From 3793d548cdb022caf9f86a9e23c8f7f9426e963c Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Fri, 8 May 2026 21:38:52 -0700 Subject: [PATCH 1/2] fix: improve Linear API timeout errors --- packages/cli/src/bin/linear.ts | 20 +- packages/cli/src/index.ts | 10 +- packages/cli/src/runtime/options.ts | 10 + packages/cli/tests/options.test.ts | 63 ++++-- packages/linear-core/src/auth/oauth.ts | 4 + packages/linear-core/src/auth/session.ts | 45 +++- packages/linear-core/src/errors/core-error.ts | 206 ++++++++++++++++++ packages/linear-core/tests/core-error.test.ts | 132 +++++++++++ 8 files changed, 459 insertions(+), 31 deletions(-) create mode 100644 packages/linear-core/tests/core-error.test.ts diff --git a/packages/cli/src/bin/linear.ts b/packages/cli/src/bin/linear.ts index 6e05b07..8e77891 100644 --- a/packages/cli/src/bin/linear.ts +++ b/packages/cli/src/bin/linear.ts @@ -3,8 +3,18 @@ import { createProgram } from "../index.js"; const program = createProgram(); -program.parseAsync(process.argv).catch((error: unknown) => { - const message = error instanceof Error ? error.message : "Unknown CLI error"; - console.error(message); - process.exitCode = 1; -}); +const drainAndExit = (code: number): void => { + Promise.all([ + new Promise((resolve) => process.stdout.write("", () => resolve())), + new Promise((resolve) => process.stderr.write("", () => resolve())), + ]).finally(() => process.exit(code)); +}; + +program + .parseAsync(process.argv) + .then(() => drainAndExit(typeof process.exitCode === "number" ? process.exitCode : 0)) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown CLI error"; + console.error(message); + drainAndExit(1); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 531643b..2a00dd1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -319,7 +319,10 @@ export function createProgram(authManager = new AuthManager()): Command { .option("--sort ", "Sort by a field, prefix with - for descending") .option("--view ", "Human output preset: table | detail | dense") .option("--all", "Drain all pages before filtering") - .option("--fields ", "Comma-separated field selection for human output"); + .option("--fields ", "Comma-separated field selection for human output") + .option("--timeout ", "Per-request network timeout in seconds (default 30)", (value) => + Number.parseInt(value, 10), + ); const authCommand = program.command("auth").description("Authentication commands"); @@ -490,7 +493,10 @@ export function createProgram(authManager = new AuthManager()): Command { const openSessionForCommand = async (cmd: Command) => { const globals = getGlobalOptions(cmd); - return authManager.openSession({ profile: globals.profile }); + return authManager.openSession({ + profile: globals.profile, + ...(globals.timeoutMs !== undefined ? { timeoutMs: globals.timeoutMs } : {}), + }); }; const sessionGateway = async (cmd: Command) => (await openSessionForCommand(cmd)).gateway; diff --git a/packages/cli/src/runtime/options.ts b/packages/cli/src/runtime/options.ts index f6ae31c..e525ccb 100644 --- a/packages/cli/src/runtime/options.ts +++ b/packages/cli/src/runtime/options.ts @@ -21,8 +21,11 @@ export interface GlobalOptions { readonly view?: "table" | "detail" | "dense"; readonly all?: boolean; readonly fields?: readonly string[]; + readonly timeoutMs?: number; } +const DEFAULT_TIMEOUT_SECONDS = 30; + export function getGlobalOptions(command: Command): GlobalOptions { const rawValue = command.optsWithGlobals(); const raw = isRecord(rawValue) ? rawValue : {}; @@ -31,6 +34,12 @@ export function getGlobalOptions(command: Command): GlobalOptions { .map((value) => value.trim()) .filter((value) => value.length > 0); + const timeoutSeconds = readNumber(raw.timeout); + const timeoutMs = + timeoutSeconds !== undefined && timeoutSeconds > 0 + ? timeoutSeconds * 1000 + : DEFAULT_TIMEOUT_SECONDS * 1000; + return { json: readBoolean(raw.json), quiet: readBoolean(raw.quiet), @@ -51,5 +60,6 @@ export function getGlobalOptions(command: Command): GlobalOptions { ...(readString(raw.view) ? { view: readString(raw.view) as "table" | "detail" | "dense" } : {}), ...(readBoolean(raw.all) ? { all: true } : {}), ...(fields && fields.length > 0 ? { fields } : {}), + timeoutMs, }; } diff --git a/packages/cli/tests/options.test.ts b/packages/cli/tests/options.test.ts index 94eda58..dd8fbff 100644 --- a/packages/cli/tests/options.test.ts +++ b/packages/cli/tests/options.test.ts @@ -2,29 +2,35 @@ import { Command } from "commander"; import { describe, expect, test } from "vitest"; import { getGlobalOptions } from "../src/runtime/options.js"; +function buildProgram(): Command { + const program = new Command(); + program + .option("--json") + .option("--profile ") + .option("--team ") + .option("--limit ", (value) => Number.parseInt(value, 10)) + .option("--cursor ") + .option("--quiet") + .option("--mine") + .option("--project ") + .option("--cycle ") + .option("--state ") + .option("--assignee ") + .option("--label ") + .option("--priority ") + .option("--status ") + .option("--filter ") + .option("--sort ") + .option("--view ") + .option("--all") + .option("--fields ") + .option("--timeout ", "Per-request timeout", (value) => Number.parseInt(value, 10)); + return program; +} + describe("getGlobalOptions", () => { test("parses shared v2 query and presentation options", () => { - const program = new Command(); - program - .option("--json") - .option("--profile ") - .option("--team ") - .option("--limit ", (value) => Number.parseInt(value, 10)) - .option("--cursor ") - .option("--quiet") - .option("--mine") - .option("--project ") - .option("--cycle ") - .option("--state ") - .option("--assignee ") - .option("--label ") - .option("--priority ") - .option("--status ") - .option("--filter ") - .option("--sort ") - .option("--view ") - .option("--all") - .option("--fields "); + const program = buildProgram(); program.parse([ "node", @@ -80,6 +86,21 @@ describe("getGlobalOptions", () => { view: "detail", all: true, fields: ["identifier", "title", "assigneeName"], + timeoutMs: 30_000, }); }); + + test("converts --timeout seconds to timeoutMs", () => { + const program = buildProgram(); + program.parse(["node", "linear", "--timeout", "5"]); + + expect(getGlobalOptions(program).timeoutMs).toBe(5_000); + }); + + test("defaults timeoutMs to 30s when --timeout absent", () => { + const program = buildProgram(); + program.parse(["node", "linear"]); + + expect(getGlobalOptions(program).timeoutMs).toBe(30_000); + }); }); diff --git a/packages/linear-core/src/auth/oauth.ts b/packages/linear-core/src/auth/oauth.ts index ad8b645..546058f 100644 --- a/packages/linear-core/src/auth/oauth.ts +++ b/packages/linear-core/src/auth/oauth.ts @@ -29,12 +29,14 @@ interface TokenExchangeInput { readonly code: string; readonly redirectUri: string; readonly codeVerifier: string; + readonly signal?: AbortSignal; } interface TokenRefreshInput { readonly clientId: string; readonly tokenUrl: string; readonly refreshToken: string; + readonly signal?: AbortSignal; } const tokenResponseSchema = z.object({ @@ -172,6 +174,7 @@ export async function exchangeAuthorizationCode( "content-type": "application/x-www-form-urlencoded", }, body, + signal: input.signal, }); return parseTokenResponse(response); @@ -193,6 +196,7 @@ export async function refreshOAuthToken( "content-type": "application/x-www-form-urlencoded", }, body, + signal: input.signal, }); const token = await parseTokenResponse(response); diff --git a/packages/linear-core/src/auth/session.ts b/packages/linear-core/src/auth/session.ts index 933fc5f..c6a3a41 100644 --- a/packages/linear-core/src/auth/session.ts +++ b/packages/linear-core/src/auth/session.ts @@ -44,12 +44,20 @@ export interface AuthorizationCodeLoginInput { readonly code: string; readonly redirectUri: string; readonly codeVerifier: string; + readonly signal?: AbortSignal; } export interface RefreshTokenInput { readonly profile: string; readonly clientId: string; readonly tokenUrl: string; + readonly signal?: AbortSignal; +} + +export interface OpenSessionOptions { + readonly profile?: string; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; } export interface ActiveSession { @@ -59,6 +67,26 @@ export interface ActiveSession { readonly credentials: StoredCredentials; } +function resolveRequestSignal(options?: OpenSessionOptions): AbortSignal | undefined { + if (!options) { + return undefined; + } + const signals: AbortSignal[] = []; + if (options.signal) { + signals.push(options.signal); + } + if (typeof options.timeoutMs === "number" && options.timeoutMs > 0) { + signals.push(AbortSignal.timeout(options.timeoutMs)); + } + if (signals.length === 0) { + return undefined; + } + if (signals.length === 1) { + return signals[0]; + } + return AbortSignal.any(signals); +} + function toStoredCredentials(token: OAuthToken): StoredCredentials { return { accessToken: token.accessToken, @@ -138,6 +166,7 @@ export class AuthManager { code: input.code, redirectUri: input.redirectUri, codeVerifier: input.codeVerifier, + signal: input.signal, }); await this.loginWithToken({ @@ -163,6 +192,7 @@ export class AuthManager { clientId: input.clientId, tokenUrl: input.tokenUrl, refreshToken: existing.refreshToken, + signal: input.signal, }); await store.set(input.profile, { @@ -210,13 +240,14 @@ export class AuthManager { }; } - public async openSession(options?: { readonly profile?: string }): Promise { + public async openSession(options?: OpenSessionOptions): Promise { const config = await this.configStore.load(); const selectedProfile = options?.profile ?? config.defaultProfile; const selected = config.profiles[selectedProfile]; const store = await this.credentialsStore(); const credentials = await store.get(selectedProfile); const oauthConfig = selected?.oauth; + const requestSignal = resolveRequestSignal(options); if (credentials?.accessToken) { let token: OAuthToken = { @@ -230,6 +261,7 @@ export class AuthManager { clientId: oauthConfig.clientId, tokenUrl: oauthConfig.tokenUrl, refreshToken: token.refreshToken, + signal: requestSignal, }); token = refreshed; await store.set(selectedProfile, { @@ -240,6 +272,7 @@ export class AuthManager { const client = new LinearClient({ accessToken: token.accessToken, + ...(requestSignal ? { signal: requestSignal } : {}), }); return { @@ -255,7 +288,10 @@ export class AuthManager { } if (credentials?.apiKey) { - const client = new LinearClient({ apiKey: credentials.apiKey }); + const client = new LinearClient({ + apiKey: credentials.apiKey, + ...(requestSignal ? { signal: requestSignal } : {}), + }); return { profile: selectedProfile, client, @@ -268,7 +304,10 @@ export class AuthManager { const envApiKey = process.env.LINEAR_API_KEY; if (envApiKey) { - const client = new LinearClient({ apiKey: envApiKey }); + const client = new LinearClient({ + apiKey: envApiKey, + ...(requestSignal ? { signal: requestSignal } : {}), + }); return { profile: selectedProfile, client, diff --git a/packages/linear-core/src/errors/core-error.ts b/packages/linear-core/src/errors/core-error.ts index 48adc84..a2aac4a 100644 --- a/packages/linear-core/src/errors/core-error.ts +++ b/packages/linear-core/src/errors/core-error.ts @@ -7,6 +7,16 @@ export type LinearCoreErrorCode = | "ENTITY_NOT_FOUND" | "UNSUPPORTED_OPERATION" | "LINEAR_API_ERROR" + | "NetworkError" + | "Timeout" + | "Ratelimited" + | "AuthenticationError" + | "Forbidden" + | "InvalidInput" + | "InternalError" + | "LockTimeout" + | "UsageLimitExceeded" + | "FeatureNotAccessible" | "UNKNOWN"; export class LinearCoreError extends Error { @@ -25,16 +35,212 @@ export class LinearCoreError extends Error { } } +const SDK_TYPE_TO_CODE: Record = { + NetworkError: "NetworkError", + Ratelimited: "Ratelimited", + AuthenticationError: "AuthenticationError", + Forbidden: "Forbidden", + InvalidInput: "InvalidInput", + InternalError: "InternalError", + LockTimeout: "LockTimeout", + UsageLimitExceeded: "UsageLimitExceeded", + FeatureNotAccessible: "FeatureNotAccessible", +}; + +const TRANSIENT_CODES: ReadonlySet = new Set([ + "NetworkError", + "Timeout", + "Ratelimited", + "InternalError", + "LockTimeout", +]); + +const NETWORK_CAUSE_CODES: ReadonlySet = new Set([ + "ENOTFOUND", + "ECONNREFUSED", + "ECONNRESET", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + "EPIPE", +]); + +const TIMEOUT_CAUSE_CODES: ReadonlySet = new Set([ + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + "ETIMEDOUT", +]); + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } +function setDetail(details: Record, key: string, value: unknown): void { + if (value === undefined || value === null) { + return; + } + if (typeof value === "string") { + if (value.length > 0) { + details[key] = value; + } + return; + } + if (typeof value === "number" || typeof value === "boolean") { + details[key] = String(value); + return; + } +} + +function transientFlag(code: LinearCoreErrorCode): "true" | "false" { + return TRANSIENT_CODES.has(code) ? "true" : "false"; +} + +function isLinearSdkError(value: unknown): value is { + readonly name: string; + readonly message: string; + readonly type?: string; + readonly status?: number; + readonly errors?: ReadonlyArray<{ + readonly type?: string; + readonly message?: string; + readonly userError?: boolean; + readonly path?: readonly string[]; + }>; + readonly raw?: unknown; + readonly retryAfter?: number; + readonly requestsResetAt?: number; +} { + if (!(value instanceof Error)) { + return false; + } + if (typeof value.name !== "string" || !value.name.endsWith("LinearError")) { + return false; + } + return "type" in value || "errors" in value || "raw" in value; +} + +function firstUserPresentableMessage(errors: unknown): string | undefined { + if (!Array.isArray(errors)) { + return undefined; + } + for (const entry of errors) { + if (isRecord(entry) && typeof entry.message === "string" && entry.message.length > 0) { + return entry.message; + } + } + return undefined; +} + +function normalizeSdkError(error: ReturnType): LinearCoreError { + const details: Record = {}; + const sdkType = typeof error.type === "string" ? error.type : undefined; + setDetail(details, "type", sdkType); + setDetail(details, "status", error.status); + + let code: LinearCoreErrorCode = "LINEAR_API_ERROR"; + if (sdkType && SDK_TYPE_TO_CODE[sdkType]) { + code = SDK_TYPE_TO_CODE[sdkType] as LinearCoreErrorCode; + } else if (typeof error.status === "number" && error.status >= 500) { + code = "InternalError"; + } else if (sdkType === "Unknown" || sdkType === "Other" || sdkType === "GraphqlError") { + code = "LINEAR_API_ERROR"; + } + + if (code === "Ratelimited") { + setDetail(details, "retryAfter", error.retryAfter); + setDetail(details, "requestsResetAt", error.requestsResetAt); + } + + const friendly = firstUserPresentableMessage(error.errors); + setDetail(details, "userPresentableMessage", friendly); + + details.transient = transientFlag(code); + + return new LinearCoreError(code, error.message || friendly || "Linear API error", details); +} + +function asAny(value: unknown): { + readonly name: string; + readonly message: string; + readonly type?: string; + readonly status?: number; + readonly errors?: ReadonlyArray<{ readonly message?: string }>; + readonly retryAfter?: number; + readonly requestsResetAt?: number; +} { + return value as { + readonly name: string; + readonly message: string; + readonly type?: string; + readonly status?: number; + readonly errors?: ReadonlyArray<{ readonly message?: string }>; + readonly retryAfter?: number; + readonly requestsResetAt?: number; + }; +} + +function readCauseCode(error: Error): string | undefined { + const cause = (error as { cause?: unknown }).cause; + if (!isRecord(cause)) { + return undefined; + } + if (typeof cause.code === "string") { + return cause.code; + } + return undefined; +} + +function isAbortLike(error: Error): boolean { + if (error.name === "AbortError" || error.name === "TimeoutError") { + return true; + } + const code = (error as { code?: unknown }).code; + if (code === "ABORT_ERR" || code === "ETIMEDOUT") { + return true; + } + const causeCode = readCauseCode(error); + if (causeCode && TIMEOUT_CAUSE_CODES.has(causeCode)) { + return true; + } + return false; +} + export function normalizeError(error: unknown): LinearCoreError { if (error instanceof LinearCoreError) { return error; } + if (isLinearSdkError(error)) { + return normalizeSdkError(asAny(error)); + } + if (error instanceof Error) { + if (isAbortLike(error)) { + const details: Record = { transient: "true" }; + const causeCode = readCauseCode(error); + setDetail(details, "cause", causeCode); + return new LinearCoreError("Timeout", error.message || "Request timed out", details); + } + + const causeCode = readCauseCode(error); + if (causeCode) { + const details: Record = { cause: causeCode }; + const code: LinearCoreErrorCode = TIMEOUT_CAUSE_CODES.has(causeCode) + ? "Timeout" + : NETWORK_CAUSE_CODES.has(causeCode) + ? "NetworkError" + : "NetworkError"; + details.transient = transientFlag(code); + return new LinearCoreError(code, `${error.message} (${causeCode})`, details); + } + + if (error instanceof TypeError && /fetch failed/i.test(error.message)) { + return new LinearCoreError("NetworkError", error.message, { + transient: transientFlag("NetworkError"), + }); + } + return new LinearCoreError("UNKNOWN", error.message); } diff --git a/packages/linear-core/tests/core-error.test.ts b/packages/linear-core/tests/core-error.test.ts new file mode 100644 index 0000000..7ffa444 --- /dev/null +++ b/packages/linear-core/tests/core-error.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "vitest"; +import { LinearCoreError, normalizeError } from "../src/errors/core-error.js"; + +class FakeAuthenticationLinearError extends Error { + public readonly type = "AuthenticationError"; + public readonly status = 401; + public readonly errors = [ + { message: "Invalid token", type: "AuthenticationError", userError: false }, + ]; + public readonly raw = {}; + + public constructor(message: string) { + super(message); + this.name = "AuthenticationLinearError"; + } +} + +class FakeRatelimitedLinearError extends Error { + public readonly type = "Ratelimited"; + public readonly status = 429; + public readonly retryAfter = 30; + public readonly requestsResetAt = 1_700_000_000; + public readonly errors = [{ message: "Too many requests" }]; + + public constructor() { + super("Rate limited"); + this.name = "RatelimitedLinearError"; + } +} + +class FakeNetworkLinearError extends Error { + public readonly type = "NetworkError"; + public readonly errors = []; + public readonly raw = {}; + + public constructor() { + super("Connection lost"); + this.name = "NetworkLinearError"; + } +} + +describe("normalizeError", () => { + test("passes through LinearCoreError", () => { + const original = new LinearCoreError("AUTH_REQUIRED", "Login required"); + expect(normalizeError(original)).toBe(original); + }); + + test("maps SDK AuthenticationLinearError to AuthenticationError code", () => { + const result = normalizeError(new FakeAuthenticationLinearError("Invalid token")); + + expect(result.code).toBe("AuthenticationError"); + expect(result.message).toBe("Invalid token"); + expect(result.details.type).toBe("AuthenticationError"); + expect(result.details.status).toBe("401"); + expect(result.details.transient).toBe("false"); + expect(result.details.userPresentableMessage).toBe("Invalid token"); + }); + + test("maps SDK RatelimitedLinearError and surfaces retryAfter", () => { + const result = normalizeError(new FakeRatelimitedLinearError()); + + expect(result.code).toBe("Ratelimited"); + expect(result.details.retryAfter).toBe("30"); + expect(result.details.requestsResetAt).toBe("1700000000"); + expect(result.details.transient).toBe("true"); + }); + + test("maps SDK NetworkLinearError to NetworkError code", () => { + const result = normalizeError(new FakeNetworkLinearError()); + + expect(result.code).toBe("NetworkError"); + expect(result.details.transient).toBe("true"); + }); + + test("maps AbortSignal.timeout() abort to Timeout code", () => { + const error = new Error("The operation was aborted"); + error.name = "TimeoutError"; + + const result = normalizeError(error); + + expect(result.code).toBe("Timeout"); + expect(result.details.transient).toBe("true"); + }); + + test("maps AbortError to Timeout", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + + expect(normalizeError(error).code).toBe("Timeout"); + }); + + test("maps native fetch TypeError with cause.code ENOTFOUND to NetworkError", () => { + const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api.linear.app"), { + code: "ENOTFOUND", + }); + const fetchError = Object.assign(new TypeError("fetch failed"), { cause }); + + const result = normalizeError(fetchError); + + expect(result.code).toBe("NetworkError"); + expect(result.details.cause).toBe("ENOTFOUND"); + expect(result.details.transient).toBe("true"); + }); + + test("maps undici connect timeout cause to Timeout", () => { + const cause = Object.assign(new Error("Connect Timeout Error"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }); + const fetchError = Object.assign(new TypeError("fetch failed"), { cause }); + + const result = normalizeError(fetchError); + + expect(result.code).toBe("Timeout"); + expect(result.details.cause).toBe("UND_ERR_CONNECT_TIMEOUT"); + }); + + test("falls back to UNKNOWN for plain errors with no signal", () => { + const result = normalizeError(new Error("boom")); + expect(result.code).toBe("UNKNOWN"); + expect(result.message).toBe("boom"); + }); + + test("maps record-shaped error to LINEAR_API_ERROR", () => { + const result = normalizeError({ message: "graphql failed" }); + expect(result.code).toBe("LINEAR_API_ERROR"); + }); + + test("returns UNKNOWN for non-error values", () => { + expect(normalizeError(undefined).code).toBe("UNKNOWN"); + expect(normalizeError("nope").code).toBe("UNKNOWN"); + }); +}); From c7a2cd1d88021a96095d67af6cd58f2353e93e0b Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Fri, 8 May 2026 21:48:15 -0700 Subject: [PATCH 2/2] fix: address PR timeout review feedback --- packages/cli/src/index.ts | 2 +- packages/cli/src/runtime/options.ts | 2 +- packages/linear-core/src/errors/core-error.ts | 23 +++++++------------ packages/linear-core/tests/core-error.test.ts | 9 ++++++++ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1cb370c..6e409b3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -495,7 +495,7 @@ export function createProgram(authManager = new AuthManager()): Command { const globals = getGlobalOptions(cmd); return authManager.openSession({ profile: globals.profile, - ...(globals.timeoutMs !== undefined ? { timeoutMs: globals.timeoutMs } : {}), + timeoutMs: globals.timeoutMs, }); }; diff --git a/packages/cli/src/runtime/options.ts b/packages/cli/src/runtime/options.ts index e525ccb..d3e9ccb 100644 --- a/packages/cli/src/runtime/options.ts +++ b/packages/cli/src/runtime/options.ts @@ -21,7 +21,7 @@ export interface GlobalOptions { readonly view?: "table" | "detail" | "dense"; readonly all?: boolean; readonly fields?: readonly string[]; - readonly timeoutMs?: number; + readonly timeoutMs: number; } const DEFAULT_TIMEOUT_SECONDS = 30; diff --git a/packages/linear-core/src/errors/core-error.ts b/packages/linear-core/src/errors/core-error.ts index a2aac4a..328b06e 100644 --- a/packages/linear-core/src/errors/core-error.ts +++ b/packages/linear-core/src/errors/core-error.ts @@ -55,16 +55,6 @@ const TRANSIENT_CODES: ReadonlySet = new Set([ "LockTimeout", ]); -const NETWORK_CAUSE_CODES: ReadonlySet = new Set([ - "ENOTFOUND", - "ECONNREFUSED", - "ECONNRESET", - "EAI_AGAIN", - "EHOSTUNREACH", - "ENETUNREACH", - "EPIPE", -]); - const TIMEOUT_CAUSE_CODES: ReadonlySet = new Set([ "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", @@ -191,11 +181,16 @@ function readCauseCode(error: Error): string | undefined { return undefined; } +function readErrorCode(error: Error): string | undefined { + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + function isAbortLike(error: Error): boolean { if (error.name === "AbortError" || error.name === "TimeoutError") { return true; } - const code = (error as { code?: unknown }).code; + const code = readErrorCode(error); if (code === "ABORT_ERR" || code === "ETIMEDOUT") { return true; } @@ -218,7 +213,7 @@ export function normalizeError(error: unknown): LinearCoreError { if (error instanceof Error) { if (isAbortLike(error)) { const details: Record = { transient: "true" }; - const causeCode = readCauseCode(error); + const causeCode = readCauseCode(error) ?? readErrorCode(error); setDetail(details, "cause", causeCode); return new LinearCoreError("Timeout", error.message || "Request timed out", details); } @@ -228,9 +223,7 @@ export function normalizeError(error: unknown): LinearCoreError { const details: Record = { cause: causeCode }; const code: LinearCoreErrorCode = TIMEOUT_CAUSE_CODES.has(causeCode) ? "Timeout" - : NETWORK_CAUSE_CODES.has(causeCode) - ? "NetworkError" - : "NetworkError"; + : "NetworkError"; details.transient = transientFlag(code); return new LinearCoreError(code, `${error.message} (${causeCode})`, details); } diff --git a/packages/linear-core/tests/core-error.test.ts b/packages/linear-core/tests/core-error.test.ts index 7ffa444..ad05c23 100644 --- a/packages/linear-core/tests/core-error.test.ts +++ b/packages/linear-core/tests/core-error.test.ts @@ -89,6 +89,15 @@ describe("normalizeError", () => { expect(normalizeError(error).code).toBe("Timeout"); }); + test("surfaces top-level abort error code as timeout cause", () => { + const error = Object.assign(new Error("connect timed out"), { code: "ETIMEDOUT" }); + + const result = normalizeError(error); + + expect(result.code).toBe("Timeout"); + expect(result.details.cause).toBe("ETIMEDOUT"); + }); + test("maps native fetch TypeError with cause.code ENOTFOUND to NetworkError", () => { const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api.linear.app"), { code: "ENOTFOUND",