Skip to content

Provider transientRetryOn5xx never reaches the Responses passthrough lane #4893

Description

@lidge-jun

Client or integration

Direct HTTP/API client

Area

Proxy and routing

Summary

A provider's transientRetryOn5xx policy never reaches the Responses passthrough lane. Configuring it on a key-auth provider whose adapter is openai-responses has no effect on /v1/responses, because that lane never reads the policy and instead uses a fixed constant.

I expected an operator who sets transientRetryOn5xx: { attempts: 5 } on such a provider to change how many upstream sends that provider gets on a transient 5xx. Today the number is always TRANSIENT_RETRY_MAX_ATTEMPTS.

The evidence is three source facts that compose:

  1. transientRetryPolicyFor has exactly four call sites in src/: src/server/chat-native.ts:301, src/server/responses/adapter-continuation.ts:207 and :411, and src/server/responses/adapter-dispatch.ts:305.
  2. createResponsesPassthroughAdapter declares passthrough: true as const (src/adapters/openai-responses/passthrough.ts:190-193), and src/server/responses/core.ts:116 returns into executePassthroughResponse on exactly that flag — before createAdapterContinuations or the adapter dispatch path is constructed. So three of those four call sites are unreachable for this adapter.
  3. src/server/responses/passthrough-dispatch.ts does not import transientRetryPolicyFor at all. Its initial send is wrapped at :760 with attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), and three further call sites in the same file do the same for the recovery legs. TRANSIENT_RETRY_MAX_ATTEMPTS is 3 (src/lib/upstream-retry.ts:106).

So the lane is not missing transient-5xx replay — it has an unconditional one that no provider setting can tune, in either direction.

Reproduction

This is a configuration-surface defect, and it is visible statically as well as at runtime.

Statically, from a checkout at the dev tip:

rg -n "transientRetryPolicyFor" src/          # four call sites, none in passthrough-dispatch.ts
rg -n "passthrough" src/adapters/openai-responses/passthrough.ts | head
sed -n '110,125p' src/server/responses/core.ts # the early return on adapter.passthrough
sed -n '755,790p' src/server/responses/passthrough-dispatch.ts # the fixed constant

At runtime:

  1. Configure a provider with adapter: "openai-responses", authMode: "key", and transientRetryOn5xx: { attempts: 1 } — a value that should reduce replay to a single send.
  2. Point it at an upstream that answers 503 on every request.
  3. ocx start --port 10100, then send a non-streaming request to /v1/responses for that provider.
  4. Count the upstream requests. Three arrive, not one. Setting attempts: 10 also produces three.

The same provider configured with adapter: "openai-chat" honours the setting, which is the asymmetry that makes this a bug rather than an undocumented default.

Version

2.58.0 (dev at f1dfda8e48b52a1734eb202550a0225f3e5f8ab1)

Operating system

macOS 15.5 (the defect is platform-independent; it is a routing fact, not a runtime one)

Provider and model

Any key-auth provider with adapter: "openai-responses"

Logs or error output

# The passthrough lane logs its replays through the shared helper, so a tuned-down
# provider still shows the constant ladder:
[upstream-retry] transient 503 (<host>) — retrying (2/3)
[upstream-retry] transient 503 (<host>) — retrying (3/3)

Screenshots and supporting files

No response

Redacted configuration

{
  "providers": {
    "example": {
      "adapter": "openai-responses",
      "authMode": "key",
      "baseUrl": "https://example.invalid/v1",
      "apiKey": "[REDACTED]",
      "transientRetryOn5xx": { "attempts": 1 }
    }
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Why this is a larger change than moving the adapter gate

The obvious patch — admitting openai-responses in transientRetryPolicyFor — does not fix this, because the passthrough lane never calls that function. The fix has to be in the lane itself, and it touches two boundaries that the constant currently insulates it from.

Budget accounting. The passthrough lane already spends remainingTransientSendBudget(...) against the request-wide counter, and fetchWithTransientRetry treats attempts as one total-send budget shared with the inner connection-reset layer rather than a per-layer count. Replacing the constant with a provider value means the provider can now widen a request-wide bound, so the interaction with remainingTransientSendBudget and with the four separate call sites in that file has to be settled deliberately, not by substitution.

The non-replayable boundary. isNonReplayableResponse exists to forbid an automatic resend after the body has already left for the origin. A provider-tunable ladder makes that boundary operator-visible for the first time on this lane: raising attempts must not become a way to get the resend that marker forbids.

Because of those two, this should be scoped as its own change with its own regression coverage, rather than folded into an adapter-gate edit.

A separate, still-open symptom

This issue was found while reviewing a report that a key-auth openai-responses provider terminated on its very first send with an "interrupted with no error" experience on the client. That symptom is not explained by this defect — the lane already retries three times — so its cause is still open. The remaining candidates are narrow and each is checkable from one captured response:

  1. remainingTransientSendBudget(...) returned 0 because the request budget was already spent, which would make this a budget-accounting problem rather than a retry-policy one.
  2. isNonReplayableResponse(res) matched, which deliberately forbids the resend (src/lib/upstream-retry.ts:612).
  3. Date.now() - attemptStart > slowAttemptMs ended the loop — unlikely for a fast 502, and worth ruling out precisely because it is unlikely.
  4. isTransientUpstreamStatus(res.status) did not match the status actually returned.

A capture of the failing response — status, headers, and whether any [upstream-retry] transient ... line was logged — separates all four in one request.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions