Skip to content

fix(adapters): charge adapter-owned retry sends to the request send budget - #4865

Merged
lidge-jun merged 3 commits into
lidge-jun:devfrom
luvs01:codex/adapter-send-budget-admission-20260917
Sep 17, 2026
Merged

lidge-jun merged 3 commits into
lidge-jun:devfrom
luvs01:codex/adapter-send-budget-admission-20260917

Conversation

@luvs01

@luvs01 luvs01 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Route the three adapter-owned retry ladders that still issued bare fetches through the request send budget: mimo-free's 401 JWT replay, command-code's reasoning-effort repair retry, and the google-http transient-retry loop (Vertex, Antigravity, AI Studio direct). Each upstream send now reserves through ctx.sendBudget before dispatch.
  • Add createAdapterPhysicalSend, one ordinal sequence per adapter fetchResponse call. Admission precedes executor pacing slots, backoff sleeps and superseded-response cancellation, so a refused retry does not pay its retry preparation. A credential hop's pending permit pays for the adapter's first send exactly once through the existing dispatch view; a refused retry returns the real upstream response rather than a synthetic error.
  • Fix one defect found while reviewing that admission order. In mimo-free, moving the drain behind admission also moved it after the JWT refresh, and getMimoJwt issues its own bootstrap request and can reject — leaving the 401 body unreleased, which the pre-change code did not do. The drain now runs first inside beforeDispatch: still after admission, so a budget-refused replay still returns that response with a readable body, but no longer dependent on the refresh succeeding.
  • Follow-up to fix(responses): enforce shared send budgets across retries and recovery #4621. Upstream absorbed that PR's budget core (adapterDispatchBudget, pendingHopPermit, permit.assumeCharge()); this change is the remaining value: the adapter dispatch sites that still bypassed the budget entirely. The combo-refund portion of fix(responses): enforce shared send budgets across retries and recovery #4621 is dropped as no longer reproducible on current dev.
  • No configuration, credential, routing, or protocol changes. No new dependencies.

Status

Merged into dev as squash commit 5ea488f7c7a212d17a66fbb1fe94b1ce29ac084a on 2026-09-17. The branch continues as the post-merge follow-up vehicle: it now carries the merge of current dev plus one review-note commit (the google-http 429 peek clone comment requested in review), which is not part of the merged diff.

Verification

Exact source identity

  • Published head: 40a103713ecdae2386775946e90cceb8ecaaa597 (tree e67a2e36f16fa9886ee7a269eebe77af016f8ed9).
  • Base integrated by merge: current dev tip 6d19a07369f218c0a0d104ed23232c0c8c75a4fc (which contains the merged squash 5ea488f7c). The merge was clean; no conflict resolution was involved.
  • Delta on top of the merged state: one one-line comment commit on src/adapters/google-http.ts documenting the 429 peek res.clone() cost and failure fallback, per review.

What the admission contract is, and where it is enforced

Three properties were checked by reading the source rather than by trusting the ladder shape.

One physical send is charged once. reserveDispatch books the spend at reservation time, not at use() — deliberately, per src/lib/request-execution-budget.ts, because deciding and charging separately let two legs read the same remainder and both dispatch. createAdapterPhysicalSend reserves once per call, and the executor it hands the adapter holds a dispatched latch beside permit.use(), so a second call on one reservation throws SendBudgetExhaustedError instead of sending twice. onPhysicalSend is observation only: noteAdapterPhysicalSend records an attempt send and never touches the counter, so there is no double count.

A send that never happens is never charged. permit.release() in the finally returns the booking whenever the permit was not used, and is a no-op once settled. Every exit before dispatch refunds: an aborted signal at entry, an abort observed after pacing, an abort observed after beforeDispatch, and a throw from beforeDispatch itself.

A refusal stays visible. Each ladder returns the last real upstream response with its status, Retry-After and quota body intact, which is the established exhaustion contract. A refusal with no prior upstream answer propagates instead, and src/server/responses/adapter-dispatch.ts answers it as 429 with SEND_BUDGET_EXHAUSTED_CODE rather than 502 — which matters, because the Codex client retries 5xx and does not retry 429.

