Skip to content

Commit 81f558e

Browse files
committed
Add retry logic for api calls
1 parent 95e7a05 commit 81f558e

3 files changed

Lines changed: 223 additions & 10 deletions

File tree

src/index.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getCLIWarnings, parseCLIArgs } from "./args";
1919
import { log, setStderr } from "./log";
2020
import { pluralize } from "./util";
2121
import { buildUserAgent } from "./user-agent";
22+
import { withRetry } from "./retry";
2223

2324
declare const CLI_VERSION: string;
2425

@@ -116,6 +117,10 @@ const options: LinearClientOptions = {
116117
const linearClient = new LinearClient(options);
117118
linearClient.client.setHeader("User-Agent", buildUserAgent());
118119

120+
async function apiRequest<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
121+
return withRetry(() => linearClient.client.rawRequest(query, variables)) as Promise<T>;
122+
}
123+
119124
function scanCommits(
120125
commits: CommitContext[],
121126
includePaths: string[] | null,
@@ -380,7 +385,7 @@ async function updateCommand(): Promise<{
380385
}
381386

382387
async function getLatestRelease(): Promise<Release | null> {
383-
const response = (await linearClient.client.rawRequest(
388+
const response = await apiRequest<AccessKeyLatestReleaseResponse>(
384389
`
385390
query latestReleaseByAccessKey {
386391
latestReleaseByAccessKey {
@@ -391,7 +396,7 @@ async function getLatestRelease(): Promise<Release | null> {
391396
}
392397
}
393398
`,
394-
)) as AccessKeyLatestReleaseResponse;
399+
);
395400

396401
return response.data.latestReleaseByAccessKey;
397402
}
@@ -417,15 +422,15 @@ async function getLatestSha(): Promise<string> {
417422
}
418423

419424
async function getPipelineSettings(): Promise<{ includePathPatterns: string[] }> {
420-
const response = (await linearClient.client.rawRequest(
425+
const response = await apiRequest<AccessKeyPipelineSettingsResponse>(
421426
`
422427
query pipelineSettingsByAccessKey {
423428
releasePipelineByAccessKey {
424429
includePathPatterns
425430
}
426431
}
427432
`,
428-
)) as AccessKeyPipelineSettingsResponse;
433+
);
429434

430435
return {
431436
includePathPatterns: response.data.releasePipelineByAccessKey.includePathPatterns ?? [],
@@ -449,7 +454,7 @@ async function syncRelease(
449454

450455
const { owner, name } = repoInfo ?? {};
451456

452-
const response = (await linearClient.client.rawRequest(
457+
const response = await apiRequest<AccessKeySyncReleaseResponse>(
453458
`
454459
mutation syncReleaseByAccessKey($input: ReleaseSyncInputBase!) {
455460
releaseSyncByAccessKey(input: $input) {
@@ -487,7 +492,7 @@ async function syncRelease(
487492
debugSink,
488493
},
489494
},
490-
)) as AccessKeySyncReleaseResponse;
495+
);
491496

492497
if (!response.data?.releaseSyncByAccessKey?.release) {
493498
throw new Error("Failed to sync release");
@@ -502,7 +507,7 @@ async function completeRelease(options: {
502507
}): Promise<{ success: boolean; release: { id: string; name: string; version?: string; url?: string } | null }> {
503508
const { version, commitSha } = options;
504509

505-
const response = (await linearClient.client.rawRequest(
510+
const response = await apiRequest<AccessKeyCompleteReleaseResponse>(
506511
`
507512
mutation releaseCompleteByAccessKey($input: ReleaseCompleteInputBase!) {
508513
releaseCompleteByAccessKey(input: $input) {
@@ -522,7 +527,7 @@ async function completeRelease(options: {
522527
commitSha,
523528
},
524529
},
525-
)) as AccessKeyCompleteReleaseResponse;
530+
);
526531

527532
return response.data.releaseCompleteByAccessKey;
528533
}
@@ -539,7 +544,7 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri
539544
.filter(Boolean)
540545
.map((s) => s.slice(2))
541546
.join(", ");
542-
const response = (await linearClient.client.rawRequest(
547+
const response = await apiRequest<AccessKeyUpdateByPipelineResponse>(
543548
`
544549
mutation {
545550
releaseUpdateByPipelineByAccessKey(input: { ${inputParts} }) {
@@ -556,7 +561,7 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri
556561
}
557562
}
558563
`,
559-
)) as AccessKeyUpdateByPipelineResponse;
564+
);
560565

561566
const result = response.data.releaseUpdateByPipelineByAccessKey;
562567
return {

src/retry.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { LinearError, LinearErrorType, RatelimitedLinearError } from "@linear/sdk";
3+
import { withRetry } from "./retry";
4+
5+
function makeLinearError(type: LinearErrorType, status?: number): LinearError {
6+
const error = new LinearError();
7+
error.type = type;
8+
if (status !== undefined) {
9+
error.status = status;
10+
}
11+
return error;
12+
}
13+
14+
function makeRateLimitedError(retryAfterSeconds?: number): RatelimitedLinearError {
15+
const error = new RatelimitedLinearError();
16+
error.type = LinearErrorType.Ratelimited;
17+
error.status = 429;
18+
if (retryAfterSeconds !== undefined) {
19+
error.retryAfter = retryAfterSeconds;
20+
}
21+
return error;
22+
}
23+
24+
describe("withRetry", () => {
25+
beforeEach(() => {
26+
vi.useFakeTimers();
27+
});
28+
29+
afterEach(() => {
30+
vi.useRealTimers();
31+
});
32+
33+
it("returns the result on first success", async () => {
34+
const fn = vi.fn().mockResolvedValue("ok");
35+
const promise = withRetry(fn);
36+
const result = await promise;
37+
expect(result).toBe("ok");
38+
expect(fn).toHaveBeenCalledTimes(1);
39+
});
40+
41+
it("retries on transient error and succeeds", async () => {
42+
const fn = vi
43+
.fn()
44+
.mockRejectedValueOnce(makeLinearError(LinearErrorType.NetworkError, 500))
45+
.mockResolvedValue("ok");
46+
47+
const promise = withRetry(fn);
48+
49+
// Advance past the 1s delay for the first retry
50+
await vi.advanceTimersByTimeAsync(1000);
51+
52+
const result = await promise;
53+
expect(result).toBe("ok");
54+
expect(fn).toHaveBeenCalledTimes(2);
55+
});
56+
57+
it("retries twice then succeeds on third attempt", async () => {
58+
const fn = vi
59+
.fn()
60+
.mockRejectedValueOnce(makeLinearError(LinearErrorType.NetworkError, 500))
61+
.mockRejectedValueOnce(makeLinearError(LinearErrorType.InternalError, 500))
62+
.mockResolvedValue("ok");
63+
64+
const promise = withRetry(fn);
65+
66+
// First retry after 1s
67+
await vi.advanceTimersByTimeAsync(1000);
68+
// Second retry after 2s
69+
await vi.advanceTimersByTimeAsync(2000);
70+
71+
const result = await promise;
72+
expect(result).toBe("ok");
73+
expect(fn).toHaveBeenCalledTimes(3);
74+
});
75+
76+
it("throws after exhausting all attempts", async () => {
77+
const error = makeLinearError(LinearErrorType.NetworkError, 500);
78+
const fn = vi.fn().mockRejectedValue(error);
79+
80+
const promise = withRetry(fn);
81+
// Attach rejection handler immediately to prevent unhandled rejection
82+
const resultPromise = promise.catch((e) => e);
83+
84+
await vi.advanceTimersByTimeAsync(1000);
85+
await vi.advanceTimersByTimeAsync(2000);
86+
87+
const caught = await resultPromise;
88+
expect(caught).toBe(error);
89+
expect(fn).toHaveBeenCalledTimes(3);
90+
});
91+
92+
it("does not retry non-retryable errors", async () => {
93+
const error = makeLinearError(LinearErrorType.AuthenticationError, 401);
94+
const fn = vi.fn().mockRejectedValue(error);
95+
96+
await expect(withRetry(fn)).rejects.toBe(error);
97+
expect(fn).toHaveBeenCalledTimes(1);
98+
});
99+
100+
it("does not retry GraphQL errors", async () => {
101+
const error = makeLinearError(LinearErrorType.GraphqlError, 200);
102+
const fn = vi.fn().mockRejectedValue(error);
103+
104+
await expect(withRetry(fn)).rejects.toBe(error);
105+
expect(fn).toHaveBeenCalledTimes(1);
106+
});
107+
108+
it("does not retry 4xx errors", async () => {
109+
const error = makeLinearError(LinearErrorType.InvalidInput, 400);
110+
const fn = vi.fn().mockRejectedValue(error);
111+
112+
await expect(withRetry(fn)).rejects.toBe(error);
113+
expect(fn).toHaveBeenCalledTimes(1);
114+
});
115+
116+
it("retries rate limit errors (429) when retryAfter is missing", async () => {
117+
const fn = vi.fn().mockRejectedValueOnce(makeRateLimitedError()).mockResolvedValue("ok");
118+
119+
const promise = withRetry(fn);
120+
await vi.advanceTimersByTimeAsync(1000);
121+
122+
const result = await promise;
123+
expect(result).toBe("ok");
124+
expect(fn).toHaveBeenCalledTimes(2);
125+
});
126+
127+
it("uses retryAfter for rate limit errors when provided", async () => {
128+
const fn = vi.fn().mockRejectedValueOnce(makeRateLimitedError(3)).mockResolvedValue("ok");
129+
130+
const promise = withRetry(fn);
131+
132+
await vi.advanceTimersByTimeAsync(2999);
133+
expect(fn).toHaveBeenCalledTimes(1);
134+
135+
await vi.advanceTimersByTimeAsync(1);
136+
const result = await promise;
137+
expect(result).toBe("ok");
138+
expect(fn).toHaveBeenCalledTimes(2);
139+
});
140+
141+
it("retries non-LinearError exceptions", async () => {
142+
const fn = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValue("ok");
143+
144+
const promise = withRetry(fn);
145+
await vi.advanceTimersByTimeAsync(1000);
146+
147+
const result = await promise;
148+
expect(result).toBe("ok");
149+
expect(fn).toHaveBeenCalledTimes(2);
150+
});
151+
});

src/retry.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { LinearError, LinearErrorType, RatelimitedLinearError } from "@linear/sdk";
2+
import { log } from "./log";
3+
4+
const MAX_ATTEMPTS = 3;
5+
const BASE_DELAY_MS = 1000;
6+
7+
const NON_RETRYABLE_TYPES = new Set<LinearErrorType>([
8+
LinearErrorType.AuthenticationError,
9+
LinearErrorType.Forbidden,
10+
LinearErrorType.FeatureNotAccessible,
11+
LinearErrorType.GraphqlError,
12+
LinearErrorType.InvalidInput,
13+
LinearErrorType.UserError,
14+
LinearErrorType.UsageLimitExceeded,
15+
]);
16+
17+
function isRetryable(error: unknown): boolean {
18+
if (error instanceof LinearError) {
19+
if (error.type && NON_RETRYABLE_TYPES.has(error.type)) {
20+
return false;
21+
}
22+
// 4xx (except 429 rate limit) are not retryable
23+
if (error.status && error.status >= 400 && error.status < 500 && error.status !== 429) {
24+
return false;
25+
}
26+
}
27+
return true;
28+
}
29+
30+
function getDelayMs(error: unknown, attempt: number): number {
31+
if (error instanceof RatelimitedLinearError) {
32+
const retryAfterSeconds = error.retryAfter;
33+
if (typeof retryAfterSeconds === "number" && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
34+
return Math.ceil(retryAfterSeconds * 1000);
35+
}
36+
}
37+
38+
return BASE_DELAY_MS * 2 ** (attempt - 1);
39+
}
40+
41+
export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
42+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
43+
try {
44+
return await fn();
45+
} catch (error) {
46+
if (attempt === MAX_ATTEMPTS || !isRetryable(error)) {
47+
throw error;
48+
}
49+
const delay = getDelayMs(error, attempt);
50+
log(`Request failed, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`);
51+
await new Promise((resolve) => setTimeout(resolve, delay));
52+
}
53+
}
54+
55+
// Unreachable — the loop always returns or throws
56+
throw new Error("Retry logic error");
57+
}

0 commit comments

Comments
 (0)