From 4ee0ad81828cc8b5b778081327a15abe25b5fb03 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Mon, 27 Jul 2026 22:31:44 +0800 Subject: [PATCH] fix(integrations): retry linearGraphQl on a transient Linear 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linearGraphQl (the sole HTTP entry point for every LinearAdapter method) did one fetch and threw on any non-OK response, so a transient Linear 429 (rate limit) hard-failed on the first attempt — unlike the GitHub-backed sibling adapters, which retry via the shared GitHub client. Add a bounded retry for 429 only: honor a valid non-negative Retry-After header when present, else a capped exponential backoff, up to a small fixed cap. Any other non-OK status still throws immediately, and an exhausted 429 falls through to the same throw, preserving the existing error contract. Bounded so the caller never hangs (same intent as LINEAR_FETCH_TIMEOUT_MS). Closes #9319 Co-Authored-By: Claude Opus 4.8 --- src/integrations/linear-adapter.ts | 49 +++++++++++++++++++------ test/unit/linear-adapter.test.ts | 58 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/integrations/linear-adapter.ts b/src/integrations/linear-adapter.ts index adca146838..50aa1f93d6 100644 --- a/src/integrations/linear-adapter.ts +++ b/src/integrations/linear-adapter.ts @@ -21,18 +21,45 @@ type LinearGraphQlErrorResponse = { errors?: { message: string }[] }; * `Bearer` prefix (confirmed against linear.app/developers/graphql -- OAuth tokens use Bearer, personal API * keys do not). Throws on a transport error or a GraphQL-level `errors` array so callers can treat any * failure uniformly with a single `.catch()`. */ +// #9319: bound a transient Linear 429 (its rate-limit status) with a small retry, mirroring the shape of +// src/github/client.ts's rate-limit retry but with Linear's own status/header semantics and local constants +// (this file's convention, like LINEAR_FETCH_TIMEOUT_MS). Small cap + capped backoff so the caller never hangs. +const LINEAR_RATE_LIMIT_MAX_RETRIES = 2; +const LINEAR_RATE_LIMIT_BASE_BACKOFF_MS = 500; +const LINEAR_RATE_LIMIT_MAX_BACKOFF_MS = 4_000; + +/** Delay before retrying a Linear 429: honor a valid non-negative `Retry-After` (seconds) header when present, + * otherwise a capped exponential backoff. Always bounded by LINEAR_RATE_LIMIT_MAX_BACKOFF_MS. */ +function linearRateLimitDelayMs(retryAfterHeader: string | null, attempt: number): number { + const retryAfterSeconds = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { + return Math.min(LINEAR_RATE_LIMIT_MAX_BACKOFF_MS, retryAfterSeconds * 1000); + } + return Math.min(LINEAR_RATE_LIMIT_MAX_BACKOFF_MS, LINEAR_RATE_LIMIT_BASE_BACKOFF_MS * 2 ** attempt); +} + async function linearGraphQl(apiKey: string, query: string, variables: Record): Promise { - const response = await fetch(LINEAR_API_URL, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: apiKey }, - body: JSON.stringify({ query, variables }), - signal: AbortSignal.timeout(LINEAR_FETCH_TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`Linear API HTTP ${response.status}`); - const body = (await response.json()) as { data?: T } & LinearGraphQlErrorResponse; - if (body.errors?.length) throw new Error(`Linear API error: ${body.errors.map((e) => e.message).join("; ")}`); - if (!body.data) throw new Error("Linear API returned no data"); - return body.data; + for (let attempt = 0; ; attempt += 1) { + const response = await fetch(LINEAR_API_URL, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: apiKey }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(LINEAR_FETCH_TIMEOUT_MS), + }); + if (response.ok) { + const body = (await response.json()) as { data?: T } & LinearGraphQlErrorResponse; + if (body.errors?.length) throw new Error(`Linear API error: ${body.errors.map((e) => e.message).join("; ")}`); + if (!body.data) throw new Error("Linear API returned no data"); + return body.data; + } + // A transient 429 gets a bounded retry; any other non-OK status (a genuine error) throws immediately, and + // an exhausted 429 falls through to the same throw so the existing error contract is preserved. + if (response.status === 429 && attempt < LINEAR_RATE_LIMIT_MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, linearRateLimitDelayMs(response.headers.get("retry-after"), attempt))); + continue; + } + throw new Error(`Linear API HTTP ${response.status}`); + } } type LinearProjectNode = { id: string; name: string }; diff --git a/test/unit/linear-adapter.test.ts b/test/unit/linear-adapter.test.ts index cd7e5df006..ed3ae88af6 100644 --- a/test/unit/linear-adapter.test.ts +++ b/test/unit/linear-adapter.test.ts @@ -90,6 +90,64 @@ describe("LinearAdapter (#3186)", () => { await expect(adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" })).rejects.toThrow(/Linear API HTTP 503/); }); + it("retries a transient 429 (honoring Retry-After) then returns the successful result (#9319)", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + let calls = 0; + vi.stubGlobal("fetch", async () => { + calls += 1; + if (calls === 1) return new Response("rate limited", { status: 429, headers: { "retry-after": "0" } }); + return Response.json({ data: { projects: { nodes: [{ id: "proj-1", name: "Roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } } } }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(calls).toBe(2); // one 429 retried, then the 200 + expect(result).toEqual([{ id: "proj-1", title: "Roadmap" }]); + }); + + it("throws Linear API HTTP 429 after exhausting the retry cap (#9319)", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + let calls = 0; + vi.stubGlobal("fetch", async () => { + calls += 1; + return new Response("rate limited", { status: 429, headers: { "retry-after": "0" } }); + }); + const adapter = new LinearAdapter(); + await expect(adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" })).rejects.toThrow(/Linear API HTTP 429/); + expect(calls).toBe(3); // initial attempt + 2 retries (the retry cap) + }); + + it("falls back to a capped exponential backoff when a 429 has no Retry-After, then succeeds (#9319)", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + let calls = 0; + vi.stubGlobal("fetch", async () => { + calls += 1; + if (calls === 1) return new Response("rate limited", { status: 429 }); // no Retry-After header -> backoff path + return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(calls).toBe(2); + expect(result).toEqual([]); + }); + + it("treats a negative/invalid Retry-After as absent and uses the backoff instead (#9319)", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + let calls = 0; + vi.stubGlobal("fetch", async () => { + calls += 1; + if (calls === 1) return new Response("rate limited", { status: 429, headers: { "retry-after": "-5" } }); + return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(calls).toBe(2); + expect(result).toEqual([]); + }); + it("listOpenProjects throws when the response has no errors but also no data (malformed response)", async () => { const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" });