One behaviour change worth naming

The google-http 429 peek now always clones (res.clone()), where it previously read res directly unless returnRawErrors was set. This is required — pendingResponse may have to be returned later, so the original body has to survive the peek — and it is observationally identical on the quota-exhausted path, because formatMessage already falls back with payloadText || peek. Before the change normalizeUpstreamHttpErrorResponse re-read an exhausted body and got "", then used peek; after it, payloadText holds that same text. The post-merge head adds a one-line code comment recording the clone's extra buffered body read and that a failed peek returns "" and falls through as a transient retry.

Checks

Check Result
Cross-platform CI at the merged head 31229b268 run 35201948739 concluded success, every test, gate, keyring, npm-global, docker and macos leg green
Post-merge verification at head 40a103713 bun run typecheck, bun run structure:check, bun run privacy:scan all pass; 134 tests pass, 0 fail across the 5 touched test files (physical-send, google-vertex-http, mimo-free-provider, command-code-provider, responses-core-modules)
Fork CI at the post-merge head run 35217752681 dispatched, in progress
New and converted admission regressions 133 pass, 0 fail — contributor evidence at source tree 6f50d2f1f8, which predates both the merge base and the mimo fix
Red check on unmodified dev all 15 new/converted cases fail without the adapter changes — same contributor evidence

The red-to-green evidence matters here: the prepaid-hop, refund and exhaustion cases exercise the reservation path, so they fail on a dev tree where these ladders still send without reserving.

The added regression, a rejected JWT refresh still releases the first 401 body, drives the bootstrap to the oversized-response rejection the file already exercises and asserts the 401 stream was cancelled. It fails on the pre-fix ordering.

Remaining gates

  • Independent maintainer and security review are not claimed.
  • Fork CI run 35217752681 in progress; dispatch-only macos control leg is expected to hit its 30-min cap (infra limitation, other lanes are the signal).
  • Kiro and Cursor already reserve per physical send on dev and are unchanged; this PR covers the ladders that did not.
  • Post-merge audit of src/adapters/ for retry ladders still issuing bare fetches outside ctx.sendBudget/createAdapterPhysicalSend: one candidate — src/adapters/cursor/live-models.ts (fetchCursorUsableModels runs one bounded retry through the raw fetch seam on the discovery/management path, which carries no send budget). Single-shot credential/preflight hops remain bare fetches by design: src/adapters/mimo-free.ts:113 (JWT bootstrap inside beforeDispatch prep), src/adapters/devin/cloud-direct/auth.ts:101 (GetUserJwt mint), src/adapters/devin/cloud-direct/catalog.ts:201 (catalog preflight), src/adapters/devin/cloud-direct/chat.ts:1210 (one send per call, counted by the outer admission).
  • The google-http clone comment on this branch is not in the merged squash; it can ride a small follow-up PR if wanted.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. No user-facing surface changed; the budget contract is internal, and src/adapters/ is already a claimed ownership area so structure:check has nothing new to bind.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. Admission is the only change; no credential, routing or default behaviour moved. The mimo fix touches when a response body is drained, not what is sent or with which credential.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing. Merged head 31229b26849a8d21524ad090d4cabf97d8f57939: cross-platform CI run 35201948739 concluded success. Post-merge head 40a103713ecdae2386775946e90cceb8ecaaa597: local typecheck/structure/privacy and 134 touched-file tests pass; fork CI run 35217752681 is in progress.

  • I pushed my PR to the latest dev commit. Branch now merges dev tip 6d19a07369f218c0a0d104ed23232c0c8c75a4fc.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • Bug Fixes
    • Improved request retry handling across supported adapters, including rate limits, transient failures, connection resets, and authorization refreshes.
    • Preserved the most recent response when retry capacity is exhausted instead of attempting unnecessary retries.
    • Improved response cleanup during retries and failed authorization refreshes.
    • Enhanced account failover behavior after rate limiting and empty completions.
    • Added safeguards to prevent requests from exceeding configured send limits.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: aa32e2a8-ed14-4319-abc9-72c936e156cb

📥 Commits

Reviewing files that changed from the base of the PR and between f1dfda8 and 31229b2.

