Skip to content

Expire exhaustion marks at the upstream's own reset time#38

Merged
lawrencecchen merged 5 commits into
mainfrom
fix-exhausted-marks-expire-at-reset
Jul 2, 2026
Merged

Expire exhaustion marks at the upstream's own reset time#38
lawrencecchen merged 5 commits into
mainfrom
fix-exhausted-marks-expire-at-reset

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Caught live (the core failed-reroute condition)

During tonight's capacity crunch, aziz@manaflow.ai's weekly window reset (57% quota available, 88% session headroom) — yet failing sessions burned all 6 failover attempts on genuinely-cooked accounts and never tried it. Real users got 429s while real quota sat unroutable.

Root cause: markAccountExhausted zero-scores an account permanently — the mark only clears on a successful usage refresh. Under load the usage endpoint rate-limits and the refresh falls back to stale "known-good" data, so a recovered account stays buried indefinitely, ranked below stale-scored cooked accounts.

Fix

Exhaustion marks now expire at the moment the account actually recovers:

  • MarkExhaustedUntil(provider, id, until) records an expiry per mark; Get() lazily prunes lapsed marks back to the optimistic default, so routing retries the account and request-time truth decides.
  • The expiry comes from the rejected response itself: anthropic-ratelimit-unified-reset (authoritative reset epoch) → Retry-After → 10-minute default; clamped to [now+1m, now+8d].
  • A full usage refresh (Set/FinishRefresh) clears pending expiries so a prune can never delete refreshed scores.
  • Codex paths keep the default TTL (no unified headers), replacing the previous permanent mark — strictly better for the same reason.

Tests

  • Expired marks lapse, unexpired hold; refresh supersedes marks (no clobber).
  • Expiry parsing: unified-reset honored, Retry-After fallback, default, past-reset floor (clock skew), far-future cap.
  • Live regression E2E: failover reaches a recovered account after its mark lapses.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Expire exhaustion marks at the upstream’s reset so recovered accounts get retried and failover reaches real quota. Preserve expiries through partial refreshes, extend them when a fresh fetch re-confirms exhaustion, and use longer TTLs for dead credentials.

  • Bug Fixes
    • Added MarkExhaustedUntil; proxy now sets expiry via claudeExhaustionExpiry using anthropic-ratelimit-unified-resetRetry-After → 10m default, clamped to [now+1m, now+8d] (codex falls back to the default).
    • Tracked Score.Fresh to reconcile refreshes: headroom clears the mark; a carried zero keeps its expiry; a fresh zero re-anchors/extends it to at least the window reset (floored at the default TTL, capped at 8d).
    • Scheduler prunes expired marks on Get() to restore optimistic scores; a lapsed mark allows one probe and a reject re-marks it with the new reset; body-inspect re-mark uses headers so it doesn’t shorten a header-derived expiry.
    • Failure-class TTLs: 401/terminal credential errors use ~1h; transient refresh failures keep the 10m default.

Written for commit 7b619e0. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Account exhaustion marks now expire automatically based on upstream recovery timing, reducing unnecessary failover delays.
    • Improved Claude account routing so recovered accounts can be selected again once their exhaustion window ends.
    • Expired routing marks are now cleared during refreshes, helping keep selection scores up to date.
  • Tests

    • Added coverage for exhaustion timing, retry handling, and failover behavior after recovery.

Caught live during a capacity crunch: aziz@manaflow.ai's weekly window had reset
(57% quota available) but its scheduler score stayed zeroed, because a
markAccountExhausted mark only cleared on a SUCCESSFUL usage refresh and the
loaded usage endpoint kept falling back to stale data. Failover then burned its
6 attempts on genuinely-cooked accounts ranked above the recovered one, and real
users got 429s while real quota sat unroutable — the exact failed-reroute
condition this service exists to prevent.

MarkExhaustedUntil records an expiry per mark; Get() lazily prunes lapsed marks
back to the optimistic default so routing retries the account and request-time
truth decides. The expiry comes from the rejected response itself:
anthropic-ratelimit-unified-reset (authoritative window reset epoch), else
Retry-After, else a 10-minute default; clamped to [now+1m, now+8d]. A full
usage refresh (Set/FinishRefresh) clears pending expiries so a prune can never
delete refreshed scores.

Tests: expired marks lapse while unexpired hold; refresh supersedes marks;
expiry parsing (unified-reset, Retry-After, default, floor, cap); and the live
regression end-to-end — failover reaches a recovered account after its mark
lapses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Claude account exhaustion marking now uses upstream-reported reset times instead of a fixed default TTL. SchedulerRef adds a time-bounded exhaustedUntil map with pruning logic, and proxy.go computes expiry from response headers (anthropic-ratelimit-unified-reset or Retry-After), clamped between now+1m and now+8d, applying it at both exhaustion-marking call sites. Tests cover expiry computation and failover recovery.

Changes

Time-bounded Claude exhaustion marking

Layer / File(s) Summary
SchedulerRef exhaustion tracking
internal/selectacct/scheduler_ref.go, internal/selectacct/scheduler_ref_test.go
Adds exhaustedUntil map to SchedulerRef; Get() prunes expired marks; Set() and FinishRefresh(update=true) clear stale marks; MarkExhausted() delegates to new MarkExhaustedUntil() with DefaultExhaustedTTL; new tests verify lapsing and refresh-clearing behavior.
Proxy exhaustion expiry and call sites
internal/proxy/proxy.go, internal/proxy/claude_ratelimit_routing_test.go
Adds claudeExhaustionExpiry to derive expiry from Claude rate-limit headers with clamping, and markAccountExhaustedFromResponse to apply it; updates captureResponseBody and usageLimitRetryTransport.RoundTrip to use the new helper; new tests cover expiry computation and failover recovery of a previously exhausted account.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClaudeAPI
  participant Proxy
  participant SchedulerRef
  participant Failover

  ClaudeAPI-->>Proxy: response with rate-limit headers
  Proxy->>Proxy: claudeExhaustionExpiry(headers)
  Proxy->>SchedulerRef: markAccountExhaustedFromResponse(expiry)
  SchedulerRef->>SchedulerRef: MarkExhaustedUntil(account, expiry)
  Note over SchedulerRef: time passes, expiry lapses
  Failover->>SchedulerRef: Get()
  SchedulerRef->>SchedulerRef: pruneExpired(now)
  SchedulerRef-->>Failover: account available again
  Failover->>ClaudeAPI: retry request to recovered account
Loading

Possibly related PRs

  • manaflow-ai/subrouter#5: Both PRs modify Claude exhaustion handling in internal/proxy/proxy.go and internal/selectacct/scheduler_ref.go for failover/retry behavior.
  • manaflow-ai/subrouter#14: Both PRs modify exhaustion marking logic in scheduler_ref.go, one adding time-bounding, the other adding provider scoping.
  • manaflow-ai/subrouter#19: Both PRs address stale/expired exhaustion state affecting routing decisions in the proxy/scheduler path.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: making exhaustion marks expire based on the upstream reset time.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-exhausted-marks-expire-at-reset

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.

Addresses autoreview P1: refreshes seed from the current scheduler and carry an
account's zero score forward when its own usage fetch fails, so blanket-clearing
exhaustedUntil on every refresh made request-time marks permanent again in the
common mixed-refresh case. Now an expiry is dropped only when the incoming score
actually supersedes the mark (shows headroom); a carried-forward zero score
keeps its expiry. Keeping an expiry for a genuinely-cooked account is safe: on
lapse routing tries it once and the upstream reject re-marks it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/proxy/claude_ratelimit_routing_test.go (1)

926-954: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add edge cases for fallback parsing.

