Skip to content

Commit bc89bf9

Browse files
outof-placeclaude
andauthored
fix(worker): retry the GitLab repository catalog step on transient timeouts (#302)
Claude-Session: https://claude.ai/code/session_015kfeohXE66xx7RJPxZ2pvH Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 94b6036 commit bc89bf9

2 files changed

Lines changed: 49 additions & 15 deletions

File tree

apps/worker/src/adapters/vcs/repository-directory.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,32 @@ describe("provider listing resilience", () => {
320320
]);
321321
});
322322

323-
it("retries a GitLab timeout once and reports the provider when it times out again", async () => {
323+
it("retries a transient GitLab timeout and keeps the recovered listing", async () => {
324+
const timeout = new DOMException("The operation timed out.", "TimeoutError");
325+
mockFetch
326+
.mockRejectedValueOnce(timeout)
327+
.mockRejectedValueOnce(timeout)
328+
.mockResolvedValueOnce(gitLabResponse([gitLabProject("acme/api")]));
329+
330+
const listing = await listRepositoriesAcrossProviders([gitlabProvider]);
331+
332+
// Two transient timeouts still resolve on the third, bounded attempt.
333+
expect(mockFetch).toHaveBeenCalledTimes(3);
334+
expect(listing.failures).toEqual([]);
335+
expect(listing.repositories).toEqual([
336+
expect.objectContaining({ provider: "gitlab", repoPath: "acme/api" }),
337+
]);
338+
});
339+
340+
it("stops after the bounded attempts and reports a GitLab timeout that never clears", async () => {
324341
const timeout = new DOMException("The operation timed out.", "TimeoutError");
325342
mockFetch.mockRejectedValue(timeout);
326343

327344
const listing = await listRepositoriesAcrossProviders([gitlabProvider]);
328345

329-
expect(mockFetch).toHaveBeenCalledTimes(2);
346+
// Retries are bounded: three attempts, then the failure surfaces with a
347+
// reason instead of the retry ladder spinning forever.
348+
expect(mockFetch).toHaveBeenCalledTimes(3);
330349
expect(listing.repositories).toEqual([]);
331350
expect(listing.failures).toEqual([
332351
expect.objectContaining({

apps/worker/src/adapters/vcs/repository-directory.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,29 @@ import type { WorkflowRepositoryScope } from "@shared/contracts";
22
import type { VcsConfig, VcsProviderConfig } from "../../../env.js";
33
import { buildOctokit } from "../../lib/github-auth.js";
44

5-
const GITLAB_PROJECTS_TIMEOUT_MS = 15_000;
5+
const GITLAB_PROJECTS_TIMEOUT_MS = 18_000;
66

7-
// One retry with a short backoff. A provider's 5xx or timeout is usually gone by
8-
// the second call, while the pre-sandbox step that owns this listing runs under a
9-
// 60s budget: a longer ladder would spend that budget hanging instead of failing
10-
// with a reason an operator can act on.
11-
const LISTING_MAX_ATTEMPTS = 2;
12-
const LISTING_RETRY_DELAY_MS = 500;
7+
// A few bounded retries with jittered exponential backoff. A provider's 5xx or
8+
// timeout is usually gone within a couple of calls, while the pre-sandbox step
9+
// that owns this listing runs under a 60s budget: a longer ladder would spend
10+
// that budget hanging instead of failing with a reason an operator can act on.
11+
// Worst case is 3 * 18s + <=1.5s of backoff ~= 55.5s, which stays inside that
12+
// budget while surviving a GitLab hiccup that outlasts a single 18s window.
13+
const LISTING_MAX_ATTEMPTS = 3;
14+
const LISTING_RETRY_BASE_DELAY_MS = 500;
15+
const LISTING_RETRY_MAX_DELAY_MS = 4_000;
16+
17+
/** Jittered exponential backoff between listing attempts: after the nth attempt
18+
* fails the next wait is a random span in [0, base * 2^(n-1)] capped at
19+
* LISTING_RETRY_MAX_DELAY_MS. Full jitter de-correlates retries that a shared
20+
* upstream blip fired at once, and the cap keeps the ladder inside the budget. */
21+
function listingRetryDelayMs(failedAttempt: number): number {
22+
const ceiling = Math.min(
23+
LISTING_RETRY_MAX_DELAY_MS,
24+
LISTING_RETRY_BASE_DELAY_MS * 2 ** (failedAttempt - 1),
25+
);
26+
return Math.floor(Math.random() * ceiling);
27+
}
1328

1429
export type VcsProvider = "github" | "gitlab";
1530

@@ -62,11 +77,11 @@ export interface RepositoryListingFailure {
6277
* providers that failed instead of a single rejection standing in for all of them.
6378
* Each provider's listing is retried under a bounded policy first.
6479
*
65-
* Latency budget for whoever tunes GITLAB_PROJECTS_TIMEOUT_MS next: the retry
66-
* doubles the worst case, so a hung provider costs about 30.5s here rather than
67-
* 15s, for every caller including the dashboard catalog endpoint. allSettled also
68-
* means the slowest provider sets the floor: a fast 401 next to a hung provider
69-
* now surfaces at the hung provider's pace instead of immediately.
80+
* Latency budget for whoever tunes GITLAB_PROJECTS_TIMEOUT_MS next: the ladder
81+
* of up to 3 attempts triples the worst case, so a hung provider costs about 55s
82+
* here rather than 18s, for every caller including the dashboard catalog endpoint.
83+
* allSettled also means the slowest provider sets the floor: a fast 401 next to a
84+
* hung provider now surfaces at the hung provider's pace instead of immediately.
7085
*/
7186
export async function listRepositoriesAcrossProviders(
7287
providers: VcsProviderConfig[],
@@ -104,7 +119,7 @@ async function listRepositoriesWithRetry(
104119
} catch (err) {
105120
lastError = err;
106121
if (attempt >= LISTING_MAX_ATTEMPTS || !isTransientListingError(err)) break;
107-
await new Promise((resolve) => setTimeout(resolve, LISTING_RETRY_DELAY_MS));
122+
await new Promise((resolve) => setTimeout(resolve, listingRetryDelayMs(attempt)));
108123
}
109124
}
110125
throw lastError;

0 commit comments

Comments
 (0)