diff --git a/src/index.ts b/src/index.ts index 4471b49..a78fb76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { getCLIWarnings, parseCLIArgs } from "./args"; import { log, setStderr } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; +import { withRetry } from "./retry"; declare const CLI_VERSION: string; @@ -116,6 +117,10 @@ const options: LinearClientOptions = { const linearClient = new LinearClient(options); linearClient.client.setHeader("User-Agent", buildUserAgent()); +async function apiRequest(query: string, variables?: Record): Promise { + return withRetry(() => linearClient.client.rawRequest(query, variables)) as Promise; +} + function scanCommits( commits: CommitContext[], includePaths: string[] | null, @@ -380,7 +385,7 @@ async function updateCommand(): Promise<{ } async function getLatestRelease(): Promise { - const response = (await linearClient.client.rawRequest( + const response = await apiRequest( ` query latestReleaseByAccessKey { latestReleaseByAccessKey { @@ -391,7 +396,7 @@ async function getLatestRelease(): Promise { } } `, - )) as AccessKeyLatestReleaseResponse; + ); return response.data.latestReleaseByAccessKey; } @@ -417,7 +422,7 @@ async function getLatestSha(): Promise { } async function getPipelineSettings(): Promise<{ includePathPatterns: string[] }> { - const response = (await linearClient.client.rawRequest( + const response = await apiRequest( ` query pipelineSettingsByAccessKey { releasePipelineByAccessKey { @@ -425,7 +430,7 @@ async function getPipelineSettings(): Promise<{ includePathPatterns: string[] }> } } `, - )) as AccessKeyPipelineSettingsResponse; + ); return { includePathPatterns: response.data.releasePipelineByAccessKey.includePathPatterns ?? [], @@ -449,7 +454,7 @@ async function syncRelease( const { owner, name } = repoInfo ?? {}; - const response = (await linearClient.client.rawRequest( + const response = await apiRequest( ` mutation syncReleaseByAccessKey($input: ReleaseSyncInputBase!) { releaseSyncByAccessKey(input: $input) { @@ -487,7 +492,7 @@ async function syncRelease( debugSink, }, }, - )) as AccessKeySyncReleaseResponse; + ); if (!response.data?.releaseSyncByAccessKey?.release) { throw new Error("Failed to sync release"); @@ -502,7 +507,7 @@ async function completeRelease(options: { }): Promise<{ success: boolean; release: { id: string; name: string; version?: string; url?: string } | null }> { const { version, commitSha } = options; - const response = (await linearClient.client.rawRequest( + const response = await apiRequest( ` mutation releaseCompleteByAccessKey($input: ReleaseCompleteInputBase!) { releaseCompleteByAccessKey(input: $input) { @@ -522,7 +527,7 @@ async function completeRelease(options: { commitSha, }, }, - )) as AccessKeyCompleteReleaseResponse; + ); return response.data.releaseCompleteByAccessKey; } @@ -539,7 +544,7 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri .filter(Boolean) .map((s) => s.slice(2)) .join(", "); - const response = (await linearClient.client.rawRequest( + const response = await apiRequest( ` mutation { releaseUpdateByPipelineByAccessKey(input: { ${inputParts} }) { @@ -556,7 +561,7 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri } } `, - )) as AccessKeyUpdateByPipelineResponse; + ); const result = response.data.releaseUpdateByPipelineByAccessKey; return { diff --git a/src/retry.test.ts b/src/retry.test.ts new file mode 100644 index 0000000..d49b495 --- /dev/null +++ b/src/retry.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LinearError, LinearErrorType, RatelimitedLinearError } from "@linear/sdk"; +import { withRetry } from "./retry"; + +function makeLinearError(type: LinearErrorType, status?: number): LinearError { + const error = new LinearError(); + error.type = type; + if (status !== undefined) { + error.status = status; + } + return error; +} + +function makeRateLimitedError(retryAfterSeconds?: number): RatelimitedLinearError { + const error = new RatelimitedLinearError(); + error.type = LinearErrorType.Ratelimited; + error.status = 429; + if (retryAfterSeconds !== undefined) { + error.retryAfter = retryAfterSeconds; + } + return error; +} + +describe("withRetry", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the result on first success", async () => { + const fn = vi.fn().mockResolvedValue("ok"); + const promise = withRetry(fn); + const result = await promise; + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries on transient error and succeeds", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(makeLinearError(LinearErrorType.NetworkError, 500)) + .mockResolvedValue("ok"); + + const promise = withRetry(fn); + + // Advance past the 1s delay for the first retry + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("retries twice then succeeds on third attempt", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(makeLinearError(LinearErrorType.NetworkError, 500)) + .mockRejectedValueOnce(makeLinearError(LinearErrorType.InternalError, 500)) + .mockResolvedValue("ok"); + + const promise = withRetry(fn); + + // First retry after 1s + await vi.advanceTimersByTimeAsync(1000); + // Second retry after 2s + await vi.advanceTimersByTimeAsync(2000); + + const result = await promise; + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("throws after exhausting all attempts", async () => { + const error = makeLinearError(LinearErrorType.NetworkError, 500); + const fn = vi.fn().mockRejectedValue(error); + + const promise = withRetry(fn); + // Attach rejection handler immediately to prevent unhandled rejection + const resultPromise = promise.catch((e) => e); + + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + const caught = await resultPromise; + expect(caught).toBe(error); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("does not retry non-retryable errors", async () => { + const error = makeLinearError(LinearErrorType.AuthenticationError, 401); + const fn = vi.fn().mockRejectedValue(error); + + await expect(withRetry(fn)).rejects.toBe(error); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("does not retry GraphQL errors", async () => { + const error = makeLinearError(LinearErrorType.GraphqlError, 200); + const fn = vi.fn().mockRejectedValue(error); + + await expect(withRetry(fn)).rejects.toBe(error); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("does not retry 4xx errors", async () => { + const error = makeLinearError(LinearErrorType.InvalidInput, 400); + const fn = vi.fn().mockRejectedValue(error); + + await expect(withRetry(fn)).rejects.toBe(error); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries rate limit errors (429) when retryAfter is missing", async () => { + const fn = vi.fn().mockRejectedValueOnce(makeRateLimitedError()).mockResolvedValue("ok"); + + const promise = withRetry(fn); + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("uses retryAfter for rate limit errors when provided", async () => { + const fn = vi.fn().mockRejectedValueOnce(makeRateLimitedError(3)).mockResolvedValue("ok"); + + const promise = withRetry(fn); + + await vi.advanceTimersByTimeAsync(2999); + expect(fn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + const result = await promise; + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("retries non-LinearError exceptions", async () => { + const fn = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValue("ok"); + + const promise = withRetry(fn); + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..4749dca --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,57 @@ +import { LinearError, LinearErrorType, RatelimitedLinearError } from "@linear/sdk"; +import { log } from "./log"; + +const MAX_ATTEMPTS = 3; +const BASE_DELAY_MS = 1000; + +const NON_RETRYABLE_TYPES = new Set([ + LinearErrorType.AuthenticationError, + LinearErrorType.Forbidden, + LinearErrorType.FeatureNotAccessible, + LinearErrorType.GraphqlError, + LinearErrorType.InvalidInput, + LinearErrorType.UserError, + LinearErrorType.UsageLimitExceeded, +]); + +function isRetryable(error: unknown): boolean { + if (error instanceof LinearError) { + if (error.type && NON_RETRYABLE_TYPES.has(error.type)) { + return false; + } + // 4xx (except 429 rate limit) are not retryable + if (error.status && error.status >= 400 && error.status < 500 && error.status !== 429) { + return false; + } + } + return true; +} + +function getDelayMs(error: unknown, attempt: number): number { + if (error instanceof RatelimitedLinearError) { + const retryAfterSeconds = error.retryAfter; + if (typeof retryAfterSeconds === "number" && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + return Math.ceil(retryAfterSeconds * 1000); + } + } + + return BASE_DELAY_MS * 2 ** (attempt - 1); +} + +export async function withRetry(fn: () => Promise): Promise { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === MAX_ATTEMPTS || !isRetryable(error)) { + throw error; + } + const delay = getDelayMs(error, attempt); + log(`Request failed, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // Unreachable — the loop always returns or throws + throw new Error("Retry logic error"); +}