Skip to content

fix: wait for cooldown expiry instead of failing when all profiles are rate-limited#24157

Closed
YoungMoneyInvestments wants to merge 3 commits into
openclaw:mainfrom
YoungMoneyInvestments:fix/wait-for-cooldown-before-failing
Closed

fix: wait for cooldown expiry instead of failing when all profiles are rate-limited#24157
YoungMoneyInvestments wants to merge 3 commits into
openclaw:mainfrom
YoungMoneyInvestments:fix/wait-for-cooldown-before-failing

Conversation

@YoungMoneyInvestments

@YoungMoneyInvestments YoungMoneyInvestments commented Feb 23, 2026

Copy link
Copy Markdown

Summary

  • When all auth profiles for every model candidate are in cooldown, runWithModelFallback now waits for the soonest cooldown to expire (up to 90 seconds) and retries once, rather than immediately throwing "All models failed"
  • This prevents transient rate limits from surfacing as hard user-facing errors (e.g. in Discord/Telegram) when the recovery window is short
  • If the wait would exceed 90s or the retry also fails, the original error is thrown as before — no change in behavior for long cooldowns

Fixes #24158

Motivation

When using Claude Max subscription tokens via OAuth, hitting rate limits causes all auth profiles to enter cooldown simultaneously. With the current code, this immediately fails the request even if the cooldown is only seconds away from expiring. This is especially disruptive for messaging channel users (Discord, Telegram) who see "All models failed" errors for what is a brief, transient rate limit.

Changes

  • src/agents/model-fallback.ts: After exhausting all candidates, detect if all failures are cooldown-related. If the soonest cooldown expires within 90s, sleep until it expires, clear expired cooldowns, and retry once.
  • src/agents/model-fallback.probe.test.ts: Added clearExpiredCooldowns to the auth-profiles mock. Adjusted the "single candidate exhausts candidates" test to use a cooldown beyond the 90s wait threshold so it continues to test the immediate-failure path.

Test plan

  • All 39 existing model-fallback tests pass
  • All 6 fallback-state tests pass
  • TypeScript type-check passes (pnpm tsgo)
  • Manual test: trigger rate limit with a single auth profile and verify the agent waits and recovers instead of erroring

🤖 Generated with Claude Code

Greptile Summary

This PR adds a cooldown-wait-retry mechanism to runWithModelFallback so that when all model candidates are rate-limited, the function waits for the soonest cooldown to expire (up to 90s) and retries once, instead of immediately failing. This is a targeted improvement for Claude Max / OAuth users who hit rate limits across all profiles simultaneously.

  • New retry logic: After exhausting all candidates, if all failures are cooldown-related and the soonest expiry is within 90s, the function sleeps until expiry + 500ms buffer, clears expired cooldowns, and attempts a single retry pass
  • Test adjustment: The "single candidate exhausts candidates" test cooldown was moved from 30s to 120s to avoid triggering the new wait path
  • Missing test coverage: The new wait-and-retry path has no dedicated test exercising it. Existing tests were adjusted to avoid it, but a happy-path test (with fake timers) demonstrating successful recovery after wait would strengthen confidence
  • Latent test slowdown: The "does NOT probe non-primary candidates during cooldown" test uses a 30s cooldown that falls within the 90s threshold, meaning the wait-and-retry path triggers with real setTimeout, causing the test to block for ~30 real seconds

Confidence Score: 3/5

  • The core logic is correct but a latent test performance issue needs fixing before merge
  • The production code logic is sound and the 90s cap + single retry approach is reasonable. However, the "does NOT probe non-primary candidates during cooldown" test will silently regress to ~30s wall-clock time due to a real setTimeout being triggered by the new code path. This test needs the same fix applied to the other test (bumping cooldown to >90s) or needs fake timers. Additionally, no test exercises the new wait-and-retry happy path.
  • src/agents/model-fallback.probe.test.ts needs the "does NOT probe non-primary candidates" test's cooldown adjusted to >90s or fake timers added