📒 Files selected for processing (12)
  • scripts/test-layout/layout.json
  • src/adapters/command-code.ts
  • src/adapters/google-http.ts
  • src/adapters/mimo-free.ts
  • src/adapters/physical-send.ts
  • tests/adapters/google/google-vertex-http.test.ts
  • tests/adapters/physical-send.test.ts
  • tests/fixtures/test-layout-expected.json
  • tests/helpers/send-budget-owner.ts
  • tests/providers/command-code-provider.test.ts
  • tests/providers/mimo-free-provider.test.ts
  • tests/responses/responses-core-modules.test.ts

📝 Walkthrough

Walkthrough

The change adds budgeted physical dispatches for adapter requests. Command-code, Google, and MiMo retry paths use the new wrapper. The tests cover prepaid sends, retry admission, response preservation, account failover, JWT recovery, and send-budget accounting.

Changes

Adapter send-budget flow

Layer / File(s) Summary
Physical dispatch wrapper and accounting
src/adapters/physical-send.ts:1-50, tests/adapters/physical-send.test.ts:1-107, tests/helpers/send-budget-owner.ts:1-23, scripts/test-layout/layout.json:191, tests/fixtures/test-layout-expected.json:23
Adds createAdapterPhysicalSend. It validates abort signals, manages permits, applies pacing and beforeDispatch, reports send ordinals, and releases permits. Tests cover refusal, refunds, settled charges, and exhausted budgets.
Adapter retry integration
src/adapters/command-code.ts:16-17,474,561-562,583-590, src/adapters/google-http.ts:2-4,14-15,49-72,84-85,96,105-125, src/adapters/mimo-free.ts:9-10,253-292
Routes adapter dispatches through the send wrapper. Retry paths record recovery kinds, defer backoff to dispatch hooks, cancel or preserve response bodies, and return prior responses when retry admission fails.
Adapter and provider validation
tests/adapters/google/google-vertex-http.test.ts:1-4,36-72, tests/providers/command-code-provider.test.ts:2-11,52-95,707-708,722-750, tests/providers/mimo-free-provider.test.ts:324-325,342-377,397-425, tests/responses/responses-core-modules.test.ts:8-9,115-128
Expands coverage for prepaid budget exhaustion, OAuth failover, reasoning-effort refresh, MiMo retry modes, JWT response cancellation, and shared budget-owner setup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Adapter
  participant createAdapterPhysicalSend
  participant SendBudget
  participant Executor
  Adapter->>createAdapterPhysicalSend: send request
  createAdapterPhysicalSend->>SendBudget: reserve dispatch permit
  createAdapterPhysicalSend->>Executor: apply pacing and dispatch
  Executor-->>Adapter: return response or retryable failure
  Adapter->>createAdapterPhysicalSend: request recovery send
  createAdapterPhysicalSend->>SendBudget: admit recovery permit
  createAdapterPhysicalSend->>Executor: dispatch recovery request
Loading
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • review readiness checklist open (3/4 boxes ticked).

What to do

  • Tick all four boxes in the PR description once you're done (currently 3/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ✅ I pushed my PR to the latest dev commit.
  • ✅ I resolved all correct Codex and CodeRabbit findings.
  • ✅ My PR is ready for review.

3/4 boxes ticked.

Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked.

@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 73 / 80

이 PR은 luvs01의 어댑터 쪽 후속 작업이다. 닫힌 #4621이 남긴 핵심 가치, 즉 아직 요청 send budget을 우회하던 어댑터 소유 재시도 사다리를 ctx.sendBudget 예약 경로로 묶는 일이다. 현재 dev HEAD는 e18ca2463(tip #4862, config show가 connect graph를 불러오지 않게 한 perf)이고 패키지는 2.58.0이다. 이 PR이 포함한 dev7a29e7b66(#4860 stream oracle)이라 tip보다 커밋 하나 뒤다. 건드리는 파일은 command-code / google-http / mimo-free 세 어댑터와 새 헬퍼 src/adapters/physical-send.ts, 그리고 그에 맞춘 테스트·픽스처다. 설정·자격 증명·라우팅·프로토콜 표면은 건드리지 않는다.

핵심 설계는 createAdapterPhysicalSend 하나다. 어댑터 fetchResponse 호출마다 서수(ordinal) 시퀀스를 만들고, 업스트림으로 나가는 매 physical send 앞에서 reserveDispatch로 입장(admission)한다. 백오프 sleep·이전 응답 cancel·JWT 갱신 같은 준비 작업은 beforeDispatch로 미뤄서, 거절된 재시도가 준비 비용까지 내지 않게 한다. 예산이 바닥나면 합성 에러 대신 직전 실제 업스트림 응답을 돌려주는 경로(mimo/command-code의 SendBudgetExhaustedError catch, google의 pendingResponse 반환)가 있다. credential hop이 이미 예약한 permit은 dispatch view로 첫 send에 한 번만 쓰이게 하고, 거절 시 prepaid hop charge는 유지·reserve-funded repair는 환불된다는 회귀가 physical-send.test.ts에 있다.

세 사다리별 변화는 짧다. mimo-free는 401 JWT 재시도를 sendClass: "auth-recovery" / recovery: "oauth-401"로 보내고, 캐시 리셋·토큰 발급·본문 cancel을 admission 뒤로 옮겼다. command-code는 reasoning-effort repair 재시도를 sendClass: "repair" / recovery: "reasoning-effort-downgrade"로 묶고, fetchCommandCode가 항상 넘어온 executor만 쓰게 정리했다. google-http(Vertex/Antigravity/AI Studio direct 공통 루프)는 transient·400 repair·429 peek 뒤 재시도를 같은 helper로 통과시키며, 예산 소진 시 pendingResponse를 돌려준다. 429 peek는 이제 항상 res.clone()한다 — 본문을 남겨 두어야 거절 시 원응답을 다시 쓸 수 있어서다.

검증 주장도 구체적이다. 새/변환 회귀 133 pass, unmodified dev에서 관련 15 케이스가 빨강으로 깨진다는 red-to-green, typecheck·structure·privacy 통과를 본문에 적었다. SendClass / AttemptRecoveryKind 값(auth-recovery, repair, oauth-401, reasoning-effort-downgrade, rate-limit-429, transient-5xx, connection-reset)은 현재 dev 타입과 맞다. types.ts/config.ts 분리 캠페인과도 무관하다. 다만 head는 tip #4862 이전이므로 exact-head CI·체크리스트 완료 전에는 Ready가 아니다. mergeable은 MERGEABLE이고 충돌 파일도 (#4862는 config-command 쪽) 겹치지 않아 rebase 비용은 작다.

우선순위 73/80으로 둔 이유: 예산 우회를 막는 방향이 맞고, 헬퍼 한 곳으로 세 사다리를 모은 구조도 읽기 쉬우며, 테스트가 계약(입장 전 준비 금지·prepaid 유지·exhaust 시 실응답)을 행동으로 고정한다. 점수를 더 올리지 않은 이유는 tip rebase 미완, Draft 체크리스트 미완, 그리고 google 루프의 pendingResponse/clone 경로가 유지보수 부담을 조금 키운다는 점이다. Kiro/Cursor 등 이미 physical send를 예약하던 경로는 손대지 않았다는 주장도 본문과 일치해 보인다.

