Repository navigation
Provider transientRetryOn5xx never reaches the Responses passthrough lane #4893
Description
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:
transientRetryPolicyForhas exactly four call sites insrc/:src/server/chat-native.ts:301,src/server/responses/adapter-continuation.ts:207and:411, andsrc/server/responses/adapter-dispatch.ts:305.createResponsesPassthroughAdapterdeclarespassthrough: true as const(src/adapters/openai-responses/passthrough.ts:190-193), andsrc/server/responses/core.ts:116returns intoexecutePassthroughResponseon exactly that flag — beforecreateAdapterContinuationsor the adapter dispatch path is constructed. So three of those four call sites are unreachable for this adapter.src/server/responses/passthrough-dispatch.tsdoes not importtransientRetryPolicyForat all. Its initial send is wrapped at:760withattempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), and three further call sites in the same file do the same for the recovery legs.TRANSIENT_RETRY_MAX_ATTEMPTSis3(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:
- Configure a provider with
adapter: "openai-responses",authMode: "key", andtransientRetryOn5xx: { attempts: 1 }— a value that should reduce replay to a single send. - Point it at an upstream that answers
503on every request. ocx start --port 10100, then send a non-streaming request to/v1/responsesfor that provider.- Count the upstream requests. Three arrive, not one. Setting
attempts: 10also 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:
remainingTransientSendBudget(...)returned0because the request budget was already spent, which would make this a budget-accounting problem rather than a retry-policy one.isNonReplayableResponse(res)matched, which deliberately forbids the resend (src/lib/upstream-retry.ts:612).Date.now() - attemptStart > slowAttemptMsended the loop — unlikely for a fast 502, and worth ruling out precisely because it is unlikely.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
lidge-jun commentedon Sep 17, 2026
리뷰 · 우선순위 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과의 비대칭
메인테이너의 판단이 필요한 지점
너의 추천
#4800만 merge하지 말고, passthrough-dispatch.ts의 고정 상수를 transientRetryPolicyFor(route.provider)?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS 패턴으로 바꾸는 후속(또는 #4800 확장)을 이 이슈에 묶는다. 회귀: attempts:1 → upstream 1회, attempts:5 → 최대 5회, 정책 없음 → 3회.
이 댓글은 grok-bot이 작성했습니다
fix(responses): honor the provider transient-5xx policy on the passth…
lidge-jun commentedon Sep 17, 2026
Fixed on
devby #4925, squashed as41a3abb992a87f0e24d0c0aa72775df507926ff8.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.tsnow resolves the provider's owntransientRetryOn5xxcap and every call site in that file reads it — the initial send and all three recovery legs — instead of passingTRANSIENT_RETRY_MAX_ATTEMPTSliterally. 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. Raisingattemptsraises 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 theupstream-retryimport block had been reformatted in the meantime. The resolution dropsTRANSIENT_RETRY_MAX_ATTEMPTSfrom 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.