Skip to content

Provider transientRetryOn5xx never reaches the Responses passthrough lane #4893

Closed

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

  1. added
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Sep 17, 2026
  2. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    OwnerAuthor

    리뷰 · 우선순위 72 / 80

    이 이슈는 프로바이더 설정 transientRetryOn5xx가 Responses 패스스루 레인에 닿지 않는다는 정적·런타임 버그다. adapter: openai-responses + key auth에 attempts: 1을 넣어도 /v1/responses는 항상 TRANSIENT_RETRY_MAX_ATTEMPTS(3)로 보내고, attempts: 10도 3이다. 같은 설정을 openai-chat에 두면 존중된다.

    현재 tip 41f1832(package 2.58.0)에서 이슈가 적은 세 사실은 그대로다. transientRetryPolicyFor 호출은 chat-native·adapter-continuation·adapter-dispatch 쪽에 있고, createResponsesPassthroughAdapter는 passthrough: true(src/adapters/openai-responses/passthrough.ts 약 190행)라 responses/core.ts가 executePassthroughResponse로 early return한다.

    src/server/responses/passthrough-dispatch.ts는 transientRetryPolicyFor를 import조차 하지 않고 약 800·865·1130·1255행에서 remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS) 고정값을 쓴다. draft PR #4800은 transientRetryPolicyFor 게이트에 openai-responses만 허용하도록 넓히는데, 패스스루 레인이 그 함수를 호출하지 않으면 이슈 재현(항상 3회)은 그대로다. 이슈 본문의 "게이트만 열어선 안 된다"가 tip에서도 맞다.

    예산 회계도 이슈가 지적한 대로다. attempts를 프로바이더 값으로 바꾸면 request-wide remainingTransientSendBudget과 파일 안 여러 recovery leg, non-replayable body 경계가 한꺼번에 열린다. chat-native(약 301행)는 이미 정책을 읽으므로, 패스스루만 고정 상수인 비대칭이 버그 증거다.

    라인 800 - src/server/responses/passthrough-dispatch.ts: attempts에 TRANSIENT_RETRY_MAX_ATTEMPTS 고정
    라인 106 - src/lib/upstream-retry.ts: TRANSIENT_RETRY_MAX_ATTEMPTS = 3
    경로/심볼 - src/server/responses/core.ts: adapter.passthrough면 executePassthroughResponse로 분기
    경로/심볼 - src/providers/key-failover.ts:314 transientRetryPolicyFor — #4800이 여기를 넓혀도 패스스루 미호출이면 무효
    라인 301 - src/server/chat-native.ts: chat-native는 정책을 읽음 → openai-chat과의 비대칭

    메인테이너의 판단이 필요한 지점

    • 패스스루에 provider attempts를 넣을 때 request-wide 예산·non-replayable·recovery leg 4곳을 어떻게 맞출지
    • #4800을 불완전 형제로 두고 passthrough-dispatch 배선 PR을 요구할지, #4800을 확장할지
    • 문서만으로 "패스스루는 고정 3"을 밝히고 코드는 나중에 할지는 설정 UI 기대와 충돌

    너의 추천
    #4800만 merge하지 말고, passthrough-dispatch.ts의 고정 상수를 transientRetryPolicyFor(route.provider)?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS 패턴으로 바꾸는 후속(또는 #4800 확장)을 이 이슈에 묶는다. 회귀: attempts:1 → upstream 1회, attempts:5 → 최대 5회, 정책 없음 → 3회.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    OwnerAuthor

    Fixed on dev by #4925, squashed as 41a3abb992a87f0e24d0c0aa72775df507926ff8.

    Your three source facts were exactly right, including the part that makes it awkward: the lane was not missing transient-5xx replay, it had an unconditional one that no provider setting could move in either direction. passthrough-dispatch.ts now resolves the provider's own transientRetryOn5xx cap and every call site in that file reads it — the initial send and all three recovery legs — instead of passing TRANSIENT_RETRY_MAX_ATTEMPTS literally. The constant remains the default when a provider declares no policy, so an operator who never configured this sees the same behavior as before.

    The send budget is unchanged: the resolved cap is still passed through remainingTransientSendBudget, so a provider configuring a larger ladder cannot spend more physical sends than the request's budget allows. Raising attempts raises the ceiling for the ladder, not the floor under the budget.

    Worth recording for anyone reading the diff later: landing this needed a merge with dev, where the upstream-retry import block had been reformatted in the meantime. The resolution drops TRANSIENT_RETRY_MAX_ATTEMPTS from that import because all six of its former uses in the file now go through the resolved cap; no call site was left reading the constant.

    Shipping in the next release off dev.

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