Skip to content

feat(routing): bound recovery with a half-open probe lease and honour Retry-After in full (#4546) - #4626

Merged
lidge-jun merged 2 commits into
devfrom
codex/4546-wpf-probe-lease-backpressure
Sep 14, 2026
Merged

lidge-jun merged 2 commits into
devfrom
codex/4546-wpf-probe-lease-backpressure

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

A held account now recovers half-open instead of all at once, and a provider's Retry-After is no longer shortened into an early retry.

The probe lease. #4616 kept a thread's binding during a 5xx streak and promotes a healthy detour when the hold expires, but nothing bounded the trial that decides whether the original account is actually back. A soft-avoided account receives no traffic, so the only way to find out was the hold expiring and handing the still-failing account to every pinned thread at once. src/routing/probe-lease.ts adds a single-holder lease keyed on the health domain: exactly one in-flight probe may test a held account while everyone else keeps the remembered detour, so a failed trial costs the caller nothing. The lease carries a deadline and a generation epoch, and a late answer from a probe that already lost its lease settles as stale and mutates nothing — it cannot overwrite a newer binding or a newer failure state.

Withheld is a real answer. When every candidate is held, the caller receives a typed { kind: "withheld" } outcome — binding remembered, dispatch refused — instead of sending to an account already known to be failing. The requirement was never only "do not forget the account"; it is "in a state where it must not send, actually do not send".

Retry-After is a lower bound, in full. This is a deliberate behaviour change. retryBackoffDelayMs clamped an honoured instruction with Math.min(retryAfter, maxDelayMs), and the same-target 429 wait capped at 60s, which retries earlier than the provider said it would accept. The instruction is now preserved; when honouring it would exceed the wait deadline the request ends with the upstream answer and its Retry-After intact rather than retrying early, which is the exhaustion contract #3294/#3606 and 040_send_budget.md already settled. The local maximum still bounds our own backoff when the provider gave no instruction.

Pool-wide backpressure. Per-request caps do not prevent a retry storm when many requests fail at once. A pool-scoped limiter sits above the per-request budget and admits recovery dispatches — retries and probes, never a new request's initial send — only while they stay under a configurable ratio of observed initial sends over a sliding window (default 20% over 10s, with a floor of 3 so a quiet pool can still recover). Its state is exposed for diagnostics.

Stacked on #4625. Roadmap and stack order: devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md.

Verification

Not run, by explicit instruction: the local suite, bun run typecheck, bun install, and any build. The only proof for this unit is hosted CI at the exact final head SHA; this push used --no-verify.

New and changed coverage: tests/routing/probe-lease.test.ts pins single-holder admission, deadline and epoch expiry, a stale settle mutating nothing, the withheld outcome, and the backpressure ratio with its floor. tests/lib/upstream-retry.test.ts has its clamping assertion rewritten to the preserved-instruction contract, plus two tests for ending with the upstream answer instead of retrying early. The new file is registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json.

Known open: the module is landed tested and not yet called. The transient hold state lives in src/codex/routing.ts, owned by a later layer of this stack, which is obliged to call resolveHeldAccountDispatch from the detour branch and route the withheld outcome to the caller. The backpressure knob is likewise not yet wired to config or to the send sites.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Summary by CodeRabbit

  • Bug Fixes

    • Upstream Retry-After instructions are now honored in full instead of being capped by a local maximum.
    • Requests return the upstream response promptly when the requested retry delay exceeds the caller’s wait deadline.
    • Retry attempts now correctly occur when the requested delay fits within the caller’s deadline.
  • Routing

    • Added transient probe handling for held accounts, ensuring only one recovery probe runs at a time while other requests retain the remembered routing detour.
    • Added pool-wide backpressure for recovery retries and probes to limit excessive recovery traffic.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 13:01
@coderabbitai

coderabbitai Bot commented Sep 14, 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: 9e758564-092b-4eb6-9763-4b3a1b422e02

📥 Commits

Reviewing files that changed from the base of the PR and between 00f1762 and 4b0e415.

📒 Files selected for processing (7)
  • scripts/test-layout/layout.json
  • src/lib/upstream-retry.ts
  • src/routing/probe-lease.ts
  • structure/catalog.md
  • tests/fixtures/test-layout-expected.json
  • tests/lib/upstream-retry.test.ts
  • tests/routing/probe-lease.test.ts

📝 Walkthrough

Walkthrough

The change revises Retry-After deadline handling and adds transient probe leasing with pool-wide recovery backpressure. It also adds tests, routing documentation, and test-layout mappings for the new probe-lease module.

Changes

Retry-After deadline handling

Layer / File(s) Summary
Retry-After deadline behavior
src/lib/upstream-retry.ts, tests/lib/upstream-retry.test.ts
retryBackoffDelayMs now preserves the full instructed delay. fetchWithTransientRetry returns the upstream response when the delay exceeds the caller deadline and retries when it fits within the deadline. Tests cover shorter, equal, and longer deadlines.

Transient probe control

Layer / File(s) Summary
Probe lease lifecycle and dispatch
src/routing/probe-lease.ts
Adds single-holder leases with pacing, deadlines, generation fencing, expiration outcomes, state retention, and probe, detour, or withheld dispatch decisions.
Pool backpressure and supporting validation
src/routing/probe-lease.ts, tests/routing/probe-lease.test.ts, structure/catalog.md, scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json
Adds sliding-window recovery limits, shared limiter configuration, tests for lease and backpressure behavior, routing documentation, and explicit test-layout mappings.

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

Change: Feature

Suggested reviewers: ingwannu

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/4546-wpf-probe-lease-backpressure

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T13:06:17.700970Z 37a89f0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the enhancement New feature or request label Sep 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37a89f0470

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread structure/catalog.md
Comment on lines +233 to +239
- **A transient hold is probed half-open, never opened all at once.** While a bound account is
held for a 5xx streak, one in-flight probe may test it and every other request keeps the
remembered detour; the lease carries a deadline and a generation so a late answer from a
probe that already lost cannot overwrite a newer binding or failure state. When every
candidate is held the caller gets a typed withheld outcome, not a send. Recovery dispatches
(retries and probes, never a new request's initial send) sit under a pool-wide ratio ceiling
measured over a sliding window (`src/routing/probe-lease.ts`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Connect the half-open path before documenting it as active

A repo-wide search at this commit finds no production import of src/routing/probe-lease.ts; src/codex/routing.ts still returns either the detour or the failing bound account, and no send path records or limits recovery dispatches. Consequently none of the probe, withheld, or pool-wide ceiling behavior described here can execute. Remove this present-tense contract until the routing/send integration lands, or wire the module into those paths in this change.

AGENTS.md reference: structure/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

kind: "withheld",
boundAccountId: input.boundAccountId,
...(input.detourAccountId !== undefined ? { detourAccountId: input.detourAccountId } : {}),
retryAt: nextProbeAt(input.boundAccountId, now, input.minProbeIntervalMs),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Delay withheld retries until backpressure can admit them

When probe eligibility succeeds but tryPermitProbeDispatch refuses because the pool allowance is exhausted, this computes retryAt solely from the account lease state. For a fresh account it returns now, even though another attempt at that time will be refused again; a caller honoring this field can therefore immediately loop while the recovery budget remains exhausted. Include the limiter's next admission time in retryAt, or otherwise return a conservative future retry time after a backpressure refusal.

Useful? React with 👍 / 👎.

Comment thread src/routing/probe-lease.ts Outdated
if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) {
return "stale";
}
if (now > lease.expiresAt) return "expired";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat equality with the lease deadline as expired

If a probe settles at exactly expiresAt, liveLease already considers the lease dead because it requires leaseExpiresAt > now, but this check still applies the outcome because it only rejects now > expiresAt. Since timestamps have millisecond precision, a completion on that exact tick can be accepted after the lease has become eligible for replacement. Use now >= lease.expiresAt so settlement and acquisition agree on the deadline boundary.

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 68 / 80

설명

이 PR(#4626, codex/4546-wpf-probe-lease-backpressure)은 #4546 남은 스택의 3층(wpf) 이다. base는 wpe(#4625)이고, 그 아래 wpc(#4624)다. 현재 dev tip 4f788f916에는 #4616이 이미 들어와 transient hold 만료 시 healthy detour를 승격하고, retryAfterIsLowerBound opt-in으로 Retry-After를 로컬 max보다 짧게 자르지 않게 했다. 그런데 soft-avoided(held) 계정은 트래픽을 못 받아서 ‘나았는지’를 확인할 방법이 없었고, 요청별 send budget만으로는 수천 요청이 각자 한도 안에서 재시도 폭풍을 만들 수 있었다.

새 파일 src/routing/probe-lease.ts 가 그 두 구멍을 막는다. (1) held 계정당 동시에 하나의 probe lease만 나가게 하고, generation fence로 stale 결과는 바인딩/실패 상태를 덮어쓰지 못한다. 성공·실패·만료·release·invalidate가 모두 정의돼 있다. (2) resolveHeldAccountDispatch 는 probe / detour / withheld 세 갈래로, detour identity를 probe 때문에 버리지 않는다. 후보가 전부 held면 보내지 않고 retryAt만 준다. (3) createPoolBackpressureLimiter 는 슬라이딩 윈도에서 초기 send 대비 recovery(재시도+probe) 비율을 제한하고, 조용한 풀이 영원히 못 깨지게 minRecoveryAllowance 바닥을 둔다.

두 번째 축은 src/lib/upstream-retry.ts 다. #4616이 Retry-After를 ceiling으로 잘라 기다렸다면, 이번엔 지시를 통째로 돌려주고, 지시가 RETRY_AFTER_CEILING_MS보다 길면 일찍 보내지 않고 upstream 응답을 그대로 끝낸다. ‘짧게 잘라 다시 보내기’와 ‘길게 기다려 주차하기’ 둘 다 피한 형태다. 테스트는 probe-lease 208줄 + upstream-retry 36줄. structure/catalog.md에 계약 요약이 붙었다. CI는 gates·docker·test 4/4 pass, test 1/3·macos 일부 pending. 호출부가 src/codex/routing.ts 에 아직 안 묶였고, backpressure 정책도 OcxConfig에 안 올라왔다(주석이 wiring lane 소유라고 명시).

68점 이유: #4616의 정직한 후속이고 계약·fence·withheld가 분명하다. 다만 미배선·Retry-After 의미 변화(#4616 ceiling clamp 제거)·스택 의존(wpc→wpe→wpf) 때문에 70대 초반으로는 올리지 않았다.

라인 settleTransientProbe / probe-lease.ts - leaseId·generation 불일치면 stale, 시간 초과면 expired. applied일 때만 lease를 비운다. expired 경로에서 lease 슬롯을 누가 치우는지는 호출부/만료 재획득 경로에 달려 있어, 배선 시 ‘expired 후 liveLease가 남아 interval만 막는’ 장면을 테스트로 고정하는 편이 좋다.
경로 tryAcquireTransientProbe - 획득 시 lastProbeAt을 즉시 찍는다. 간격 제한은 맞지만, acquire 직후 release(미전송)도 간격을 소비한다. 의도적일 수 있으나 호출부가 빈번히 취소하면 probe가 sparingly 나가지 못할 수 있다.
경로 resolveHeldAccountDispatch - backpressure tryPermitProbeDispatch 후에 tryAcquire한다. permit만 쓰고 acquire가 null이면 그 recovery 슬롯이 소비된다. 비율 한도가 있는 풀에서 probe 기회를 헛소비할 수 있다 — acquire 가능 여부를 permit 전에 더 강하게 묶을지 판단 필요.
경로 retryBackoffDelayMs - ceiling clamp 제거로 #4616 때와 숫자 의미가 바뀐다. fetchWithTransientRetry만 ‘너무 긴 Retry-After면 재시도 안 함’. 다른 호출자가 delay를 그대로 sleep에 넣으면 주차 위험이 다시 생긴다. opt-in 호출부 목록을 후속에서 점검해야 한다.
심볼 sharedPoolBackpressure / configureSharedPoolBackpressure - 프로세스 전역. 설정 표면 없음. 기본 0.2 ratio / 3 floor / 10s window가 운영에 맞는지 wiring PR에서 노브화할지 결정.
경로 tests/routing/probe-lease.test.ts - lease·stale·withheld·backpressure 핵심은 커버. routing.ts 통합은 이 PR에 없다(스택 한계와 일치).
경로 base codex/4546-wpe-durable-reservation - wpc·wpe 없이 dev 단독 머지 불가. GitHub CodeRabbit도 base branch라 review skipped.

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

너의 추천
스택 순서(#4624#4625#4626)를 지키되, wpf는 CI green 후 계약 착륙 후보. 머지와 별도로 바로 이은 배선 PR에서 (1) resolveHeldAccountDispatch를 held 경로에 연결, (2) expired/stale settle이 affinity를 덮지 않음을 routing 테스트로 고정, (3) Retry-After 긴 헤더 종료 동작을 transient 호출부 목록과 대조한다. types/config 분할·중복 close 대상 아님. #4624/#4625와 함께 열차로만 추적한다.

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

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Requesting changes on exact head 37a89f047043c13767043a925e1157ddda32b507. The half-open lease and full Retry-After direction are sound, but two deterministic timing contracts need correction before the routing layer consumes them.

  1. A probe refused only by pool backpressure can return kind:"withheld", retryAt: now. With a fresh account state, nextProbeAt() knows nothing about the limiter and returns the current timestamp. A caller honoring that value can immediately retry, repeatedly hit the limiter and form a local busy loop. The limiter needs to expose (or the resolver must conservatively compute) the next meaningful permit time, and the zero-allowance/no-detour test should assert retryAt > now and eventual progress after the window.

  2. Lease expiry uses inconsistent boundaries. liveLease() considers the lease dead at now === leaseExpiresAt, but settleTransientProbe() expires it only when now > lease.expiresAt. At the exact deadline, the old result can be reported as applied even though acquisition already considers that lease non-live. Use one predicate (now >= expiresAt) and pin the equality case.

Please also remove or explicitly relocate the now-unused RetryBackoffOptions.retryAfterCeilingMs contract; retryBackoffDelayMs() no longer consults it, while the actual one-minute decision is hard-coded in fetchWithTransientRetry. A caller-visible option that appears to set a deadline but has no effect will create the next mismatch.

Exact-head hosted CI is green, but no current test exercises the two contradicted edges. This PR is also stacked on #4625/#4624, both currently blocked by requested changes.

@lidge-jun
lidge-jun force-pushed the codex/4546-wpe-durable-reservation branch from ef3eb73 to 56cd54a Compare September 14, 2026 14:15
@lidge-jun
lidge-jun force-pushed the codex/4546-wpf-probe-lease-backpressure branch from 37a89f0 to ba04d72 Compare September 14, 2026 14:15
@lidge-jun
lidge-jun force-pushed the codex/4546-wpe-durable-reservation branch from 56cd54a to 1d5d299 Compare September 14, 2026 14:55
Base automatically changed from codex/4546-wpe-durable-reservation to dev September 14, 2026 14:55
… Retry-After in full (#4546)

Part of the stacked delivery closing the remaining OCX-4546 cost-guard scope.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. Pushed with --no-verify.
…e state (#4546)

Review findings on the probe-lease layer: fetchWithTransientRetry ignored the documented retryAfterCeilingMs, probeStates retained every account ever probed, and the lease expiry boundary disagreed between liveLease and settleTransientProbe.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof.
@lidge-jun
lidge-jun force-pushed the codex/4546-wpf-probe-lease-backpressure branch from ba04d72 to 4b0e415 Compare September 14, 2026 14:55
@lidge-jun
lidge-jun merged commit 627274b into dev Sep 14, 2026
4 of 19 checks passed
@lidge-jun
lidge-jun deleted the codex/4546-wpf-probe-lease-backpressure branch September 14, 2026 14:56
oliver-mee pushed a commit to oliver-mee/opencodex that referenced this pull request Sep 16, 2026
…later (lidge-jun#4546) [skip ci]

The transient-hold resolver and the pool-wide recovery limiter added in lidge-jun#4626
still have no production caller, so lidge-jun#4701 is not closed here. Wiring them turned
up a defect in the thing being wired, and that has to be fixed first.

A withheld dispatch promises the caller a retry time. It was computed from the
probe pacing alone. When the RATIO limiter is what refused, the account usually
has no probe state at all -- nothing was ever granted for it -- so nextProbeAt
returned now, and the refusal told the caller to try again immediately. A
withheld dispatch that busy-loops puts the same load on an already-failing pool
as the dispatch it refused, which is the opposite of what the limiter is for.
It also violates the Retry-After half of lidge-jun#4701's completion criteria directly.

The limiter is the only thing that knows when its own window moves, so it now
says: nextRecoveryAt returns now while the allowance is unspent, and otherwise
the moment the oldest bucket still inside the window falls out. Every such
bucket started after now - windowMs, so the answer is always strictly in the
future, and it is a real change point rather than a guessed delay. The withheld
result takes the later of that and the probe pacing.

The existing zero-allowance test asserted only that the result was withheld,
which is why the defect survived the unit suite that was written to cover this
module. It now asserts the time as well.

Refs lidge-jun#4701
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants