Retry-After capped at 60s causes OAuth usage-limit 429s to hard-fail instead of waiting the real reset time
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 19.9k
- Forks
- 2.3k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 30
Description
Summary
Retry-After from the Anthropic API is silently capped at 60 seconds, even for genuine
usage-limit 429s on OAuth/subscription accounts. Combined with a 3-attempt retry budget,
this means jcode gives up on rate-limited turns after ~2 minutes and hard-fails them,
even when Anthropic's own header says the real reset time is much further out. Manually
cancelling and retrying later only "fixes" it because more real time has passed than
jcode's own retry loop was willing to wait.
Checked against: f11adb59 (master)
Root cause
crates/jcode-provider-core/src/retry_after.rs
pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
This cap applies to every parsed Retry-After header, with no exception for
confirmed rate-limit / usage-limit responses.
crates/jcode-provider-anthropic-runtime/src/lib.rs (~line 2087)
if !response.status().is_success() {
let status = response.status();
let retry_after = jcode_provider_core::retry_after::retry_after(response.headers());
let error_text = jcode_base::util::http_error_body(response, "HTTP error").await;
return Err(jcode_provider_core::retry_after::error_with_retry_after(
format!("Anthropic API error ({}): {}", status, error_text),
retry_after,
));
}
The parsed (and already-capped) hint feeds straight into the retry loop's delay
calculation (~line 1682).
is_retryable_error() (same file, ~line 2168) buckets rate-limit errors together
with transient 5xx/overload errors:
fn is_retryable_error(error_str: &str) -> bool {
jcode_provider_core::is_transient_transport_error(error_str)
|| error_str.contains("500 internal server error")
|| error_str.contains("502 bad gateway")
|| error_str.contains("503 service unavailable")
|| error_str.contains("504 gateway timeout")
|| error_str.contains("overloaded")
|| error_str.contains("429 too many requests")
|| error_str.contains("rate limit")
|| error_str.contains("rate_limit")
|| error_str.contains("api_error")
|| error_str.contains("internal server error")
}
And MAX_RETRIES = 3 (~line 449) means the whole retry budget is exhausted in roughly
two 60s-capped backoff waits.
Failure sequence
- Turn hits a real usage-limit 429 (5-hour or weekly OAuth quota). Anthropic's actual
Retry-Afterfor this can legitimately be tens of minutes. - jcode waits at most 60s (capped), retries, gets 429 again, waits ~60s again, retries,
gets 429 a third time. - After ~2 minutes total, retries are exhausted and the turn hard-fails with an opaque
Anthropic API error (429): ...message — no indication of how long the real wait is. - User cancels, waits some real amount of time, retries manually — succeeds, because
more wall-clock time passed than jcode's own loop allowed for. Looks like cancelling
fixed it; it didn't.
What I ruled out
Before landing on this, I checked whether jcode was somehow opening a new "conversation"
with Anthropic on every turn (breaking rate-limit/cache continuity). It isn't:
oauth_session_id(AnthropicProvider) is generated once innew()and cached in
MultiProvider.anthropicfor the process lifetime, not regenerated per turn.- The system prompt is deliberately split into a cached
static_partand an uncached
dynamic_part(date/git/memory), so per-turn dynamic content doesn't break the
Anthropic prompt-cache prefix.
Suggested fix
- Don't apply
MAX_RETRY_AFTERto confirmed rate-limit/usage-limit responses — only to
the generic transient-error fallback path where an untrusted/malformed value is the
actual risk. - Surface the real wait time to the user (e.g. "rate limited, resets in ~34m") instead
of silently retrying and failing. - Consider not lumping
rate_limit/429 in with transient 5xx errors in
is_retryable_error()— a usage-limit exhaustion isn't a transient blip and shouldn't
get the same short backoff treatment.
Repro
Trigger a usage-limit 429 on an OAuth/subscription account (e.g. exhaust the 5-hour
window). Turn fails within ~2 minutes with no meaningful backoff. Retry manually a few
minutes later — succeeds.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in crates/jcode-provider-core/src/retry_after.rs and trace its use from crates/jcode-provider-anthropic-runtime/src/lib.rs, including the non-success response path, retry delay calculation, and is_retryable_error(). Confirm how 429 Retry-After values reach the retry loop, then define behavior for genuine usage-limit responses so the real wait is preserved and the user receives a meaningful reset indication.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- api, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100