Last reviewed commit: 0f58ff4

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +453 to +476
for (const candidate of candidates) {
const profileIds = resolveAuthProfileOrder({
cfg: params.cfg,
store: authStore,
provider: candidate.provider,
});
if (!profileIds.some((id) => !isProfileInCooldown(authStore, id))) {
continue;
}
try {
return {
result: await params.run(candidate.provider, candidate.model),
provider: candidate.provider,
model: candidate.model,
attempts,
};
} catch (retryErr) {
if (shouldRethrowAbort(retryErr)) {
throw retryErr;
}
lastError = retryErr;
break;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Retry loop breaks on first error, skipping remaining candidates

The retry for loop iterates over all candidates but breaks on the first failure (line 474). This means if the first available candidate fails on retry, other candidates that may have recovered from cooldown are never tried. This is arguably intentional ("retry once"), but the loop structure suggests a full pass over candidates. If only one retry attempt is intended, consider adding a comment explaining this is by design, or replacing the loop with a targeted single-candidate retry to make the intent clearer.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 453-476

Comment:
**Retry loop breaks on first error, skipping remaining candidates**

The retry `for` loop iterates over all candidates but `break`s on the first failure (line 474). This means if the first available candidate fails on retry, other candidates that may have recovered from cooldown are never tried. This is arguably intentional ("retry once"), but the loop structure suggests a full pass over candidates. If only one retry attempt is intended, consider adding a comment explaining this is by design, or replacing the loop with a targeted single-candidate retry to make the intent clearer.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps

greptile-apps Bot commented Feb 23, 2026

Copy link
Copy Markdown
Contributor
Additional Comments (1)

src/agents/model-fallback.probe.test.ts
Test will block for ~30s on real timers

The "does NOT probe non-primary candidates during cooldown" test sets almostExpired = NOW + 30 * 1000 (30s), which is under the new 90s MAX_COOLDOWN_WAIT_MS threshold. After the primary probe fails with 429 and the other candidates are skipped as cooldown, allCooldownSkips evaluates to true and the wait-and-retry path triggers a real setTimeout(resolve, 30500). Since the test doesn't use vi.useFakeTimers(), this will block for ~30 real seconds before the retry loop skips all candidates (because isProfileInCooldown still returns true) and finally throws.

The fix applied to the "single candidate exhausts candidates" test (changing to NOW + 120 * 1000) should also be applied here, or the test should use fake timers to advance past the wait.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.probe.test.ts
Line: 183-184

Comment:
**Test will block for ~30s on real timers**

The "does NOT probe non-primary candidates during cooldown" test sets `almostExpired = NOW + 30 * 1000` (30s), which is under the new 90s `MAX_COOLDOWN_WAIT_MS` threshold. After the primary probe fails with 429 and the other candidates are skipped as cooldown, `allCooldownSkips` evaluates to `true` and the wait-and-retry path triggers a real `setTimeout(resolve, 30500)`. Since the test doesn't use `vi.useFakeTimers()`, this will block for ~30 real seconds before the retry loop skips all candidates (because `isProfileInCooldown` still returns `true`) and finally throws.

The fix applied to the "single candidate exhausts candidates" test (changing to `NOW + 120 * 1000`) should also be applied here, or the test should use fake timers to advance past the wait.

How can I resolve this? If you propose a fix, please make it concise.

@HenryLoenwind

Copy link
Copy Markdown
Contributor

I agree with greptile here. But in general, I do like the idea. We have no "retry" button, so user-facing aborts are always annoying. 90 seconds can feel like an eternity, though. Maybe make this configurable?

@YoungMoneyInvestments

Copy link
Copy Markdown
Author

Thanks for the reviews @greptile-apps and @HenryLoenwind!

Addressed both points in 8bb0591:

  1. Fixed second test — both almostExpired values in the probe tests now use NOW + 120 * 1000 (well beyond the 90s wait window), so they skip cleanly without blocking on real timers.

  2. Made wait configurable — added auth.cooldowns.rateLimitWaitSeconds (default: 90, set to 0 to disable). Includes type definition, zod schema validation, help text, and config label.

@greptile-apps

greptile-apps Bot commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Looking at your updates in 8bb0591, both changes look good! The test fix and configurable wait are implemented correctly. A few observations:

Test fix ✅
Both probe tests now use 120s cooldowns, ensuring they skip the wait path and remain fast.

Config implementation ✅
The rateLimitWaitSeconds config is properly wired through:

  • Type definition in ConfigSchema
  • Zod validation with default of 90
  • Used correctly in the wait condition: soonestExpiryMs <= waitSeconds * 1000
  • UI label and help text are clear

One minor suggestion:

In src/agents/model-fallback.ts around line 147-148, the log message could be slightly clearer about the wait time:

      log.info(
        `All candidates in cooldown; waiting ${Math.ceil(soonestExpiryMs / 1000)}s (up to ${waitSeconds}s configured) for soonest expiry and will retry once`,
      );

This makes it immediately obvious how long the actual wait will be vs the configured max.

Otherwise, the implementation is solid and ready. The config gives users an escape hatch (set to 0 to disable) while keeping a sensible default that prevents most transient rate-limit errors from surfacing.

@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Mar 1, 2026
@YoungMoneyInvestments

Copy link
Copy Markdown
Author

Still relevant and working as intended — this fix prevents transient rate limit windows from surfacing as hard errors when a short wait would resolve them. The 90s configurable ceiling keeps it from hanging indefinitely. Happy to address any remaining review feedback or rebase if needed.

@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Mar 3, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added stale Marked as stale due to inactivity and removed stale Marked as stale due to inactivity labels Mar 22, 2026
YoungMoneyInvestments and others added 2 commits March 28, 2026 19:23
…e rate-limited

When all auth profiles for every model candidate are in cooldown,
runWithModelFallback now waits for the soonest cooldown to expire
(up to 90 seconds) and retries once, rather than immediately throwing
"All models failed".

This prevents transient rate limits from surfacing as hard user-facing
errors when the recovery window is short. If the wait would exceed 90s
or the retry also fails, the original error is thrown as before.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Address PR review feedback:
- Add auth.cooldowns.rateLimitWaitSeconds config option (default: 90, 0 to disable)
- Fix second test that had 30s cooldown within the 90s wait window (now 120s)
- Add type, zod schema, help text, and label for the new config option

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@YoungMoneyInvestments
YoungMoneyInvestments force-pushed the fix/wait-for-cooldown-before-failing branch from 8bb0591 to a380831 Compare March 29, 2026 00:25
@YoungMoneyInvestments

Copy link
Copy Markdown
Author

Rebased onto current main — conflicts resolved and all prior review feedback is still addressed.

Quick recap of what this PR does:

  • When all model candidates are in cooldown, instead of immediately failing with "All models failed", we wait for the soonest cooldown to expire (up to a configurable limit) and retry once.
  • Default wait ceiling is 90s, configurable via auth.cooldowns.rateLimitWaitSeconds (set to 0 to disable).
  • Tests updated so cooldown values exceed the threshold, avoiding real-timer blocking.

This prevents transient rate limits from surfacing as hard user-facing errors when a short wait would resolve them. Ready for another look!

The previous implementation capped the cooldown wait at 90 seconds,
which meant that longer but still reasonable rate-limit windows would
fail immediately even though the system knew exactly when the cooldown
expires.

Now the default behavior waits for the actual cooldown expiry with no
cap, matching how Claude Code handles rate limits. Users can still set
auth.cooldowns.rateLimitWaitSeconds to a custom cap or 0 to disable.

Adds user-visible log.warn messages during the wait and on retry.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@YoungMoneyInvestments

Copy link
Copy Markdown
Author

Pushed an additional improvement in 26d3693:

Changed the default from a 90s cap to waiting for the actual cooldown expiry. The system already tracks exact reset timestamps for every profile, so there's no reason to impose an arbitrary ceiling — if the cooldown resets in 5 minutes, we should wait 5 minutes instead of throwing an error the user can't recover from.

This matches how Claude Code handles rate limits: it knows when the limit resets and just waits.

The config still gives users full control:

  • rateLimitWaitSeconds: 0 — disable waiting entirely (fail immediately)
  • rateLimitWaitSeconds: 300 — cap at 5 minutes
  • Omitted (default) — wait for the actual expiry, however long

Also added log.warn messages so the user sees what's happening:

  • All providers are rate-limited. Waiting 60s for cooldown to expire before retrying…
  • Cooldown expired — retrying request.

All 20 probe tests pass.

@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 3, 2026
@openclaw-barnacle

Copy link
Copy Markdown

Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.

@openclaw-barnacle openclaw-barnacle Bot closed this Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate-limited profiles cause immediate failure instead of waiting for cooldown expiry

2 participants