Skip to content

Commit 0b082d6

Browse files
add retry for 502,503,504 error codes
1 parent 2384d2d commit 0b082d6

3 files changed

Lines changed: 88 additions & 2 deletions

File tree

.github/workflows/pull_request.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ jobs:
7979
name: codecov-unit-node-${{ matrix.node-version }}
8080
fail_ci_if_error: false
8181

82-
# Integration tests (v5): one job at a time (max-parallel: 1) to avoid 502/503 on shared Conductor.
82+
# Integration tests (v5): one shard at a time (max-parallel: 1) to avoid 502/503 on shared Conductor.
8383
# Sharding (--shard i/N) splits the suite so each job runs ~1/N of tests — keeps per-job under timeout.
8484
integration-tests:
8585
runs-on: ubuntu-latest
8686
timeout-minutes: 25
8787
strategy:
8888
fail-fast: false
89-
max-parallel: 2
89+
max-parallel: 1
9090
matrix:
9191
node-version: [20, 22, 24]
9292
shard: [1, 2, 3]

src/sdk/createConductorClient/helpers/__tests__/fetchWithRetry.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,80 @@ describe("fetchWithRetry", () => {
181181
});
182182
});
183183

184+
// ─── Server error (502/503/504) retry ───────────────────────────────
185+
186+
describe("server error (502/503/504) retry", () => {
187+
it("should retry 502 and succeed on next attempt", async () => {
188+
mockFetch
189+
.mockResolvedValueOnce(createMockResponse(502, "Bad Gateway"))
190+
.mockResolvedValueOnce(createMockResponse(200, "ok"));
191+
192+
const result = await retryFetch("http://test.com", {}, mockFetch, {
193+
maxTransportRetries: 3,
194+
initialRetryDelay: 1,
195+
});
196+
197+
expect(result.status).toBe(200);
198+
expect(mockFetch).toHaveBeenCalledTimes(2);
199+
});
200+
201+
it("should retry 503 and succeed on next attempt", async () => {
202+
mockFetch
203+
.mockResolvedValueOnce(createMockResponse(503, "Service Unavailable"))
204+
.mockResolvedValueOnce(createMockResponse(200, "ok"));
205+
206+
const result = await retryFetch("http://test.com", {}, mockFetch, {
207+
maxTransportRetries: 3,
208+
initialRetryDelay: 1,
209+
});
210+
211+
expect(result.status).toBe(200);
212+
expect(mockFetch).toHaveBeenCalledTimes(2);
213+
});
214+
215+
it("should retry 504 and succeed on next attempt", async () => {
216+
mockFetch
217+
.mockResolvedValueOnce(createMockResponse(504, "Gateway Timeout"))
218+
.mockResolvedValueOnce(createMockResponse(200, "ok"));
219+
220+
const result = await retryFetch("http://test.com", {}, mockFetch, {
221+
maxTransportRetries: 3,
222+
initialRetryDelay: 1,
223+
});
224+
225+
expect(result.status).toBe(200);
226+
expect(mockFetch).toHaveBeenCalledTimes(2);
227+
});
228+
229+
it("should exhaust retries and return last 5xx response", async () => {
230+
mockFetch.mockResolvedValue(createMockResponse(502, "Bad Gateway"));
231+
232+
const result = await retryFetch("http://test.com", {}, mockFetch, {
233+
maxTransportRetries: 2,
234+
initialRetryDelay: 1,
235+
});
236+
237+
expect(result.status).toBe(502);
238+
// 1 initial + 2 retries = 3
239+
expect(mockFetch).toHaveBeenCalledTimes(3);
240+
});
241+
242+
it("should handle transport error then 502 then success", async () => {
243+
mockFetch
244+
.mockRejectedValueOnce(new Error("ECONNRESET"))
245+
.mockResolvedValueOnce(createMockResponse(502, "Bad Gateway"))
246+
.mockResolvedValueOnce(createMockResponse(200, "ok"));
247+
248+
const result = await retryFetch("http://test.com", {}, mockFetch, {
249+
maxTransportRetries: 3,
250+
initialRetryDelay: 1,
251+
});
252+
253+
expect(result.status).toBe(200);
254+
expect(mockFetch).toHaveBeenCalledTimes(3);
255+
});
256+
});
257+
184258
// ─── Auth failure (401/403) retry ──────────────────────────────────
185259

186260
describe("auth failure (401/403) retry", () => {

src/sdk/createConductorClient/helpers/fetchWithRetry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,18 @@ export const retryFetch = async (
157157
return rateLimitResponse;
158158
}
159159

160+
// Gateway error retry (502, 503, 504) -- transient proxy/server errors
161+
if (response.status >= 502 && response.status <= 504) {
162+
lastError = new Error(`Server error: HTTP ${response.status}`);
163+
if (transportAttempt < maxTransportRetries) {
164+
await new Promise((resolve) =>
165+
setTimeout(resolve, withJitter(initialRetryDelay * (transportAttempt + 1)))
166+
);
167+
continue;
168+
}
169+
return response;
170+
}
171+
160172
// Auth failure retry (401/403) - only refresh+retry when the error is a token
161173
// problem (EXPIRED_TOKEN or INVALID_TOKEN). Permission errors should propagate
162174
// immediately without wasting a token refresh + retry round-trip.

0 commit comments

Comments
 (0)