From 83a6fd33730695a7ff7ec79c4f46bda5b6af5c48 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 20:33:22 +0000 Subject: [PATCH 1/5] Fix client interface 4xx retry handling --- .../src/interface/client-interface.test.ts | 28 +++++++++++++++++++ .../src/interface/client-interface.ts | 28 +++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.test.ts b/packages/stack-shared/src/interface/client-interface.test.ts index 67842e8b0e..d7a863affc 100644 --- a/packages/stack-shared/src/interface/client-interface.test.ts +++ b/packages/stack-shared/src/interface/client-interface.test.ts @@ -52,6 +52,10 @@ function createKnownErrorResponse(error: InstanceType }): Response { + return new Response(body, options); +} + function getRequestBody(fetchMock: { mock: { calls: unknown[][] } }): Record { const requestInit = fetchMock.mock.calls[0]?.[1]; if (requestInit == null || typeof requestInit !== "object" || !("body" in requestInit)) { @@ -437,6 +441,30 @@ describe("_withFallback", () => { expect(log.every(u => urlIndex(urls, u) === 0)).toBe(true); }); + it("does not retry or fall back on non-KnownError 4xx responses", async () => { + const urls = urlList(3); + const log: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + log.push(input.toString()); + return createTextResponse("Payments are not set up", { status: 402 }); + })); + + const iface = createClientInterface({ apiUrls: urls }); + await expect(sendRequest(iface)).rejects.toThrow(Error); + expect(log.length).toBe(1); + expect(urlIndex(urls, log[0])).toBe(0); + }); + + it("wraps non-KnownError 4xx responses as normal errors", async () => { + vi.stubGlobal("fetch", vi.fn(async () => createTextResponse("Payments are not set up", { status: 402 }))); + + const iface = createClientInterface({ apiUrls: urlList(1) }); + await expect(sendRequest(iface)).rejects.toMatchObject({ + name: "Error", + message: expect.stringContaining("402 Payments are not set up"), + }); + }); + it("makes 2 passes × N URLs attempts before throwing", async () => { for (const n of [2, 3, 5]) { const urls = urlList(n); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index bd3744d3f2..2ed585782e 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -219,8 +219,8 @@ export class HexclaveClientInterface { * - Sticky URL fails → exit sticky mode, do a full iteration. * * In both modes, a full iteration tries every URL once per pass for 2 - * passes before giving up. KnownErrors are never retried (they're - * application-level, not network-level). + * passes before giving up. KnownErrors and 4xx API responses are never + * retried (they're application-level, not network-level). * * Single-URL lists skip all of this and use 5-retry behavior directly. */ @@ -243,6 +243,15 @@ export class HexclaveClientInterface { return await this._iterateUrls(apiUrls, cb); } + private _shouldSkipFallback(error: unknown) { + return error instanceof KnownError || this._isNonRetryableApiResponseError(error); + } + + private _isNonRetryableApiResponseError(error: unknown) { + const cause = error instanceof Error ? error.cause : undefined; + return cause instanceof Response && cause.status >= 400 && cause.status < 500; + } + /** * Attempts the sticky URL, optionally probing primary first. * Returns the result on success, or `undefined` if we should fall through to full iteration. @@ -260,7 +269,7 @@ export class HexclaveClientInterface { this._sticky = null; return result; } catch (e) { - if (e instanceof KnownError) throw e; + if (this._shouldSkipFallback(e)) throw e; sticky.probeRate = Math.max(sticky.probeRate * 0.5, 0.01); } } @@ -269,7 +278,7 @@ export class HexclaveClientInterface { try { return await cb(apiUrls[sticky.index], { maxAttempts: 1, skipDiagnostics: true }); } catch (e) { - if (e instanceof KnownError) throw e; + if (this._shouldSkipFallback(e)) throw e; this._sticky = null; return undefined; } @@ -294,7 +303,7 @@ export class HexclaveClientInterface { } return result; } catch (e) { - if (e instanceof KnownError) throw e; + if (this._shouldSkipFallback(e)) throw e; lastError = e instanceof Error ? e : new Error(String(e)); } } @@ -777,16 +786,17 @@ export class HexclaveClientInterface { } else { const error = await res.text(); - const errorObj = new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path }); - if (res.status === 508 && error.includes("INFINITE_LOOP_DETECTED")) { // Some Vercel deployments seem to have an odd infinite loop bug. In that case, retry. // See: https://github.com/hexclave/stack-auth/issues/319 - return Result.error(errorObj); + return Result.error(new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path })); } // Do not retry, throw error instead of returning one - throw errorObj; + if (res.status >= 400 && res.status < 500) { + throw new Error(`Failed to send request to ${url}: ${res.status} ${error}`, { cause: res }); + } + throw new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path }); } } From e91f4ed16e217b2408282571c705a82196eb9e1e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 20:36:17 +0000 Subject: [PATCH 2/5] Keep client interface 5xx fallback behavior --- .../src/interface/client-interface.test.ts | 34 +++++++++++++++++++ .../src/interface/client-interface.ts | 8 +---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.test.ts b/packages/stack-shared/src/interface/client-interface.test.ts index d7a863affc..a0fff33449 100644 --- a/packages/stack-shared/src/interface/client-interface.test.ts +++ b/packages/stack-shared/src/interface/client-interface.test.ts @@ -465,6 +465,40 @@ describe("_withFallback", () => { }); }); + it("retries non-KnownError 5xx responses on a single URL", async () => { + let attempts = 0; + vi.stubGlobal("fetch", vi.fn(async () => { + attempts++; + if (attempts < 3) { + return createTextResponse("Server unavailable", { status: 503 }); + } + return createJsonResponse({ display_name: "test" }); + })); + + const iface = createClientInterface({ apiUrls: urlList(1) }); + await sendRequest(iface); + expect(attempts).toBe(3); + }); + + it("falls back on non-KnownError 5xx responses", async () => { + const urls = urlList(3); + const log: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + log.push(url); + if (urlIndex(urls, url) === 0) { + return createTextResponse("Server unavailable", { status: 503 }); + } + return createJsonResponse({ display_name: "test" }); + })); + + const iface = createClientInterface({ apiUrls: urls }); + await sendRequest(iface); + expect(log.length).toBe(2); + expect(urlIndex(urls, log[0])).toBe(0); + expect(urlIndex(urls, log[1])).toBe(1); + }); + it("makes 2 passes × N URLs attempts before throwing", async () => { for (const n of [2, 3, 5]) { const urls = urlList(n); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 2ed585782e..6695ef9c9e 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -786,17 +786,11 @@ export class HexclaveClientInterface { } else { const error = await res.text(); - if (res.status === 508 && error.includes("INFINITE_LOOP_DETECTED")) { - // Some Vercel deployments seem to have an odd infinite loop bug. In that case, retry. - // See: https://github.com/hexclave/stack-auth/issues/319 - return Result.error(new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path })); - } - // Do not retry, throw error instead of returning one if (res.status >= 400 && res.status < 500) { throw new Error(`Failed to send request to ${url}: ${res.status} ${error}`, { cause: res }); } - throw new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path }); + return Result.error(new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path })); } } From a92932b8fd81df4876b7b6d4556e06247bb658ad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 20:37:35 +0000 Subject: [PATCH 3/5] Preserve 5xx API fallback behavior --- .../src/interface/client-interface.test.ts | 11 ++++------- .../stack-shared/src/interface/client-interface.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.test.ts b/packages/stack-shared/src/interface/client-interface.test.ts index a0fff33449..048630d0e4 100644 --- a/packages/stack-shared/src/interface/client-interface.test.ts +++ b/packages/stack-shared/src/interface/client-interface.test.ts @@ -465,19 +465,16 @@ describe("_withFallback", () => { }); }); - it("retries non-KnownError 5xx responses on a single URL", async () => { + it("does not retry non-KnownError 5xx responses on a single URL", async () => { let attempts = 0; vi.stubGlobal("fetch", vi.fn(async () => { attempts++; - if (attempts < 3) { - return createTextResponse("Server unavailable", { status: 503 }); - } - return createJsonResponse({ display_name: "test" }); + return createTextResponse("Server unavailable", { status: 503 }); })); const iface = createClientInterface({ apiUrls: urlList(1) }); - await sendRequest(iface); - expect(attempts).toBe(3); + await expect(sendRequest(iface)).rejects.toThrow("503 Server unavailable"); + expect(attempts).toBe(1); }); it("falls back on non-KnownError 5xx responses", async () => { diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 6695ef9c9e..643799b265 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -790,7 +790,16 @@ export class HexclaveClientInterface { if (res.status >= 400 && res.status < 500) { throw new Error(`Failed to send request to ${url}: ${res.status} ${error}`, { cause: res }); } - return Result.error(new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path })); + const errorObj = new HexclaveAssertionError(`Failed to send request to ${url}: ${res.status} ${error}`, { request: params, res, path }); + + if (res.status === 508 && error.includes("INFINITE_LOOP_DETECTED")) { + // Some Vercel deployments seem to have an odd infinite loop bug. In that case, retry. + // See: https://github.com/hexclave/stack-auth/issues/319 + return Result.error(errorObj); + } + + // Do not retry, throw error instead of returning one + throw errorObj; } } From 04804094c51ba7949a593f8706c6473ff70359ff Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 20:40:04 +0000 Subject: [PATCH 4/5] Tighten 4xx error assertion --- packages/stack-shared/src/interface/client-interface.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-shared/src/interface/client-interface.test.ts b/packages/stack-shared/src/interface/client-interface.test.ts index 048630d0e4..63afac62ab 100644 --- a/packages/stack-shared/src/interface/client-interface.test.ts +++ b/packages/stack-shared/src/interface/client-interface.test.ts @@ -450,7 +450,7 @@ describe("_withFallback", () => { })); const iface = createClientInterface({ apiUrls: urls }); - await expect(sendRequest(iface)).rejects.toThrow(Error); + await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" }); expect(log.length).toBe(1); expect(urlIndex(urls, log[0])).toBe(0); }); From c6944cb6b78094a3b4faaa9fa207562afccb1380 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 20:48:26 +0000 Subject: [PATCH 5/5] Handle token refresh 4xx fallback --- .../stack-shared/src/interface/client-interface.test.ts | 4 +++- packages/stack-shared/src/interface/client-interface.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.test.ts b/packages/stack-shared/src/interface/client-interface.test.ts index 63afac62ab..16c3445391 100644 --- a/packages/stack-shared/src/interface/client-interface.test.ts +++ b/packages/stack-shared/src/interface/client-interface.test.ts @@ -456,12 +456,14 @@ describe("_withFallback", () => { }); it("wraps non-KnownError 4xx responses as normal errors", async () => { - vi.stubGlobal("fetch", vi.fn(async () => createTextResponse("Payments are not set up", { status: 402 }))); + const response = createTextResponse("Payments are not set up", { status: 402 }); + vi.stubGlobal("fetch", vi.fn(async () => response)); const iface = createClientInterface({ apiUrls: urlList(1) }); await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error", message: expect.stringContaining("402 Payments are not set up"), + cause: response, }); }); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 643799b265..2e4e9c5316 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -219,8 +219,8 @@ export class HexclaveClientInterface { * - Sticky URL fails → exit sticky mode, do a full iteration. * * In both modes, a full iteration tries every URL once per pass for 2 - * passes before giving up. KnownErrors and 4xx API responses are never - * retried (they're application-level, not network-level). + * passes before giving up. KnownErrors and 4xx API responses (except 429) + * are never retried (they're application-level, not network-level). * * Single-URL lists skip all of this and use 5-retry behavior directly. */ @@ -466,7 +466,7 @@ export class HexclaveClientInterface { if (!response.data.ok) { const body = await response.data.text(); - throw new Error(`Failed to send refresh token request: ${response.status} ${body}`); + throw new Error(`Failed to send refresh token request: ${response.status} ${body}`, { cause: response.data }); } return response.data;