경로/심볼 createAdapterPhysicalSend - 새 src/adapters/physical-send.ts. admission → pacing → beforeDispatch → dispatch(physicalExecutor) 순서와 permit.release() finally는 계약에 맞음. sendBudget이 없으면 reservation 없이 통과하는 기존 동작도 유지됨.
라인(google-http 429 peek) - ctx.returnRawErrors ? res.clone() : res를 항상 res.clone()로 바꿈. pendingResponse 재사용을 위해 필요해 보이지만, 본문 이중 읽기 비용·clone 실패 시 동작을 한 줄로 본문/테스트에 남겨 두면 좋음.
경로/base - 포함 dev7a29e7b66이고 tip은 e18ca2463(#4862). 충돌 가능성은 낮으나 exact-head를 위해 rebase가 필요함.
경로/체크리스트 - Draft이며 local CI·Ready 박스가 아직 비어 있음. fork run 증거 링크는 본문에 있음.
경로/#4621 - 닫힌(머지되지 않은) PR의 예산 코어는 upstream에 흡수됐고, 이 PR은 남은 어댑터 dispatch 사이트만 다룬다고 명시됨. 중복 재제출이 아니라 후속으로 읽힘.

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

  • tip e18ca2463 위로 rebase한 뒤 전체 CI를 이 head에서 다시 볼지.
  • google 429 peek 항상-clone이 의도된 계약인지, returnRawErrors=false 경로의 본문 소비 차이가 문서화될 필요가 있는지.
  • 같은 패턴이 아직 bare fetch로 남은 다른 어댑터 사다리가 더 있는지(후속 이슈로 열지).

너의 추천
KEEP Draft. dev tip(e18ca2463)으로 rebase한 다음 exact-head CI가 초록이면 merge 후보. types/config 분리에 무효화되지 않으니 close-don't-rebase 대상이 아니다. 체크리스트 네 칸을 채운 뒤에 Ready.

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

luvs01 and others added 3 commits September 17, 2026 17:49
…udget

mimo-free's 401 JWT replay, command-code's reasoning-effort repair, and the google-http transient loop each issued bare fetches that never touched ctx.sendBudget, so a request holding only its final recovery permit still dispatched and a refused retry still paid the backoff sleep.

Route each physical send through createAdapterPhysicalSend: admission precedes pacing, backoff and superseded-response cancellation, a credential hop's pending permit pays for the first send exactly once, and a refused retry returns the real upstream response instead of a synthetic error.

Follow-up to lidge-jun#4621.
The 401 replay moved its drain behind admission so that a budget-refused
replay can still return that same response with a readable body. Inside
beforeDispatch it ran last, after resetMimoJwtCache and getMimoJwt.

getMimoJwt issues its own bootstrap request and rejects on a failed or
oversized response. When it did, fetchResponse threw and the 401 body was
never released - a leak the pre-change code did not have, because it
cancelled first and refreshed second.

Draining first WITHIN beforeDispatch keeps both properties: it is still
after admission, so a refusal returns the untouched response, and it no
longer depends on the refresh succeeding.
@lidge-jun
lidge-jun force-pushed the codex/adapter-send-budget-admission-20260917 branch from 22130fb to 31229b2 Compare September 17, 2026 08:51
@github-actions
github-actions Bot marked this pull request as ready for review September 17, 2026 09:31
@lidge-jun
lidge-jun merged commit 5ea488f into lidge-jun:dev Sep 17, 2026
35 of 38 checks passed
@luvs01

luvs01 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Post-merge follow-up on this branch:

  • New head 40a103713ecdae2386775946e90cceb8ecaaa597 (tree e67a2e36f16fa9886ee7a269eebe77af016f8ed9): the merged content plus a clean merge of current dev tip 6d19a07369f218c0a0d104ed23232c0c8c75a4fc (which contains the squash 5ea488f7c), plus one review-note commit.
  • Review note addressed: src/adapters/google-http.ts now carries a one-line comment on the 429 peek recording that res.clone() costs one extra buffered body read and that a failed peek returns "" and falls through as a transient retry.
  • Bare-fetch audit (src/adapters/, post-merge dev): one remaining retry ladder outside ctx.sendBudget/createAdapterPhysicalSendsrc/adapters/cursor/live-models.ts (fetchCursorUsableModels runs one bounded retry through the raw fetch seam on the discovery path, which carries no send budget). Single-shot hops still on bare fetch by design: mimo-free.ts:113 (JWT bootstrap inside beforeDispatch prep), devin/cloud-direct/auth.ts:101 (GetUserJwt mint), devin/cloud-direct/catalog.ts:201 (catalog preflight), devin/cloud-direct/chat.ts:1210 (one send per call, counted by the outer admission).
  • Verification at the new head: bun run typecheck, bun run structure:check, bun run privacy:scan pass; 134 tests pass, 0 fail across the five touched test files.
  • CI: fork run https://github.com/luvs01/opencodex/actions/runs/35217752681 dispatched on the new head and in progress.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants