From 5e5768a743d750e445f3467d280eac3acabccf9b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:43:57 +0200 Subject: [PATCH 1/6] build: add graphql runtime dependency --- package-lock.json | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 5ae6528..4c70392 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@linear/sdk": "82.1.0", "commander": "14.0.3", + "graphql": "16.12.0", "node-emoji": "2.2.0" }, "bin": { @@ -6101,7 +6102,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } diff --git a/package.json b/package.json index 0c63259..c06cace 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "dependencies": { "@linear/sdk": "82.1.0", "commander": "14.0.3", + "graphql": "16.12.0", "node-emoji": "2.2.0" }, "devDependencies": { From 8548ae97881bd98a93bbff7c26d59833467cf92c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:46:25 +0200 Subject: [PATCH 2/6] test: cover native graphql fetch transport --- tests/unit/client/graphql-client.test.ts | 408 ++++++++++++++--------- 1 file changed, 250 insertions(+), 158 deletions(-) diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index a1fcdd5..69de6f3 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -1,94 +1,121 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GraphQLClient } from "../../../src/client/graphql-client.js"; import { AuthenticationError } from "../../../src/common/errors.js"; -// We test the error handling logic by mocking the underlying rawRequest -// The constructor creates a real LinearClient, so we mock at module level -vi.mock("@linear/sdk", () => { - const mockRawRequest = vi.fn(); - const mockConstructorCalls: Array<{ signal?: AbortSignal }> = []; - return { - // biome-ignore lint/complexity/useArrowFunction: vitest v4 requires regular function for constructor mocks - LinearClient: vi.fn().mockImplementation(function (options?: { - signal?: AbortSignal; - }) { - mockConstructorCalls.push(options ?? {}); - return { client: { rawRequest: mockRawRequest } }; - }), - __mockRawRequest: mockRawRequest, - __mockConstructorCalls: mockConstructorCalls, - }; -}); +const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; + +function fakeDocument(): Parameters[0] { + return { kind: "Document", definitions: [] } as Parameters< + GraphQLClient["request"] + >[0]; +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + statusText: init?.statusText, + headers: { "Content-Type": "application/json", ...init?.headers }, + }); +} describe("GraphQLClient", () => { + const originalFetch = globalThis.fetch; + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as typeof fetch; + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + }); + it("can be constructed with an API token", () => { const client = new GraphQLClient("test-token"); expect(client).toBeDefined(); }); describe("request", () => { - let mockRawRequest: ReturnType; - let mockConstructorCalls: Array<{ signal?: AbortSignal }>; - - beforeEach(async () => { - const sdk = (await import("@linear/sdk")) as unknown as { - __mockRawRequest: ReturnType; - __mockConstructorCalls: Array<{ signal?: AbortSignal }>; - }; - mockRawRequest = sdk.__mockRawRequest; - mockConstructorCalls = sdk.__mockConstructorCalls; - mockRawRequest.mockReset(); - mockConstructorCalls.length = 0; - }); - - it("throws AuthenticationError on 'Authentication required' error", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Authentication required" }], - }, - }); + it("calls fetch with the Linear GraphQL endpoint", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { ok: true } })); + + const client = new GraphQLClient("test-token"); + await client.request(fakeDocument()); + + expect(fetchMock).toHaveBeenCalledWith( + LINEAR_GRAPHQL_ENDPOINT, + expect.objectContaining({ method: "POST" }), + ); + }); + + it("sends POST, expected headers, and expected JSON body", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { ok: true } })); + + const client = new GraphQLClient("test-token"); + await client.request(fakeDocument(), { issueId: "LIN-123" }); + + expect(fetchMock).toHaveBeenCalledWith( + LINEAR_GRAPHQL_ENDPOINT, + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "test-token", + "public-file-urls-expire-in": "3600", + }, + body: JSON.stringify({ + query: "", + variables: { issueId: "LIN-123" }, + }), + signal: expect.any(AbortSignal), + }), + ); + }); + + it("returns data from successful GraphQL responses", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { foo: "bar" } })); + + const client = new GraphQLClient("good-token"); + const result = await client.request<{ foo: string }>(fakeDocument()); + + expect(result).toEqual({ foo: "bar" }); + }); + + it("throws AuthenticationError on 'Authentication required' GraphQL error", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ message: "Authentication required" }] }), + ); const client = new GraphQLClient("bad-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; - await expect(client.request(fakeDoc)).rejects.toThrow( + await expect(client.request(fakeDocument())).rejects.toThrow( AuthenticationError, ); }); - it("throws AuthenticationError on 'Unauthorized' error message", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Unauthorized" }], - }, - }); + it("throws AuthenticationError on 'Unauthorized' GraphQL error", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ message: "Unauthorized" }] }), + ); const client = new GraphQLClient("bad-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; - await expect(client.request(fakeDoc)).rejects.toThrow( + await expect(client.request(fakeDocument())).rejects.toThrow( AuthenticationError, ); }); - it("throws regular Error on non-auth errors", async () => { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Entity not found" }], - }, - }); + it("throws regular Error on non-auth GraphQL errors", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ message: "Entity not found" }] }), + ); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; try { - await client.request(fakeDoc); + await client.request(fakeDocument()); expect.fail("Should have thrown"); } catch (error: unknown) { expect(error).toBeInstanceOf(Error); @@ -99,124 +126,189 @@ describe("GraphQLClient", () => { it("clears timeout timer when request succeeds before timeout", async () => { vi.useFakeTimers(); - try { - mockRawRequest.mockResolvedValueOnce({ data: { ok: true } }); - - const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { ok: true } })); - const result = await client.request<{ ok: boolean }>(fakeDoc); + const client = new GraphQLClient("good-token"); + const result = await client.request<{ ok: boolean }>(fakeDocument()); - expect(result).toEqual({ ok: true }); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } + expect(result).toEqual({ ok: true }); + expect(vi.getTimerCount()).toBe(0); }); it("clears timeout timer on non-retryable GraphQL error", async () => { vi.useFakeTimers(); - try { - mockRawRequest.mockRejectedValueOnce({ - response: { - errors: [{ message: "Entity not found" }], - }, - }); - - const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; - - await expect(client.request(fakeDoc)).rejects.toThrow( - "Entity not found", - ); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ message: "Entity not found" }] }), + ); + + const client = new GraphQLClient("good-token"); + + await expect(client.request(fakeDocument())).rejects.toThrow( + "Entity not found", + ); + expect(vi.getTimerCount()).toBe(0); }); - it("aborts in-flight request when timeout elapses", async () => { + it("clears timeout timer on non-retryable HTTP error", async () => { vi.useFakeTimers(); - try { - mockRawRequest.mockImplementation(() => { - const call = mockConstructorCalls.at(-1); - return new Promise((_, reject) => { - call?.signal?.addEventListener("abort", () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { errors: [{ message: "Bad request" }] }, + { status: 400, statusText: "Bad Request" }, + ), + ); + + const client = new GraphQLClient("good-token"); + + await expect(client.request(fakeDocument())).rejects.toThrow( + "Bad request", + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it("aborts in-flight fetch when timeout elapses", async () => { + vi.useFakeTimers(); + let capturedSignal: AbortSignal | undefined; + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + capturedSignal?.addEventListener("abort", () => { reject(new Error("aborted-by-signal")); }); }); - }); - - const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; - - const promise = client.request(fakeDoc); - const rejection = expect(promise).rejects.toThrow("Request timed out"); - await vi.runAllTimersAsync(); - - await rejection; - expect(mockConstructorCalls.at(-1)?.signal?.aborted).toBe(true); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } + }, + ); + + const client = new GraphQLClient("good-token"); + const promise = client.request(fakeDocument()); + const rejection = expect(promise).rejects.toThrow("Request timed out"); + + await vi.runAllTimersAsync(); + + await rejection; + expect(capturedSignal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("maps timeout abort to Request timed out", async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + reject( + new DOMException("This operation was aborted", "AbortError"), + ); + }); + }); + }, + ); + + const client = new GraphQLClient("good-token"); + const promise = client.request(fakeDocument()); + const rejection = expect(promise).rejects.toThrow("Request timed out"); + + await vi.runAllTimersAsync(); + await rejection; }); - it("retries on 429 and succeeds on next attempt", async () => { - const rateLimitError = { response: { status: 429 } }; - mockRawRequest - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ data: { foo: "bar" } }); + it("retries on HTTP 429 and succeeds on next attempt", async () => { + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { errors: [{ message: "Rate limited" }] }, + { status: 429, statusText: "Too Many Requests" }, + ), + ) + .mockResolvedValueOnce(jsonResponse({ data: { foo: "bar" } })); const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; + const promise = client.request(fakeDocument()); + + await vi.runAllTimersAsync(); + await expect(promise).resolves.toEqual({ foo: "bar" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + it("retries on HTTP 5xx and succeeds on next attempt", async () => { vi.useFakeTimers(); - try { - const promise = client.request(fakeDoc); - await vi.runAllTimersAsync(); - const result = await promise; - - expect(result).toEqual({ foo: "bar" }); - expect(mockRawRequest).toHaveBeenCalledTimes(2); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { message: "Upstream unavailable" }, + { status: 502, statusText: "Bad Gateway" }, + ), + ) + .mockResolvedValueOnce(jsonResponse({ data: { ok: true } })); + + const client = new GraphQLClient("good-token"); + const promise = client.request(fakeDocument()); + + await vi.runAllTimersAsync(); + await expect(promise).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); }); it("clears timeout timers across retry attempts", async () => { vi.useFakeTimers(); - try { - const rateLimitError = { response: { status: 429 } }; - mockRawRequest - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ data: { foo: "bar" } }); - - const client = new GraphQLClient("good-token"); - const fakeDoc = { kind: "Document", definitions: [] } as Parameters< - typeof client.request - >[0]; - - const promise = client.request(fakeDoc); - - // Advance only the first retry backoff (500ms), without draining unrelated timers. - await vi.advanceTimersByTimeAsync(500); - - await expect(promise).resolves.toEqual({ foo: "bar" }); - expect(mockRawRequest).toHaveBeenCalledTimes(2); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { errors: [{ message: "Rate limited" }] }, + { status: 429, statusText: "Too Many Requests" }, + ), + ) + .mockResolvedValueOnce(jsonResponse({ data: { foo: "bar" } })); + + const client = new GraphQLClient("good-token"); + const promise = client.request(fakeDocument()); + + await vi.advanceTimersByTimeAsync(500); + + await expect(promise).resolves.toEqual({ foo: "bar" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses a useful HTTP error for non-2xx invalid JSON", async () => { + fetchMock.mockResolvedValueOnce( + new Response("not-json", { status: 502, statusText: "Bad Gateway" }), + ); + + const client = new GraphQLClient("good-token"); + + await expect(client.request(fakeDocument())).rejects.toThrow( + "GraphQL request failed: HTTP 502 Bad Gateway", + ); + }); + + it("uses a useful HTTP error for non-2xx empty JSON", async () => { + fetchMock.mockResolvedValueOnce( + new Response("", { status: 502, statusText: "Bad Gateway" }), + ); + + const client = new GraphQLClient("good-token"); + + await expect(client.request(fakeDocument())).rejects.toThrow( + "GraphQL request failed: HTTP 502 Bad Gateway", + ); + }); + + it("wraps 2xx invalid JSON as a GraphQL request failure", async () => { + fetchMock.mockResolvedValueOnce( + new Response("not-json", { status: 200 }), + ); + + const client = new GraphQLClient("good-token"); + + await expect(client.request(fakeDocument())).rejects.toThrow( + "GraphQL request failed: Invalid JSON response", + ); }); }); }); From 3973d3ea6e56fb82f8b7491de00d58fdfa461bb7 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:49:48 +0200 Subject: [PATCH 3/6] test: harden graphql fetch transport tests --- tests/unit/client/graphql-client.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index 69de6f3..71c47ea 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -60,11 +60,11 @@ describe("GraphQLClient", () => { LINEAR_GRAPHQL_ENDPOINT, expect.objectContaining({ method: "POST", - headers: { + headers: expect.objectContaining({ "Content-Type": "application/json", Authorization: "test-token", "public-file-urls-expire-in": "3600", - }, + }), body: JSON.stringify({ query: "", variables: { issueId: "LIN-123" }, @@ -227,9 +227,11 @@ describe("GraphQLClient", () => { const client = new GraphQLClient("good-token"); const promise = client.request(fakeDocument()); + const expectation = expect(promise).resolves.toEqual({ foo: "bar" }); + void expectation.catch(() => undefined); await vi.runAllTimersAsync(); - await expect(promise).resolves.toEqual({ foo: "bar" }); + await expectation; expect(fetchMock).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); @@ -247,9 +249,11 @@ describe("GraphQLClient", () => { const client = new GraphQLClient("good-token"); const promise = client.request(fakeDocument()); + const expectation = expect(promise).resolves.toEqual({ ok: true }); + void expectation.catch(() => undefined); await vi.runAllTimersAsync(); - await expect(promise).resolves.toEqual({ ok: true }); + await expectation; expect(fetchMock).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); @@ -267,10 +271,12 @@ describe("GraphQLClient", () => { const client = new GraphQLClient("good-token"); const promise = client.request(fakeDocument()); + const expectation = expect(promise).resolves.toEqual({ foo: "bar" }); + void expectation.catch(() => undefined); await vi.advanceTimersByTimeAsync(500); - await expect(promise).resolves.toEqual({ foo: "bar" }); + await expectation; expect(fetchMock).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); From 4053ac87204ed29d548200bdd5282bcf2b3ae95b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:51:48 +0200 Subject: [PATCH 4/6] test: align invalid json errors with retry behavior --- tests/unit/client/graphql-client.test.ts | 64 ++++++++++++++++++++---- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index 71c47ea..f2d6a17 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -282,27 +282,71 @@ describe("GraphQLClient", () => { }); it("uses a useful HTTP error for non-2xx invalid JSON", async () => { - fetchMock.mockResolvedValueOnce( - new Response("not-json", { status: 502, statusText: "Bad Gateway" }), - ); + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce( + new Response("not-json", { + status: 502, + statusText: "Bad Gateway", + }), + ) + .mockResolvedValueOnce( + new Response("not-json", { + status: 502, + statusText: "Bad Gateway", + }), + ) + .mockResolvedValueOnce( + new Response("not-json", { + status: 502, + statusText: "Bad Gateway", + }), + ) + .mockResolvedValueOnce( + new Response("not-json", { + status: 502, + statusText: "Bad Gateway", + }), + ); const client = new GraphQLClient("good-token"); - - await expect(client.request(fakeDocument())).rejects.toThrow( + const promise = client.request(fakeDocument()); + const expectation = expect(promise).rejects.toThrow( "GraphQL request failed: HTTP 502 Bad Gateway", ); + void expectation.catch(() => undefined); + + await vi.runAllTimersAsync(); + await expectation; + expect(fetchMock).toHaveBeenCalledTimes(4); }); it("uses a useful HTTP error for non-2xx empty JSON", async () => { - fetchMock.mockResolvedValueOnce( - new Response("", { status: 502, statusText: "Bad Gateway" }), - ); + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce( + new Response("", { status: 502, statusText: "Bad Gateway" }), + ) + .mockResolvedValueOnce( + new Response("", { status: 502, statusText: "Bad Gateway" }), + ) + .mockResolvedValueOnce( + new Response("", { status: 502, statusText: "Bad Gateway" }), + ) + .mockResolvedValueOnce( + new Response("", { status: 502, statusText: "Bad Gateway" }), + ); const client = new GraphQLClient("good-token"); - - await expect(client.request(fakeDocument())).rejects.toThrow( + const promise = client.request(fakeDocument()); + const expectation = expect(promise).rejects.toThrow( "GraphQL request failed: HTTP 502 Bad Gateway", ); + void expectation.catch(() => undefined); + + await vi.runAllTimersAsync(); + await expectation; + expect(fetchMock).toHaveBeenCalledTimes(4); }); it("wraps 2xx invalid JSON as a GraphQL request failure", async () => { From 44918b6b8e179fe29599edf41b7884fd4cef6afa Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:53:57 +0200 Subject: [PATCH 5/6] feat: use native fetch for graphql transport --- src/client/graphql-client.ts | 148 +++++++++++++++++++++++++++++------ 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index db75c62..84d40ef 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -1,18 +1,106 @@ -import { LinearClient } from "@linear/sdk"; import { type DocumentNode, print } from "graphql"; import { AuthenticationError, isAuthError } from "../common/errors.js"; import { withRetry } from "../common/retry.js"; /** Default timeout for GraphQL API requests (30 seconds) */ const REQUEST_TIMEOUT_MS = 30_000; +const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; + +interface GraphQLResponseError { + message: string; +} + +interface GraphQLResponseBody { + data?: unknown; + errors?: GraphQLResponseError[]; + message?: string; +} interface GraphQLErrorResponse { response?: { - errors?: Array<{ message: string }>; + status?: number; + errors?: GraphQLResponseError[]; }; message?: string; } +class GraphQLTransportError extends Error { + readonly response: { + status?: number; + errors?: GraphQLResponseError[]; + }; + + constructor( + message: string, + response: { status?: number; errors?: GraphQLResponseError[] }, + ) { + super(message); + this.name = "GraphQLTransportError"; + this.response = response; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toGraphQLErrors(value: unknown): GraphQLResponseError[] | undefined { + if (!Array.isArray(value)) return undefined; + + const errors = value.flatMap((entry): GraphQLResponseError[] => { + if (!isRecord(entry) || typeof entry.message !== "string") return []; + return [{ message: entry.message }]; + }); + + return errors.length > 0 ? errors : undefined; +} + +async function parseJsonResponse( + response: Response, +): Promise { + const text = await response.text(); + if (text.trim() === "") return undefined; + + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) return undefined; + + return { + data: parsed.data, + errors: toGraphQLErrors(parsed.errors), + message: typeof parsed.message === "string" ? parsed.message : undefined, + }; +} + +async function parseResponseBody( + response: Response, +): Promise { + try { + return await parseJsonResponse(response); + } catch (_error: unknown) { + if (response.ok) throw new Error("Invalid JSON response"); + return undefined; + } +} + +function httpErrorMessage( + response: Response, + body: GraphQLResponseBody | undefined, +): string { + return ( + body?.errors?.[0]?.message ?? + body?.message ?? + `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}` + ); +} + +function isAbortAfterTimeout(signal: AbortSignal, error: unknown): boolean { + if (!signal.aborted) return false; + if (!(error instanceof Error)) return true; + + const message = error.message.toLowerCase(); + return error.name === "AbortError" || message.includes("aborted"); +} + export class GraphQLClient { private readonly apiToken: string; @@ -20,20 +108,6 @@ export class GraphQLClient { this.apiToken = apiToken; } - private createRawClient( - signal?: AbortSignal, - ): InstanceType["client"] { - const linearClient = new LinearClient({ - apiKey: this.apiToken, - signal, - headers: { - // Request 1-hour signed URLs for file downloads (see file-service.ts) - "public-file-urls-expire-in": "3600", - }, - }); - return linearClient.client; - } - async request( document: DocumentNode, variables?: Record, @@ -46,15 +120,41 @@ export class GraphQLClient { }, REQUEST_TIMEOUT_MS); try { - return await this.createRawClient( - timeoutController.signal, - ).rawRequest(print(document), variables); + const fetchResponse = await fetch(LINEAR_GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: this.apiToken, + // Request 1-hour signed URLs for file downloads (see file-service.ts) + "public-file-urls-expire-in": "3600", + }, + body: JSON.stringify({ query: print(document), variables }), + signal: timeoutController.signal, + }); + + const body = await parseResponseBody(fetchResponse); + + if (!fetchResponse.ok) { + throw new GraphQLTransportError( + httpErrorMessage(fetchResponse, body), + { + status: fetchResponse.status, + errors: body?.errors, + }, + ); + } + + if (body?.errors?.[0]) { + throw new GraphQLTransportError(body.errors[0].message, { + errors: body.errors, + }); + } + + if (!body) throw new Error("Invalid JSON response"); + + return { data: body.data }; } catch (error: unknown) { - if ( - timeoutController.signal.aborted && - error instanceof Error && - error.message.toLowerCase().includes("aborted") - ) { + if (isAbortAfterTimeout(timeoutController.signal, error)) { throw new Error("Request timed out"); } throw error; From 63e4c3b2f0617bcbf9fa3f86d26982fc71b5d5fe Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:57:20 +0200 Subject: [PATCH 6/6] fix: retry native fetch network failures --- src/client/graphql-client.ts | 29 +++++++++++++++--------- tests/unit/client/graphql-client.test.ts | 17 ++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index 84d40ef..da2d1f5 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -16,14 +16,6 @@ interface GraphQLResponseBody { message?: string; } -interface GraphQLErrorResponse { - response?: { - status?: number; - errors?: GraphQLResponseError[]; - }; - message?: string; -} - class GraphQLTransportError extends Error { readonly response: { status?: number; @@ -101,6 +93,20 @@ function isAbortAfterTimeout(signal: AbortSignal, error: unknown): boolean { return error.name === "AbortError" || message.includes("aborted"); } +function normalizeFetchRejection(signal: AbortSignal, error: unknown): never { + if (signal.aborted) throw error; + if (error instanceof Error) { + throw new Error(`Network error: ${error.message}`); + } + throw new Error(`Network error: ${String(error)}`); +} + +function graphQLErrorMessage(error: unknown): string { + if (!isRecord(error) || !isRecord(error.response)) return ""; + + return toGraphQLErrors(error.response.errors)?.[0]?.message ?? ""; +} + export class GraphQLClient { private readonly apiToken: string; @@ -130,7 +136,9 @@ export class GraphQLClient { }, body: JSON.stringify({ query: print(document), variables }), signal: timeoutController.signal, - }); + }).catch((error: unknown) => + normalizeFetchRejection(timeoutController.signal, error), + ); const body = await parseResponseBody(fetchResponse); @@ -164,8 +172,7 @@ export class GraphQLClient { }); return response.data as TResult; } catch (error: unknown) { - const gqlError = error as GraphQLErrorResponse; - const errorMessage = gqlError.response?.errors?.[0]?.message ?? ""; + const errorMessage = graphQLErrorMessage(error); if (isAuthError(new Error(errorMessage))) { throw new AuthenticationError(errorMessage || undefined); diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index f2d6a17..832598f 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -258,6 +258,23 @@ describe("GraphQLClient", () => { expect(vi.getTimerCount()).toBe(0); }); + it("retries native fetch rejected network failures and succeeds on next attempt", async () => { + vi.useFakeTimers(); + fetchMock + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce(jsonResponse({ data: { ok: true } })); + + const client = new GraphQLClient("good-token"); + const promise = client.request(fakeDocument()); + const expectation = expect(promise).resolves.toEqual({ ok: true }); + void expectation.catch(() => undefined); + + await vi.runAllTimersAsync(); + await expectation; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + it("clears timeout timers across retry attempts", async () => { vi.useFakeTimers(); fetchMock