Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions src/client/graphql-client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,15 +32,17 @@ export class GraphQLClient {
variables?: Record<string, unknown>,
): Promise<TResult> {
try {
const response = await Promise.race([
this.rawClient.rawRequest(print(document), variables),
new Promise<never>((_, 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<never>((_, reject) =>
setTimeout(
() => reject(new Error("Request timed out")),
REQUEST_TIMEOUT_MS,
),
),
),
]);
]),
);
return response.data as TResult;
} catch (error: unknown) {
const gqlError = error as GraphQLErrorResponse;
Expand Down
46 changes: 46 additions & 0 deletions src/common/retry.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
fn: () => Promise<T>,
options?: RetryOptions,
): Promise<T> {
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<void>((r) => setTimeout(r, delay));
}
}
// unreachable, but TypeScript needs it
throw new Error("withRetry: exhausted attempts");
}
21 changes: 21 additions & 0 deletions tests/unit/client/graphql-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,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();
});
});
});
73 changes: 73 additions & 0 deletions tests/unit/common/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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
Comment on lines +38 to +62
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests for withRetry validate retry counts, but they don’t verify the exponential backoff timing (e.g., 500ms → 1s → 2s). Since backoff is a core requirement of this PR, consider adding a test with fake timers that asserts the scheduled delays (or at least the sequence of setTimeout durations) to ensure future changes don’t accidentally make the backoff linear or constant.

Copilot uses AI. Check for mistakes.
});

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);
});
});