Skip to content

Commit 018ecd1

Browse files
authored
Fix client interface 4xx retry handling (#1492)
1 parent 5f3dc6d commit 018ecd1

2 files changed

Lines changed: 80 additions & 6 deletions

File tree

packages/stack-shared/src/interface/client-interface.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ function createKnownErrorResponse(error: InstanceType<typeof KnownErrors[keyof t
5252
});
5353
}
5454

55+
function createTextResponse(body: string, options: { status: number, headers?: Record<string, string> }): Response {
56+
return new Response(body, options);
57+
}
58+
5559
function getRequestBody(fetchMock: { mock: { calls: unknown[][] } }): Record<string, unknown> {
5660
const requestInit = fetchMock.mock.calls[0]?.[1];
5761
if (requestInit == null || typeof requestInit !== "object" || !("body" in requestInit)) {
@@ -437,6 +441,63 @@ describe("_withFallback", () => {
437441
expect(log.every(u => urlIndex(urls, u) === 0)).toBe(true);
438442
});
439443

444+
it("does not retry or fall back on non-KnownError 4xx responses", async () => {
445+
const urls = urlList(3);
446+
const log: string[] = [];
447+
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
448+
log.push(input.toString());
449+
return createTextResponse("Payments are not set up", { status: 402 });
450+
}));
451+
452+
const iface = createClientInterface({ apiUrls: urls });
453+
await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" });
454+
expect(log.length).toBe(1);
455+
expect(urlIndex(urls, log[0])).toBe(0);
456+
});
457+
458+
it("wraps non-KnownError 4xx responses as normal errors", async () => {
459+
const response = createTextResponse("Payments are not set up", { status: 402 });
460+
vi.stubGlobal("fetch", vi.fn(async () => response));
461+
462+
const iface = createClientInterface({ apiUrls: urlList(1) });
463+
await expect(sendRequest(iface)).rejects.toMatchObject({
464+
name: "Error",
465+
message: expect.stringContaining("402 Payments are not set up"),
466+
cause: response,
467+
});
468+
});
469+
470+
it("does not retry non-KnownError 5xx responses on a single URL", async () => {
471+
let attempts = 0;
472+
vi.stubGlobal("fetch", vi.fn(async () => {
473+
attempts++;
474+
return createTextResponse("Server unavailable", { status: 503 });
475+
}));
476+
477+
const iface = createClientInterface({ apiUrls: urlList(1) });
478+
await expect(sendRequest(iface)).rejects.toThrow("503 Server unavailable");
479+
expect(attempts).toBe(1);
480+
});
481+
482+
it("falls back on non-KnownError 5xx responses", async () => {
483+
const urls = urlList(3);
484+
const log: string[] = [];
485+
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
486+
const url = input.toString();
487+
log.push(url);
488+
if (urlIndex(urls, url) === 0) {
489+
return createTextResponse("Server unavailable", { status: 503 });
490+
}
491+
return createJsonResponse({ display_name: "test" });
492+
}));
493+
494+
const iface = createClientInterface({ apiUrls: urls });
495+
await sendRequest(iface);
496+
expect(log.length).toBe(2);
497+
expect(urlIndex(urls, log[0])).toBe(0);
498+
expect(urlIndex(urls, log[1])).toBe(1);
499+
});
500+
440501
it("makes 2 passes × N URLs attempts before throwing", async () => {
441502
for (const n of [2, 3, 5]) {
442503
const urls = urlList(n);

packages/stack-shared/src/interface/client-interface.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ export class HexclaveClientInterface {
219219
* - Sticky URL fails → exit sticky mode, do a full iteration.
220220
*
221221
* In both modes, a full iteration tries every URL once per pass for 2
222-
* passes before giving up. KnownErrors are never retried (they're
223-
* application-level, not network-level).
222+
* passes before giving up. KnownErrors and 4xx API responses (except 429)
223+
* are never retried (they're application-level, not network-level).
224224
*
225225
* Single-URL lists skip all of this and use 5-retry behavior directly.
226226
*/
@@ -243,6 +243,15 @@ export class HexclaveClientInterface {
243243
return await this._iterateUrls(apiUrls, cb);
244244
}
245245

246+
private _shouldSkipFallback(error: unknown) {
247+
return error instanceof KnownError || this._isNonRetryableApiResponseError(error);
248+
}
249+
250+
private _isNonRetryableApiResponseError(error: unknown) {
251+
const cause = error instanceof Error ? error.cause : undefined;
252+
return cause instanceof Response && cause.status >= 400 && cause.status < 500;
253+
}
254+
246255
/**
247256
* Attempts the sticky URL, optionally probing primary first.
248257
* Returns the result on success, or `undefined` if we should fall through to full iteration.
@@ -260,7 +269,7 @@ export class HexclaveClientInterface {
260269
this._sticky = null;
261270
return result;
262271
} catch (e) {
263-
if (e instanceof KnownError) throw e;
272+
if (this._shouldSkipFallback(e)) throw e;
264273
sticky.probeRate = Math.max(sticky.probeRate * 0.5, 0.01);
265274
}
266275
}
@@ -269,7 +278,7 @@ export class HexclaveClientInterface {
269278
try {
270279
return await cb(apiUrls[sticky.index], { maxAttempts: 1, skipDiagnostics: true });
271280
} catch (e) {
272-
if (e instanceof KnownError) throw e;
281+
if (this._shouldSkipFallback(e)) throw e;
273282
this._sticky = null;
274283
return undefined;
275284
}
@@ -294,7 +303,7 @@ export class HexclaveClientInterface {
294303
}
295304
return result;
296305
} catch (e) {
297-
if (e instanceof KnownError) throw e;
306+
if (this._shouldSkipFallback(e)) throw e;
298307
lastError = e instanceof Error ? e : new Error(String(e));
299308
}
300309
}
@@ -457,7 +466,7 @@ export class HexclaveClientInterface {
457466

458467
if (!response.data.ok) {
459468
const body = await response.data.text();
460-
throw new Error(`Failed to send refresh token request: ${response.status} ${body}`);
469+
throw new Error(`Failed to send refresh token request: ${response.status} ${body}`, { cause: response.data });
461470
}
462471

463472
return response.data;
@@ -777,6 +786,10 @@ export class HexclaveClientInterface {
777786
} else {
778787
const error = await res.text();
779788

789+
// Do not retry, throw error instead of returning one
790+
if (res.status >= 400 && res.status < 500) {
791+
throw new Error(`Failed to send request to ${url}: ${res.status} ${error}`, { cause: res });
792+
}
780793
const errorObj = new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path });
781794

782795
if (res.status === 508 && error.includes("INFINITE_LOOP_DETECTED")) {

0 commit comments

Comments
 (0)