Please extend this table with malformed anthropic-ratelimit-unified-reset + valid Retry-After, and HTTP-date Retry-After, so the expiry parser’s fallback contract stays covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/proxy/claude_ratelimit_routing_test.go` around lines 926 - 954,
Extend TestClaudeExhaustionExpiry to cover the fallback behavior in
claudeExhaustionExpiry: add a case where Anthropic-Ratelimit-Unified-Reset is
malformed but Retry-After is valid, and verify the parser falls back to
Retry-After; also add a case where Retry-After is an HTTP-date and confirm it is
parsed correctly. Keep the coverage centered on claudeExhaustionExpiry’s header
precedence and fallback parsing contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/proxy/proxy.go`:
- Around line 1688-1692: `markAccountExhaustedFromResponse` currently applies
`claudeExhaustionExpiry` for every provider because it ignores `provider`, which
can make the Codex retry path honor `Retry-After` instead of the default TTL.
Update the helper in `Server.markAccountExhaustedFromResponse` to branch on the
`accounts.Provider` value and only derive the expiry from Claude response
headers for Claude; for all other providers, keep using the scheduler’s default
exhaustion TTL. Make sure the caller path around the Codex retry logic continues
to route through this helper so the provider-specific behavior is centralized.
- Around line 1703-1710: The fallback logic in the header parsing block should
use Retry-After whenever anthropic-ratelimit-unified-reset is missing or
invalid, not just when it is absent. Update the parsing around claudeHeaderGet,
strconv.ParseInt, and strconv.Atoi so that a malformed reset value does not
short-circuit the Retry-After path; only keep until from the reset header when
parsing succeeds, otherwise continue to the Retry-After branch.

---

Nitpick comments:
In `@internal/proxy/claude_ratelimit_routing_test.go`:
- Around line 926-954: Extend TestClaudeExhaustionExpiry to cover the fallback
behavior in claudeExhaustionExpiry: add a case where
Anthropic-Ratelimit-Unified-Reset is malformed but Retry-After is valid, and
verify the parser falls back to Retry-After; also add a case where Retry-After
is an HTTP-date and confirm it is parsed correctly. Keep the coverage centered
on claudeExhaustionExpiry’s header precedence and fallback parsing contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86e58d4c-8d8e-4bc6-9954-5051d693604e

📥 Commits

Reviewing files that changed from the base of the PR and between 0cde97f and 8df07ce.

📒 Files selected for processing (4)
  • internal/proxy/claude_ratelimit_routing_test.go
  • internal/proxy/proxy.go
  • internal/selectacct/scheduler_ref.go
  • internal/selectacct/scheduler_ref_test.go

