Skip to content

Fix client interface 4xx retry handling#1492

Merged
N2D4 merged 5 commits into
devfrom
devin/1761682140-client-interface-4xx-retry
May 26, 2026
Merged

Fix client interface 4xx retry handling#1492
N2D4 merged 5 commits into
devfrom
devin/1761682140-client-interface-4xx-retry

Conversation

@N2D4
Copy link
Copy Markdown
Contributor

@N2D4 N2D4 commented May 26, 2026

Prevents ordinary non-KnownError 4xx API responses from retrying or failing over, and wraps them as normal Errors instead of HexclaveAssertionError.

Tests:

  • pnpm -C /home/ubuntu/repos/stack-auth test run packages/stack-shared/src/interface/client-interface.test.ts
  • pnpm -C /home/ubuntu/repos/stack-auth --filter @stackframe/stack-shared typecheck
  • pnpm -C /home/ubuntu/repos/stack-auth --filter @stackframe/stack-shared lint

Link to Devin session: https://app.devin.ai/sessions/ad50447fe1de425888f6d54a66376297
Requested by: @N2D4


Summary by cubic

Stop retry/failover for non-KnownError 4xx API responses (including refresh-token requests) and surface them as normal Errors. 5xx still fall back to the next URL (no retry for single-URL clients) and keep the 508 INFINITE_LOOP_DETECTED special-case.

  • Bug Fixes
    • Treat KnownErrors and 4xx as non-retryable across sticky and full-iteration modes.
    • For 4xx, throw Error with the Response as cause; apply to refresh-token flow.
    • For 5xx, return Result.error(HexclaveAssertionError) to enable cross-URL fallback; no retry on single-URL clients.
    • Tightened 4xx detection: non-retry only when error.cause is a 4xx Response.
    • Added tests for 4xx no-retry and error wrapping, 5xx single-URL no retry, and 5xx cross-URL fallback.

Written for commit c6944cb. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Prevented retries and URL failover for application-level known errors and client-side HTTP 4xx responses (excluding rate limits), reducing unnecessary retries and improving reliability.
    • Ensured refresh-token failures surface underlying response details for clearer error reporting.
  • Tests

    • Expanded tests to cover non-retriable 4xx/5xx scenarios and failover behavior across multiple API URLs.

Review Change Stack

@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@vercel
Copy link
Copy Markdown

vercel Bot commented May 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-auth-mcp Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-auth-skills Error Error May 26, 2026 8:58pm
stack-backend Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-dashboard Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-demo Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-docs Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-preview-backend Ready Ready Preview, Comment May 26, 2026 8:58pm
stack-preview-dashboard Ready Ready Preview, Comment May 26, 2026 8:58pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed9e207a-800d-4593-afef-7a5f3a0ed88f

📥 Commits

Reviewing files that changed from the base of the PR and between 0480409 and c6944cb.

📒 Files selected for processing (2)
  • packages/stack-shared/src/interface/client-interface.test.ts
  • packages/stack-shared/src/interface/client-interface.ts

📝 Walkthrough

Walkthrough

This PR extends the HTTP client's fallback and retry logic to treat 4xx responses as non-retryable, matching existing KnownError behavior. It adds helper methods to classify such errors, updates sendClientRequestInner to throw wrapped errors for 4xx responses, applies the classification across all fallback paths, and verifies the behavior with comprehensive tests.

Changes

4xx Response Non-Retryable Fallback

Layer / File(s) Summary
Non-retryable error classification helpers and documentation
packages/stack-shared/src/interface/client-interface.ts
Added _shouldSkipFallback and _isNonRetryableApiResponseError helpers and updated _withFallback docs to treat KnownError and 4xx API responses (except 429) as non-retryable.
Apply non-retryable classification across fallback catch paths
packages/stack-shared/src/interface/client-interface.ts
Replaced instanceof KnownError checks with _shouldSkipFallback(e) in sticky probe, sticky-URL error handling, and per-URL iteration catch blocks so 4xx-wrapped errors skip fallback.
Error construction for HTTP 4xx responses
packages/stack-shared/src/interface/client-interface.ts
sendClientRequestInner now throws a plain Error for 4xx responses and attaches the underlying Response as cause, enabling detection of non-retryable 4xx errors.
Refresh token error cause
packages/stack-shared/src/interface/client-interface.ts
When refresh-token exchange fails (response.data.ok false), the thrown error now includes response.data as its cause.
Test helper and test cases for 4xx and 5xx behavior
packages/stack-shared/src/interface/client-interface.test.ts
Added createTextResponse helper and new _withFallback tests asserting non-KnownError 4xx responses are not retried/fallbacked and that 5xx responses behave correctly for single vs multiple API URLs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A rabbit hops through fallback paths with care,
Four-hundreds now stop short—no retry there!
Cause attached, helpers listen, decisions made,
Tests stand guard where URL priorities fade.
🥕 Thump-thump — the client skips what shouldn't be retried.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing how 4xx HTTP responses are handled in client interface retry logic.
Description check ✅ Passed The description provides detailed context about the changes, including the problem being solved, how 4xx/5xx responses are handled differently, test commands, and references to the Devin session and requester.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1761682140-client-interface-4xx-retry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI review requested due to automatic review settings May 26, 2026 20:36
@N2D4 N2D4 review requested due to automatic review settings May 26, 2026 20:36
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Tip: cubic could auto-approve low-risk PRs like this, if it thinks it's safe to merge. Learn more

Re-trigger cubic

Copilot AI review requested due to automatic review settings May 26, 2026 20:37
@N2D4 N2D4 review requested due to automatic review settings May 26, 2026 20:37
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 26, 2026

Greptile Summary

This PR fixes a bug where non-KnownError 4xx API responses were being retried and failed-over to other URLs, when they should be treated as application-level errors. It also changes these errors from HexclaveAssertionError to a plain Error with the Response as cause.

Confidence Score: 4/5

The change is safe to merge; logic is consistent across all three catch sites and the cause: Response detection pattern is well-scoped.

The implementation correctly handles all edge cases — 429 rate-limiting is intercepted before the new 4xx branch, KnownError headers are resolved by _processResponse before status-based branching, and the single-URL path simply propagates the throw without needing _shouldSkipFallback.

Both changed files are straightforward; no files require special attention.

Important Files Changed

Filename Overview
packages/stack-shared/src/interface/client-interface.ts Adds _shouldSkipFallback / _isNonRetryableApiResponseError helpers and changes 4xx error handling to throw a plain Error with cause: res instead of HexclaveAssertionError, correctly preventing retry and fallback for application-level 4xx responses.
packages/stack-shared/src/interface/client-interface.test.ts Adds a createTextResponse helper and two tests: one verifying no retry/fallback on 4xx, and one verifying the error is a plain Error with the correct message format.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[_withFallback called] --> B{Single URL?}
    B -- Yes --> C[cb with maxAttempts=5]
    B -- No --> D{Sticky mode?}
    D -- Yes --> E[_tryStickyUrl]
    D -- No --> F[_iterateUrls]
    E --> G{Error thrown?}
    G -- Yes --> H{_shouldSkipFallback?}
    H -- Yes: KnownError or 4xx --> I[rethrow immediately]
    H -- No: network error --> J[clear sticky, fall to _iterateUrls]
    G -- No --> K[return result]
    F --> L{Error thrown per URL?}
    L -- Yes --> M{_shouldSkipFallback?}
    M -- Yes: KnownError or 4xx --> I
    M -- No --> N[try next URL / pass]
    N --> O{Exhausted?}
    O -- Yes --> P[throw lastError]
    O -- No --> L
    L -- No --> K
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/stack-shared/src/interface/client-interface.test.ts:452-455
The first new test uses `toThrow(Error)`, which matches any `Error` subclass including `HexclaveAssertionError`. Before this fix, the old code also threw a `HexclaveAssertionError` (not returned `Result.error`), so the `toThrow(Error)` assertion would have passed even without the fix. The `log.length === 1` assertion is the load-bearing check here. Using `toMatchObject({ name: "Error" })` would make the assertion explicitly target the plain-`Error` requirement and catch a regression if the type reverts.

```suggestion
    const iface = createClientInterface({ apiUrls: urls });
    await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" });
    expect(log.length).toBe(1);
    expect(urlIndex(urls, log[0])).toBe(0);
```

Reviews (1): Last reviewed commit: "Preserve 5xx API fallback behavior" | Re-trigger Greptile

Comment on lines +452 to +455
const iface = createClientInterface({ apiUrls: urls });
await expect(sendRequest(iface)).rejects.toThrow(Error);
expect(log.length).toBe(1);
expect(urlIndex(urls, log[0])).toBe(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The first new test uses toThrow(Error), which matches any Error subclass including HexclaveAssertionError. Before this fix, the old code also threw a HexclaveAssertionError (not returned Result.error), so the toThrow(Error) assertion would have passed even without the fix. The log.length === 1 assertion is the load-bearing check here. Using toMatchObject({ name: "Error" }) would make the assertion explicitly target the plain-Error requirement and catch a regression if the type reverts.

Suggested change
const iface = createClientInterface({ apiUrls: urls });
await expect(sendRequest(iface)).rejects.toThrow(Error);
expect(log.length).toBe(1);
expect(urlIndex(urls, log[0])).toBe(0);
const iface = createClientInterface({ apiUrls: urls });
await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" });
expect(log.length).toBe(1);
expect(urlIndex(urls, log[0])).toBe(0);
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/stack-shared/src/interface/client-interface.test.ts
Line: 452-455

Comment:
The first new test uses `toThrow(Error)`, which matches any `Error` subclass including `HexclaveAssertionError`. Before this fix, the old code also threw a `HexclaveAssertionError` (not returned `Result.error`), so the `toThrow(Error)` assertion would have passed even without the fix. The `log.length === 1` assertion is the load-bearing check here. Using `toMatchObject({ name: "Error" })` would make the assertion explicitly target the plain-`Error` requirement and catch a regression if the type reverts.

```suggestion
    const iface = createClientInterface({ apiUrls: urls });
    await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" });
    expect(log.length).toBe(1);
    expect(urlIndex(urls, log[0])).toBe(0);
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0480409 by matching { name: "Error" } in the no-retry/fallback test.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/stack-shared/src/interface/client-interface.test.ts (1)

458-466: 💤 Low value

Consider verifying the Response is attached as error cause.

The test correctly checks the error message format, but per the PR description the 4xx Response should be attached as cause. Explicitly verifying this would make the test more complete.

💡 Optional enhancement to verify cause
     const iface = createClientInterface({ apiUrls: urlList(1) });
-    await expect(sendRequest(iface)).rejects.toMatchObject({
-      name: "Error",
-      message: expect.stringContaining("402 Payments are not set up"),
-    });
+    try {
+      await sendRequest(iface);
+      throw new Error("Expected request to fail");
+    } catch (error) {
+      expect(error).toMatchObject({
+        name: "Error",
+        message: expect.stringContaining("402 Payments are not set up"),
+      });
+      expect(error).toHaveProperty("cause");
+      expect((error as Error).cause).toBeInstanceOf(Response);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack-shared/src/interface/client-interface.test.ts` around lines
458 - 466, The test should also assert that the thrown Error includes the
original Response as its cause: create the Response instance (e.g., const resp =
createTextResponse("Payments are not set up", { status: 402 })), stub fetch to
return that resp, then update the assertion to verify the rejection includes a
cause property equal to resp (or matching its status/text) when calling
sendRequest(iface); reference sendRequest, createClientInterface, fetch stub,
and createTextResponse to make the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stack-shared/src/interface/client-interface.ts`:
- Around line 221-223: Update the comment describing retry behavior to exclude
429: change the sentence that currently states "KnownErrors and 4xx API
responses are never retried (they're application-level, not network-level)" to
explicitly say "KnownErrors and 4xx API responses (except 429) are never
retried" so it matches the implementation in sendClientRequestInner() which
treats 429 specially and honors Retry-After. Ensure the updated wording appears
where the retry contract is documented (the block describing "full iteration
tries every URL once per pass for 2 passes...").
- Around line 246-253: The helper
_shouldSkipFallback/_isNonRetryableApiResponseError currently treats 4xx as
non-retryable only when the thrown Error carries a Response in error.cause, but
_fetchNewAccessTokenInner throws a plain Error on non-OK token responses so
falls through to retry logic; update _fetchNewAccessTokenInner (used by
fetchNewAccessToken and _withFallback) to throw an Error that includes the HTTP
response as the cause (e.g. throw new Error('token fetch failed', { cause:
response }) or include response.data as the cause) so
_isNonRetryableApiResponseError can detect 4xx token responses, or alternatively
limit the scope by moving/renaming _isNonRetryableApiResponseError and its usage
so it only applies to sendClientRequestInner.

---

Nitpick comments:
In `@packages/stack-shared/src/interface/client-interface.test.ts`:
- Around line 458-466: The test should also assert that the thrown Error
includes the original Response as its cause: create the Response instance (e.g.,
const resp = createTextResponse("Payments are not set up", { status: 402 })),
stub fetch to return that resp, then update the assertion to verify the
rejection includes a cause property equal to resp (or matching its status/text)
when calling sendRequest(iface); reference sendRequest, createClientInterface,
fetch stub, and createTextResponse to make the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6096d64c-1def-4256-b0d9-a66b91b028b0

📥 Commits

Reviewing files that changed from the base of the PR and between a06c933 and 0480409.

📒 Files selected for processing (2)
  • packages/stack-shared/src/interface/client-interface.test.ts
  • packages/stack-shared/src/interface/client-interface.ts

Comment thread packages/stack-shared/src/interface/client-interface.ts Outdated
Comment thread packages/stack-shared/src/interface/client-interface.ts
Copilot AI review requested due to automatic review settings May 26, 2026 20:48
@N2D4 N2D4 review requested due to automatic review settings May 26, 2026 20:48
@N2D4 N2D4 merged commit 018ecd1 into dev May 26, 2026
@N2D4 N2D4 deleted the devin/1761682140-client-interface-4xx-retry branch May 26, 2026 20:48
@devin-ai-integration
Copy link
Copy Markdown
Contributor

Post-merge note: Vercel – stack-auth-skills is still failing. I couldn't inspect Vercel logs because the CLI requires interactive Vercel auth in this session. Local pnpm -C /home/ubuntu/repos/stack-auth --filter @stackframe/skills build reproduces a failure in packages/stack-shared import.meta.vitest typings (e.g. packages/stack-shared/src/crud.tsx:194), which is outside this PR's changed files and also present on origin/dev.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant