Skip to content

Commit 942b74f

Browse files
committed
fix(review): retry a 503 REES startup ping before escalating (#5006)
REES's own /v1/ping returns 503 specifically to mean "not configured/ ready yet" (server.ts checks its own REES_SHARED_SECRET before anything else) -- the same benign startup-ordering race the engine's probe already extends grace to for a refused connection, just via an HTTP response instead of a connection failure. All 7 GITTENSORY-1J events clustered in one ~5h window and never recurred, consistent with a one-time deploy/restart race rather than a persistent misconfiguration. Retry a 503 up to twice (500ms apart) before logging rees_ping_error; any other status is still final immediately, and a persistent 503 still escalates after the retries exhaust.
1 parent 34437c9 commit 942b74f

2 files changed

Lines changed: 67 additions & 11 deletions

File tree

src/review/enrichment-wire.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ function sharedSecretWasNormalized(
5353
return (normalized ?? "") !== raw;
5454
}
5555

56+
// REES's own /v1/ping returns 503 specifically to mean "not configured/ready yet" (server.ts: no
57+
// REES_SHARED_SECRET set on that side) -- the same benign startup-ordering race probeReesSecretAtStartup's
58+
// catch block already extends grace to for a refused connection (GITTENSORY-1J: 7 Sentry events, all in one
59+
// ~5h window, never recurring -- consistent with a one-time deploy/restart race, not a persistent
60+
// misconfiguration). Retry a few times before escalating; any other status is final on the first response.
61+
const REES_PING_NOT_READY_RETRIES = 2;
62+
const REES_PING_NOT_READY_RETRY_DELAY_MS = 500;
63+
64+
async function fetchReesPingWithRetry(url: string, secret: string): Promise<Response> {
65+
const request = () =>
66+
fetch(url, {
67+
method: "POST",
68+
headers: {
69+
"user-agent": "gittensory-selfhost/1.0",
70+
authorization: `Bearer ${secret}`,
71+
},
72+
signal: AbortSignal.timeout(5000),
73+
});
74+
let response = await request();
75+
for (let attempt = 0; attempt < REES_PING_NOT_READY_RETRIES && response.status === 503; attempt += 1) {
76+
await new Promise((resolve) => setTimeout(resolve, REES_PING_NOT_READY_RETRY_DELAY_MS));
77+
response = await request();
78+
}
79+
return response;
80+
}
81+
5682
// Set true once the startup probe confirms REES rejects the shared secret (401/403). Once set,
5783
// buildReviewEnrichment skips every /v1/enrich call for the rest of this process's lifetime instead of
5884
// repeating a call that's confirmed to fail on every PR review, each one logging review_context_fetch_failed.
@@ -104,17 +130,7 @@ export function probeReesSecretAtStartup(env: Env): void {
104130
// Probe asynchronously — never block the server from starting.
105131
void (async () => {
106132
try {
107-
const response = await fetch(
108-
`${base.replace(/\/+$/, "")}/v1/ping`,
109-
{
110-
method: "POST",
111-
headers: {
112-
"user-agent": "gittensory-selfhost/1.0",
113-
authorization: `Bearer ${sharedSecret}`,
114-
},
115-
signal: AbortSignal.timeout(5000),
116-
},
117-
);
133+
const response = await fetchReesPingWithRetry(`${base.replace(/\/+$/, "")}/v1/ping`, sharedSecret);
118134
if (response.ok) {
119135
console.log(
120136
JSON.stringify({

test/unit/enrichment-wire.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,46 @@ describe("probeReesSecretAtStartup", () => {
144144
errSpy.mockRestore();
145145
});
146146

147+
it("REGRESSION (#5006, GITTENSORY-1J): retries a 503 ('not ready yet') a few times before escalating to rees_ping_error", async () => {
148+
const fetchSpy = vi.fn(async () => ({ ok: false, status: 503 }) as Response);
149+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
150+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
151+
probeReesSecretAtStartup(env({ REES_URL: "https://rees.example", REES_SHARED_SECRET: "s3cret" }));
152+
await new Promise((resolve) => setTimeout(resolve, 1100));
153+
expect(fetchSpy).toHaveBeenCalledTimes(3); // the first attempt + 2 retries, all still 503
154+
const parsed = errSpy.mock.calls.map((c) => JSON.parse(c[0] as string));
155+
expect(parsed.some((p) => p.event === "rees_ping_error" && p.status === 503)).toBe(true);
156+
errSpy.mockRestore();
157+
});
158+
159+
it("REGRESSION (#5006): a 503 that clears on retry succeeds without ever escalating to rees_ping_error", async () => {
160+
let calls = 0;
161+
const fetchSpy = vi.fn(async () => {
162+
calls += 1;
163+
return (calls < 2 ? { ok: false, status: 503 } : { ok: true }) as Response;
164+
});
165+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
166+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
167+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
168+
probeReesSecretAtStartup(env({ REES_URL: "https://rees.example", REES_SHARED_SECRET: "s3cret" }));
169+
await new Promise((resolve) => setTimeout(resolve, 1100));
170+
expect(fetchSpy).toHaveBeenCalledTimes(2);
171+
expect(logSpy.mock.calls.some((c) => JSON.parse(c[0] as string).event === "rees_ping_ok")).toBe(true);
172+
expect(errSpy).not.toHaveBeenCalled();
173+
logSpy.mockRestore();
174+
errSpy.mockRestore();
175+
});
176+
177+
it("does not retry a non-503 non-ok status — a single attempt is final", async () => {
178+
const fetchSpy = vi.fn(async () => ({ ok: false, status: 500 }) as Response);
179+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
180+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
181+
probeReesSecretAtStartup(env({ REES_URL: "https://rees.example", REES_SHARED_SECRET: "s3cret" }));
182+
await flush();
183+
expect(fetchSpy).toHaveBeenCalledTimes(1);
184+
errSpy.mockRestore();
185+
});
186+
147187
it("warns rees_ping_error (not throw) when the fetch itself rejects — REES may not be up yet", async () => {
148188
const fetchSpy = vi.fn(async () => {
149189
throw new Error("connect ECONNREFUSED");

0 commit comments

Comments
 (0)