Comment thread internal/proxy/proxy.go Outdated
Comment on lines +1688 to +1692
func (s Server) markAccountExhaustedFromResponse(provider accounts.Provider, accountID string, header http.Header) {
if s.SchedulerRef == nil {
return
}
s.SchedulerRef.MarkExhaustedUntil(provider, accountID, claudeExhaustionExpiry(header, time.Now()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep non-Claude exhaustion marks on the default TTL.

markAccountExhaustedFromResponse ignores provider, so the Codex retry path at Line 2950 can honor a Retry-After header and diverge from the stated default-TTL behavior. Branch inside the helper and only use Claude header-derived expiry for Claude.

Proposed fix
 func (s Server) markAccountExhaustedFromResponse(provider accounts.Provider, accountID string, header http.Header) {
 	if s.SchedulerRef == nil {
 		return
 	}
+	if provider != accounts.ProviderClaude {
+		s.SchedulerRef.MarkExhausted(provider, accountID)
+		return
+	}
 	s.SchedulerRef.MarkExhaustedUntil(provider, accountID, claudeExhaustionExpiry(header, time.Now()))
 }

Also applies to: 2947-2950

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/proxy/proxy.go` around lines 1688 - 1692,
`markAccountExhaustedFromResponse` currently applies `claudeExhaustionExpiry`
for every provider because it ignores `provider`, which can make the Codex retry
path honor `Retry-After` instead of the default TTL. Update the helper in
`Server.markAccountExhaustedFromResponse` to branch on the `accounts.Provider`
value and only derive the expiry from Claude response headers for Claude; for
all other providers, keep using the scheduler’s default exhaustion TTL. Make
sure the caller path around the Codex retry logic continues to route through
this helper so the provider-specific behavior is centralized.

Comment thread internal/proxy/proxy.go
Comment on lines +1703 to +1710
if raw := strings.TrimSpace(claudeHeaderGet(header, "anthropic-ratelimit-unified-reset")); raw != "" {
if epoch, err := strconv.ParseInt(raw, 10, 64); err == nil && epoch > 0 {
until = time.Unix(epoch, 0)
}
} else if ra := strings.TrimSpace(claudeHeaderGet(header, "Retry-After")); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
until = now.Add(time.Duration(secs) * time.Second)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant function and nearby uses of the headers.
FILE="internal/proxy/proxy.go"

echo "== File size =="
wc -l "$FILE"

echo
echo "== Outline =="
ast-grep outline "$FILE" --view expanded || true

echo
echo "== Relevant region =="
sed -n '1670,1735p' "$FILE" | cat -n

echo
echo "== Search for header parsing and Retry-After handling =="
rg -n 'anthropic-ratelimit-unified-reset|Retry-After|ParseTime|strconv\.Atoi|time\.Unix|DefaultExhaustedTTL' "$FILE"

Repository: manaflow-ai/subrouter

Length of output: 22700


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for Retry-After parsing across repo =="
rg -n 'Retry-After|ParseTime\(' .

echo
echo "== Relevant nearby code for overload backoff =="
sed -n '2728,2748p' internal/proxy/proxy.go | cat -n

echo
echo "== Search for tests around claudeExhaustionExpiry / overload backoff =="
rg -n 'claudeExhaustionExpiry|claudeOverloadBackoff|Retry-After' internal -g '*_test.go'

Repository: manaflow-ai/subrouter

Length of output: 4202


Fall back to Retry-After when reset parsing fails.
A malformed anthropic-ratelimit-unified-reset currently bypasses the Retry-After path; run the fallback when reset parsing doesn’t succeed, not only when the header is absent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/proxy/proxy.go` around lines 1703 - 1710, The fallback logic in the
header parsing block should use Retry-After whenever
anthropic-ratelimit-unified-reset is missing or invalid, not just when it is
absent. Update the parsing around claudeHeaderGet, strconv.ParseInt, and
strconv.Atoi so that a malformed reset value does not short-circuit the
Retry-After path; only keep until from the reset header when parsing succeeds,
otherwise continue to the Retry-After branch.

@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: 8df07ce22f

ℹ️ 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 internal/proxy/proxy.go
if s.SchedulerRef == nil {
return
}
s.SchedulerRef.MarkExhaustedUntil(provider, accountID, claudeExhaustionExpiry(header, time.Now()))

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 Avoid expiring credential failures as rate-limit windows

When Claude returns a 401 authentication_error, claudeAccountExhaustedByResponse routes it through this helper with no reset headers, so claudeExhaustionExpiry stores only DefaultExhaustedTTL; after 10 minutes SchedulerRef.Get prunes the mark and the scheduler can pick the same dead OAuth credential again. A 401 is not a quota window that will recover at reset time, so these auth failures should keep using a refresh/reauth-driven exhaustion mark rather than the new rate-limit expiry.

Useful? React with 👍 / 👎.

Autoreview round 3 flagged that a lapsed expiry lets a still-cooked account
(fresh-zero and carried-zero scores are indistinguishable without provenance)
go optimistic once. That is the intended tradeoff, consistent with the
scheduler-wide optimistic-routing philosophy: the probe's reject re-marks the
account with the new authoritative reset, bounding the cost at one attempt per
account per expiry window, while the opposite choice (trusting zero scores
without expiry) is exactly the stranded-recovered-quota failure this fixes.
Documented on pruneExpired and covered by TestLapsedMarkRemarksOnNextReject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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: 1618082579

ℹ️ 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".

const DefaultExhaustedTTL = 10 * time.Minute

func (r *SchedulerRef) MarkExhausted(provider accounts.Provider, accountID string) {
r.MarkExhaustedUntil(provider, accountID, time.Now().Add(DefaultExhaustedTTL))

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 Keep credential-failure marks from expiring

Separately from the 401 response path, MarkExhausted is still used for terminal credential failures in proxy.go's oauthRetryAccount and refreshSelectedAccount, but this change makes every such mark expire after 10 minutes. When a refresh token is permanently invalid or missing, the next SchedulerRef.Get after that TTL prunes the zero score and Pick treats the account as fully healthy again, so new requests can keep selecting a dead credential and incur refresh-failover or upstream 401s instead of staying out of rotation until reauth/account reload. The expiring path should be limited to quota-window responses with reset times, not permanent credential failures.

Useful? React with 👍 / 👎.

Addresses autoreview round 4: (1) the passive body-inspect path called the
plain default-TTL mark, which could overwrite a header-derived reset expiry set
moments earlier — it now recomputes from the same response headers. (2) Dead
credentials (401 / terminal invalid_grant refresh failures) get a 1h credential
TTL instead of the 10m rate-limit default: they only heal via human re-auth, so
frequent probes are overhead, but the mark still lapses so a re-authed account
rejoins within the hour without needing a successful usage refresh. Transient
refresh failures keep the short default. Adds ExhaustedUntilFor for verification
and TestMarkTTLSelection pinning expiry per class + the no-shortening re-mark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/proxy/claude_ratelimit_routing_test.go">

<violation number="1" location="internal/proxy/claude_ratelimit_routing_test.go:1048">
P1: The inverted comparison for the transient-refresh assertion (`blip@refresh-failure`) is reversed: it checks `time.Until(blipUntil) > 15*time.Minute` but should check `< 15*time.Minute`. Because transient marks default to ~10m remaining, the current condition causes a false fatal and breaks the test. Swap the comparison so the test validates the expected TTL bound.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

}
server.markAccountExhaustedRefreshFailure(accounts.ProviderClaude, "blip@example.com", fmt.Errorf("dial tcp: connection refused"))
blipUntil, _ := server.SchedulerRef.ExhaustedUntilFor(accounts.ProviderClaude, "blip@example.com")
if time.Until(blipUntil) > 15*time.Minute {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The inverted comparison for the transient-refresh assertion (blip@refresh-failure) is reversed: it checks time.Until(blipUntil) > 15*time.Minute but should check < 15*time.Minute. Because transient marks default to ~10m remaining, the current condition causes a false fatal and breaks the test. Swap the comparison so the test validates the expected TTL bound.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/proxy/claude_ratelimit_routing_test.go, line 1048:

<comment>The inverted comparison for the transient-refresh assertion (`blip@refresh-failure`) is reversed: it checks `time.Until(blipUntil) > 15*time.Minute` but should check `< 15*time.Minute`. Because transient marks default to ~10m remaining, the current condition causes a false fatal and breaks the test. Swap the comparison so the test validates the expected TTL bound.</comment>

<file context>
@@ -1003,3 +1004,48 @@ func TestClaudeFailoverTriesRecoveredAccount(t *testing.T) {
+	}
+	server.markAccountExhaustedRefreshFailure(accounts.ProviderClaude, "blip@example.com", fmt.Errorf("dial tcp: connection refused"))
+	blipUntil, _ := server.SchedulerRef.ExhaustedUntilFor(accounts.ProviderClaude, "blip@example.com")
+	if time.Until(blipUntil) > 15*time.Minute {
+		t.Fatalf("transient refresh mark in %v, want ~10m default", time.Until(blipUntil))
+	}
</file context>

…est evidence

Addresses autoreview round 5: fresh-confirmed exhaustion and a carried-forward
zero seed were indistinguishable, so an older request-time expiry could lapse a
freshly-observed zero back to optimistic. Score.Fresh now tags scores computed
from a successful current usage fetch (set at the single write site in
scoreAccounts; seeds explicitly carry Fresh=false). Expiry reconciliation:
superseded (headroom) drops the expiry; carried zero keeps it (the stranded-
recovered-account fix); fresh zero re-anchors it to the fetch's own window reset
(floored at the default TTL, capped 8d) — extend-only, so an authoritative long
reset from a rejected response still holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lawrencecchen lawrencecchen merged commit 93010e3 into main Jul 2, 2026
5 checks passed
lawrencecchen added a commit that referenced this pull request Jul 2, 2026
Exhaustion marks now expire at the upstream's own reset time (with per-failure-
class TTLs and fresh-evidence re-anchoring), fixing recovered accounts staying
unroutable while users got 429s (#38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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: 7b619e099b

ℹ️ 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".

if r.exhaustedUntil == nil {
r.exhaustedUntil = make(map[string]time.Time)
}
r.exhaustedUntil[ScoreKey(provider, accountID)] = until

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 Preserve later exhaustion reset times

When an account already has a later expiry from Anthropic-Ratelimit-Unified-Reset, a subsequent mark without reset headers (for example the WebSocket usageLimitJSON path calling MarkExhausted) passes now+DefaultExhaustedTTL, and this assignment replaces the later timestamp. That shortens an hourly/weekly reset window to 10 minutes, so Get() can prune the zero score early and route traffic back to an account that upstream still says is exhausted; keep the later of the existing and new expiries unless the mark is being explicitly cleared or reclassified.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant