diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index 03840b8..926e5f5 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -1,6 +1,7 @@ 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; @@ -31,15 +32,17 @@ export class GraphQLClient { variables?: Record, ): Promise { try { - const response = await Promise.race([ - this.rawClient.rawRequest(print(document), variables), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Request timed out")), - REQUEST_TIMEOUT_MS, + const response = await withRetry(() => + Promise.race([ + this.rawClient.rawRequest(print(document), variables), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Request timed out")), + REQUEST_TIMEOUT_MS, + ), ), - ), - ]); + ]), + ); return response.data as TResult; } catch (error: unknown) { const gqlError = error as GraphQLErrorResponse; diff --git a/src/common/retry.ts b/src/common/retry.ts new file mode 100644 index 0000000..6c53502 --- /dev/null +++ b/src/common/retry.ts @@ -0,0 +1,46 @@ +interface RetryOptions { + maxRetries?: number; + baseDelayMs?: number; +} + +interface RetryableError { + response?: { + status?: number; + }; +} + +export function isRetryable(error: unknown): boolean { + const err = error as RetryableError; + const status = err?.response?.status; + if (typeof status === "number") { + return status === 429 || (status >= 500 && status < 600); + } + // network-level errors (ECONNRESET, ETIMEDOUT, etc.) + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return ( + msg.includes("timed out") || + msg.includes("econnreset") || + msg.includes("network") + ); + } + return false; +} + +export async function withRetry( + fn: () => Promise, + options?: RetryOptions, +): Promise { + const { maxRetries = 3, baseDelayMs = 500 } = options ?? {}; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxRetries || !isRetryable(error)) throw error; + const delay = baseDelayMs * 2 ** attempt; + await new Promise((r) => setTimeout(r, delay)); + } + } + // unreachable, but TypeScript needs it + throw new Error("withRetry: exhausted attempts"); +} diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index d901ea4..5862b7d 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -87,5 +87,26 @@ describe("GraphQLClient", () => { expect((error as Error).message).toBe("Entity not found"); } }); + + it("retries on 429 and succeeds on next attempt", async () => { + 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]; + + vi.useFakeTimers(); + const promise = client.request(fakeDoc); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual({ foo: "bar" }); + expect(mockRawRequest).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); }); }); diff --git a/tests/unit/common/retry.test.ts b/tests/unit/common/retry.test.ts new file mode 100644 index 0000000..88fd572 --- /dev/null +++ b/tests/unit/common/retry.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { isRetryable, withRetry } from "../../../src/common/retry.js"; + +describe("isRetryable", () => { + it("returns true for 429", () => { + expect(isRetryable({ response: { status: 429 } })).toBe(true); + }); + + it("returns true for 500", () => { + expect(isRetryable({ response: { status: 500 } })).toBe(true); + }); + + it("returns true for 503", () => { + expect(isRetryable({ response: { status: 503 } })).toBe(true); + }); + + it("returns false for 400", () => { + expect(isRetryable({ response: { status: 400 } })).toBe(false); + }); + + it("returns false for 404", () => { + expect(isRetryable({ response: { status: 404 } })).toBe(false); + }); + + it("returns false for auth errors", () => { + expect(isRetryable({ response: { status: 401 } })).toBe(false); + }); + + it("returns true for timeout errors", () => { + expect(isRetryable(new Error("Request timed out"))).toBe(true); + }); + + it("returns false for generic errors", () => { + expect(isRetryable(new Error("Entity not found"))).toBe(false); + }); +}); + +describe("withRetry", () => { + it("returns result on first success", async () => { + const fn = vi.fn().mockResolvedValueOnce("ok"); + const result = await withRetry(fn, { baseDelayMs: 1 }); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries on 429 and succeeds", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ response: { status: 429 } }) + .mockResolvedValueOnce("ok"); + const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 1 }); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("retries on 503 up to maxRetries then throws", async () => { + const err = { response: { status: 503 } }; + const fn = vi.fn().mockRejectedValue(err); + await expect( + withRetry(fn, { maxRetries: 2, baseDelayMs: 1 }), + ).rejects.toEqual(err); + expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries + }); + + it("does not retry non-retryable errors", async () => { + const err = new Error("Entity not found"); + const fn = vi.fn().mockRejectedValue(err); + await expect(withRetry(fn, { baseDelayMs: 1 })).rejects.toThrow( + "Entity not found", + ); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("uses exponential backoff: 500ms → 1s → 2s", async () => { + vi.useFakeTimers(); + const delays: number[] = []; + const realSetTimeout = globalThis.setTimeout; + + // track each setTimeout call to capture the delay values + const spy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((cb: TimerHandler, ms?: number) => { + delays.push(ms ?? 0); + return realSetTimeout(cb as () => void, 0); + }); + + const err = { response: { status: 503 } }; + const fn = vi + .fn() + .mockRejectedValueOnce(err) + .mockRejectedValueOnce(err) + .mockRejectedValueOnce(err) + .mockResolvedValueOnce("ok"); + + const promise = withRetry(fn, { maxRetries: 3, baseDelayMs: 500 }); + await vi.runAllTimersAsync(); + await promise; + + spy.mockRestore(); + vi.useRealTimers(); + + expect(delays).toEqual([500, 1000, 2000]); + }); +});