Skip to content

fix(resilience): park Gemini PerDay quota violations until the daily reset - #16705

Merged
diegosouzapw merged 3 commits into
release/v3.8.52from
fix/port-pr-4348-gemini-daily-quota-park
Oct 15, 2026
Merged

diegosouzapw merged 3 commits into
release/v3.8.52from
fix/port-pr-4348-gemini-daily-quota-park

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Summary

  • A Gemini 429 whose google.rpc.QuotaFailure names a per-day quotaId (e.g. GenerateRequestsPerDayPerProjectPerModel-FreeTier) now locks that model until the daily reset. Before, the ~20s RetryInfo attached to the same body was the only signal used, so an exhausted free-tier key went back into rotation every ~20s until midnight Pacific.
  • Per-minute violations keep the short RetryInfo cooldown (fix(resilience): Model Lockouts ignore configured cap and upstream retry hint (freeze until midnight) #7940 behavior unchanged).
  • Only the model is locked (Gemini quotas are per project and model). Other models on the same key keep serving. The lock has a finite expiry and is never a terminal state.

Attribution

Thanks to @scottchristian for the original implementation.

Changes

  • open-sse/services/retryAfterJson.ts: googleDailyQuotaIds() / hasGoogleDailyQuotaViolation() read error.details[] with a strict google.rpc.QuotaFailure @type and match /perday/i on quotaId only, never on free text. A bounded raw-text fallback (ReDoS-safe) handles a prose message that has the detail appended.
  • open-sse/services/googleDailyQuota.ts (new):
    • googleDailyQuotaCooldownMs() uses the operator's node daily-reset clock when one is configured. Otherwise it waits until the next midnight America/Los_Angeles, Google's documented reset for Gemini's requests-per-day quota. The result is capped at 25h.
    • appendGoogleDailyQuotaViolation() adds a compact QuotaFailure line, holding the PerDay quota ids only, to the classification text.
  • open-sse/services/accountFallback.ts:
    • checkFallbackError returns quota_exhausted + dailyQuotaExhausted + quotaResetHintMs for these 429s. The check runs before the Gemini metric and credits checks and is keyed on the body shape, so it covers gemini, vertex and AI-Studio-style connections. For per-model providers, markAccountUnavailable turns quotaResetHintMs into a model lockout.
    • parseRetryFromErrorText returns the same daily window instead of 26s.
  • open-sse/handlers/chatCore/providerExecutionPipeline.ts (non-streaming) and open-sse/handlers/chatCore/streamingResponse.ts (streaming): classification only ever saw error.message. For Gemini that message reads the same for per-minute and per-day exhaustion. Both paths now carry the compact QuotaFailure line in the classification text. Client-facing error bodies keep only the first line, so their content does not change. I checked this with createErrorResult.
  • Design choices:
    • The model lockout goes through the existing markAccountUnavailable path, so the operator's model-lockout maxCooldownMs (30 min by default) still bounds it. That is the same policy the existing daily-quota (isDailyQuotaExhausted) lockouts follow. The connection-level cooldownMs / quotaResetHintMs carry the full daily window.
    • No random multi-hour park for 429s that carry no hint: exponential backoff stays the house policy.
    • rateLimitManager is left as is, because a multi-hour reservoir would queue requests instead of failing over.

Test plan

  • node --import tsx/esm --test tests/unit/gemini-daily-quota-violation.test.ts (new, 11 tests): before the wiring, 4 behavior tests failed (parseRetryFromErrorText daily window, checkFallbackError daily park, prose+append path, model-only lockout via markAccountUnavailable). The streaming source-wiring assertion and the new pipeline test also failed with the call sites reverted. After the change: ℹ tests 11 ℹ pass 11 ℹ fail 0.
  • Focused suites: gemini-daily-quota-violation, provider-execution-pipeline, bug-7940-gemini-retrydelay, gemini-429-retry-hint-skips-intra-retry, account-fallback-service, account-fallback-retry-after-json → ℹ tests 153 ℹ pass 153 ℹ fail 0.
  • All 187 test files that import the touched modules (accountFallback|retryAfterJson|providerExecutionPipeline|streamingResponse|parseRetryFromErrorText|checkFallbackError|dailyQuotaReset) → ℹ tests 1865 ℹ pass 1861 ℹ fail 4. Two of the 4 pass in isolation (load-sensitive). The other two (copilot-learned-catalog-15634 "a listed, supported Copilot model still reaches the HTTP upstream" and provider-request-failure-pipeline "network failure persisted call log…") fail identically with this PR's source changes reverted, so they come from the base.
  • npm run typecheck:core → exit 0.
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> → clean; npx prettier --check <changed files> → clean.
  • node scripts/check/check-pr-test-policy.mjs (PR mode) → PASS; GITHUB_BASE_REF=release/v3.8.52 npm run check:test-masking → OK (no weakening).
  • npm run check:file-size → no violation in touched files. accountFallback.ts is at 2487 against its 2504 ceiling and streamingResponse.ts at 1308 against 1308. The only reported violations are two untouched test files (base drift).

⚠️ base-red inherited: #15306 — unit/vitest/integration/package-artifact gates on release/v3.8.52

diegosouzapw and others added 3 commits October 15, 2026 10:20
…reset

A Gemini free-tier 429 whose google.rpc.QuotaFailure names a per-day quotaId
(e.g. GenerateRequestsPerDayPerProjectPerModel-FreeTier) still carries a ~20s
RetryInfo. Honoring only that hint re-burned the exhausted key on every
rotation until midnight Pacific.

- retryAfterJson: hasGoogleDailyQuotaViolation / googleDailyQuotaIds read the
  QuotaFailure details (strict @type + quotaId match, bounded raw-text fallback).
- googleDailyQuota: cooldown until the operator's node daily-reset clock, else
  the next midnight America/Los_Angeles; appendGoogleDailyQuotaViolation keeps
  the signal next to the prose message the classifiers read.
- checkFallbackError returns quota_exhausted + dailyQuotaExhausted with
  quotaResetHintMs, so per-model providers (gemini) lock only the model;
  parseRetryFromErrorText returns the same daily window.
- chatCore pipeline (non-streaming) and streaming leg carry the compact
  QuotaFailure line into the classification text; client bodies keep the
  first line only.

Co-authored-by: Scott Christian <scottchristian.tas@gmail.com>
Inspired-by: decolua/9router#4348
@github-actions

Copy link
Copy Markdown
Contributor

CI Coverage Report

  • Coverage job: skipped
  • PR test policy: success

Coverage artifact was not available for this run.

@diegosouzapw
diegosouzapw merged commit 2bee2e6 into release/v3.8.52 Oct 15, 2026
37 of 50 checks passed
diegosouzapw added a commit that referenced this pull request Oct 15, 2026
## Summary

- Re-measure the five files this owner merge-batch pushed over the frozen ceilings, on release/v3.8.52 at d50cd80. No slack.
- `open-sse/executors/base.ts` 1743→1745 (#16621), `open-sse/handlers/chatCore/streamingResponse.ts` 1308→1309 (#16705), `open-sse/handlers/chatCore.ts` 3938→3943 (#16720), `open-sse/translator/response/openai-responses.ts` 1547→1590 (#16645), `tests/unit/provider-validation-specialty.test.ts` 2912→2965 (#16625).
- `node scripts/check/check-file-size.mjs` exits 0 on this branch. README drift (#16132) and the inherited doc-source drift were not stamped.

⚠️ base-red inherited: #15306

Thanks @diegosouzapw.
@diegosouzapw
diegosouzapw deleted the fix/port-pr-4348-gemini-daily-quota-park branch October 18, 2026 23:50
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