yc1838 commited on
Commit
259c7bc
·
1 Parent(s): 7aa5b26

docs: refine gemini cooldown design

Browse files
docs/superpowers/specs/2026-05-06-gemini-rate-limit-cooldown-design.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
  title: Gemini Rate-Limit Cooldown Design
3
  date: 2026-05-06
4
- status: approved-for-planning
5
  ---
6
 
7
  # Gemini Rate-Limit Cooldown Design
@@ -10,6 +10,8 @@ status: approved-for-planning
10
 
11
  Lilith currently wraps chat model calls in `_RetryWrapper` with tenacity exponential backoff for provider rate-limit exceptions. This protects a single model call, but it does not coordinate across calls or GAIA tasks. If Gemini quota pressure persists after retries are exhausted, the runner can move to the next task and immediately repeat the same retry ladder against the same overloaded model lane.
12
 
 
 
13
  For current usage, only two Google text models need first-class cooldown behavior:
14
 
15
  | Model | RPM | TPM | RPD |
@@ -21,11 +23,13 @@ Gemma, embeddings, image, audio, video, and grounding quotas are out of scope fo
21
 
22
  ## Goals
23
 
24
- 1. Avoid hammering Gemini after retry exhaustion.
25
- 2. Cool down the specific provider/model lane that is rate-limited.
26
- 3. Let GAIA batch runs wait, retry the same question once, then continue if still blocked.
27
- 4. Keep existing per-call exponential backoff behavior.
28
- 5. Preserve normal non-rate-limit error handling.
 
 
29
 
30
  ## Non-goals
31
 
@@ -45,7 +49,40 @@ Represent each rate-limited Gemini model as a lane keyed by provider and model:
45
  - `google:gemini-3-flash-preview`
46
  - `google:gemini-3.1-pro`
47
 
48
- Unknown models should continue to use the existing retry wrapper without profile-specific behavior. They may still share generic retry handling, but they should not get special cooldown assumptions from this design.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  ### In-process cooldown registry
51
 
@@ -56,26 +93,25 @@ Add a small module-level registry in `models.py`:
56
  (provider, model) -> consecutive_rate_limit_exhaustions
57
  ```
58
 
 
 
59
  Before invoking the wrapped model, `_RetryWrapper` checks whether its lane has an active cooldown. If active, it sleeps until the cooldown expires, then proceeds with the existing tenacity retry loop.
60
 
61
  After a successful call, the lane's consecutive rate-limit exhaustion count resets to zero.
62
 
63
- ### Rate-limit exhaustion signal
64
 
65
- Introduce a project-level exception such as `RateLimitCooldownError`. `_RetryWrapper` should raise it only when all retry attempts were exhausted due to a recognized retryable rate-limit exception.
66
 
67
- The exception should carry enough information for callers and logs:
68
 
69
- - provider
70
- - model
71
- - cooldown seconds
72
- - original exception string
73
-
74
- Non-rate-limit exceptions should keep their existing behavior and should not be rewritten as cooldown errors.
75
 
76
- ### Cooldown duration
77
 
78
- Because both target models have high RPM, exhausted 429s are likely burst, token, or daily-quota pressure rather than ordinary request spacing. Use a conservative escalating cooldown for repeated failures on the same lane:
79
 
80
  | Consecutive exhausted rate limits | Cooldown |
81
  | ---: | ---: |
@@ -83,72 +119,158 @@ Because both target models have high RPM, exhausted 429s are likely burst, token
83
  | 2 | 120 seconds |
84
  | 3+ | 300 seconds |
85
 
86
- If the provider exception exposes an explicit retry delay later, a future patch can prefer that delay. This design does not require parsing provider-specific retry metadata.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  ### Runner behavior
89
 
90
- `runner.py::run_agent_on_questions` should distinguish rate-limit cooldown failures from generic agent errors.
91
 
92
  For each GAIA task:
93
 
94
- 1. Invoke the graph normally.
95
  2. If a `RateLimitCooldownError` escapes:
96
  - log the task, model, and cooldown duration,
97
  - sleep for the indicated cooldown,
98
- - retry the same task once from a fresh ephemeral memory context.
99
  3. If the retry succeeds, format and checkpoint the answer normally.
100
  4. If the retry still fails with rate-limit exhaustion, append an `AGENT ERROR: RATE LIMITED` answer for this run and continue to the next question.
101
- 5. Do not write a normal success checkpoint for rate-limited failures.
 
 
 
 
 
102
 
103
- This keeps reruns possible after quota resets and avoids falsely treating a quota failure as a completed answer.
104
 
105
  ## Data Flow
106
 
107
  ```text
108
  GAIA task
 
109
  -> runner invokes graph
110
  -> model node calls Gemini lane
111
  -> _RetryWrapper checks lane cooldown
112
- -> tenacity retries per call
113
- -> exhausted 429 records lane cooldown
 
114
  -> RateLimitCooldownError bubbles up
115
  -> runner waits cooldown
116
- -> runner retries same GAIA task once
117
  -> success checkpoints answer OR failure continues without success checkpoint
118
  ```
119
 
 
 
 
 
 
 
 
 
 
120
  ## Error Handling
121
 
122
- - Rate-limit exhaustion should be logged at warning level with provider, model, and cooldown.
123
  - Generic graph/model/tool errors should continue through the existing `AGENT ERROR` path.
124
  - A failed retry after cooldown should not crash the whole batch.
125
  - A successful call after cooldown should reset that model lane's consecutive rate-limit count.
 
 
126
 
127
  ## Testing Plan
128
 
129
  Add focused unit tests without hitting external APIs:
130
 
131
- 1. `_RetryWrapper` sleeps when a lane cooldown is active.
132
- 2. `_RetryWrapper` records a 60-second cooldown after first exhausted retryable error.
133
- 3. Consecutive exhausted rate-limit errors escalate to 120 seconds and then 300 seconds.
134
- 4. Successful calls reset the consecutive failure counter.
135
- 5. `run_agent_on_questions` retries the same task once after `RateLimitCooldownError`.
136
- 6. `run_agent_on_questions` does not write a normal checkpoint when both attempts are rate-limited.
137
- 7. Unknown/non-Google models do not receive Gemini-specific cooldown profiles.
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  ## Risks and Mitigations
140
 
141
  - **Risk: sleeping in tests slows the suite.**
142
  - Mitigation: inject or monkeypatch sleep/time functions in tests.
143
 
 
 
 
144
  - **Risk: cooldown is only in-process.**
145
- - Mitigation: acceptable for current local batch runs; cross-process persistence is a non-goal.
146
 
147
  - **Risk: same-question retry duplicates some work.**
148
  - Mitigation: retry only once and use a fresh ephemeral memory store, matching existing per-task isolation.
149
 
150
- - **Risk: provider raises a different 429 exception shape.**
151
- - Mitigation: reuse the existing `RETRY_EXCEPTIONS` list so cooldown behavior follows current retry classification.
152
 
153
  ## Implementation Boundaries
154
 
 
1
  ---
2
  title: Gemini Rate-Limit Cooldown Design
3
  date: 2026-05-06
4
+ status: revised-for-review
5
  ---
6
 
7
  # Gemini Rate-Limit Cooldown Design
 
10
 
11
  Lilith currently wraps chat model calls in `_RetryWrapper` with tenacity exponential backoff for provider rate-limit exceptions. This protects a single model call, but it does not coordinate across calls or GAIA tasks. If Gemini quota pressure persists after retries are exhausted, the runner can move to the next task and immediately repeat the same retry ladder against the same overloaded model lane.
12
 
13
+ Current Gemini retry detection also needs tightening. The installed `langchain-google-genai` path catches `google.genai.errors.ClientError`, while `models.py` currently only registers `google.api_core.exceptions.ResourceExhausted` for Google. A cooldown layer must first classify real Gemini 429s correctly, without retrying non-429 client errors such as `400 INVALID_ARGUMENT`.
14
+
15
  For current usage, only two Google text models need first-class cooldown behavior:
16
 
17
  | Model | RPM | TPM | RPD |
 
23
 
24
  ## Goals
25
 
26
+ 1. Correctly detect retryable Gemini 429s from the actual GenAI client exception shape.
27
+ 2. Retain per-call exponential backoff for transient single-call hiccups.
28
+ 3. Add shared lane cooldown for persistent retry exhaustion.
29
+ 4. Skip a single GAIA question when it hits the per-question 429 streak threshold.
30
+ 5. Pause the whole batch when the recent cross-task 429 rate is high.
31
+ 6. Stop the batch on daily-quota/RPD exhaustion instead of polling every few minutes.
32
+ 7. Preserve normal non-rate-limit error handling.
33
 
34
  ## Non-goals
35
 
 
49
  - `google:gemini-3-flash-preview`
50
  - `google:gemini-3.1-pro`
51
 
52
+ Unknown models should continue to use generic retry behavior. They must not inherit Gemini-specific cooldown profiles or Gemini quota assumptions.
53
+
54
+ ### Retryable rate-limit predicate
55
+
56
+ Replace type-only retry classification with a custom predicate, for example `is_retryable_rate_limit(exc)`.
57
+
58
+ The predicate should return true for:
59
+
60
+ - `google.api_core.exceptions.ResourceExhausted`
61
+ - `google.genai.errors.ClientError` only when `exc.code == 429`
62
+ - Anthropic `RateLimitError`
63
+ - OpenAI `RateLimitError`
64
+
65
+ The predicate should return false for:
66
+
67
+ - `google.genai.errors.ClientError` with any non-429 code
68
+ - generic `ClientError`/`ValueError`/tool errors
69
+ - invalid request errors such as `400 INVALID_ARGUMENT`
70
+
71
+ Tenacity should use this predicate rather than `retry_if_exception_type(RETRY_EXCEPTIONS)`. This keeps 429 handling broad enough for real Gemini calls while preventing retries/cooldowns for deterministic bad requests.
72
+
73
+ ### Quota metadata classification
74
+
75
+ When a retryable Gemini 429 is observed, inspect available metadata before deciding whether to apply a short cooldown.
76
+
77
+ Use defensive parsing against `exc.details` because Gemini error payloads may vary. The implementation should look for:
78
+
79
+ - `RetryInfo` details with `retryDelay`
80
+ - `QuotaFailure` details with a `quotaId`
81
+ - quota identifiers containing daily-scope markers such as `PerDay`
82
+
83
+ If `retryDelay` is greater than 600 seconds, or if a quota identifier indicates a daily/RPD limit, raise a batch-abort signal instead of applying the 60/120/300 second lane cooldown. Waiting 300 seconds does not fix `50K/day` exhaustion on `gemini-3.1-pro`.
84
+
85
+ If metadata is absent or unparseable, fall back to the normal lane cooldown ladder.
86
 
87
  ### In-process cooldown registry
88
 
 
93
  (provider, model) -> consecutive_rate_limit_exhaustions
94
  ```
95
 
96
+ Use `time.monotonic()` for all cooldown calculations.
97
+
98
  Before invoking the wrapped model, `_RetryWrapper` checks whether its lane has an active cooldown. If active, it sleeps until the cooldown expires, then proceeds with the existing tenacity retry loop.
99
 
100
  After a successful call, the lane's consecutive rate-limit exhaustion count resets to zero.
101
 
102
+ Cooldown state is shared across `_RetryWrapper` instances for the same lane and isolated across different lanes. A cooldown on `google:gemini-3.1-pro` must not delay a `google:gemini-3-flash-preview` call.
103
 
104
+ ### Cooldown trigger and duration
105
 
106
+ The cooldown layer sits on top of the existing tenacity retry ladder. A normal exhausted call still spends time inside tenacity first. With the current retry settings, the rough pre-cooldown wait sequence is approximately:
107
 
108
+ ```text
109
+ 4s -> 8s -> 16s -> 32s -> 60s
110
+ ```
 
 
 
111
 
112
+ Only after tenacity exhausts retryable 429 attempts should the wrapper record lane exhaustion and raise a rate-limit signal.
113
 
114
+ Use this conservative escalating cooldown for repeated failures on the same lane:
115
 
116
  | Consecutive exhausted rate limits | Cooldown |
117
  | ---: | ---: |
 
119
  | 2 | 120 seconds |
120
  | 3+ | 300 seconds |
121
 
122
+ If Gemini supplies a shorter explicit retry delay for non-daily quota pressure, a future patch can prefer it. This patch only needs the daily-vs-short-cooldown distinction.
123
+
124
+ ### Rate-limit signals
125
+
126
+ Introduce project-level exceptions:
127
+
128
+ - `RateLimitCooldownError`
129
+ - Raised when one model invocation exhausts tenacity retries on a retryable, non-daily rate limit.
130
+ - Carries provider, model, cooldown seconds, and original exception text.
131
+
132
+ - `QuestionRateLimitStreakError`
133
+ - Raised when the current GAIA question reaches the per-question 429 streak threshold.
134
+ - Used to skip the current question without burning more model calls.
135
+
136
+ - `BatchAbortRateLimitError`
137
+ - Raised when metadata indicates daily/RPD exhaustion or a retry delay longer than 600 seconds.
138
+ - Used to stop the batch rather than polling through every remaining task.
139
+
140
+ Non-rate-limit exceptions should keep their existing behavior and should not be rewritten as cooldown errors.
141
+
142
+ ### Per-question streak threshold
143
+
144
+ The runner should execute each task inside a rate-limit question scope. Within that scope, `_RetryWrapper` records each observed retryable 429 event for the current question.
145
+
146
+ If one question accumulates 50 consecutive retryable 429 events, abort that question with `QuestionRateLimitStreakError`.
147
+
148
+ The streak resets when:
149
+
150
+ - the current question finishes,
151
+ - a model call succeeds,
152
+ - a non-rate-limit error occurs,
153
+ - the runner moves to the next GAIA task.
154
+
155
+ This protects against a single hard question or loop burning retry ladders indefinitely.
156
+
157
+ ### Cross-task sliding window
158
+
159
+ Maintain an in-process sliding window over recent model-call outcomes during a GAIA batch. One outcome means one lower-level model request attempt observed by the retry wrapper, including tenacity retry attempts; it does not mean one GAIA task.
160
+
161
+ ```text
162
+ window size: 100 model-call outcomes
163
+ pause condition: >= 70 rate-limited outcomes in the window
164
+ ```
165
+
166
+ When the pause condition is met, the runner should pause the whole batch with exponential backoff:
167
+
168
+ | Consecutive batch pauses | Pause |
169
+ | ---: | ---: |
170
+ | 1 | 300 seconds |
171
+ | 2 | 600 seconds |
172
+ | 3+ | 1200 seconds |
173
+
174
+ After a batch pause, clear the sliding window so the same old failures do not immediately trigger another pause.
175
+
176
+ This is separate from lane cooldown. Lane cooldown handles the next call on a specific model; the sliding window handles the case where many tasks across the batch are hitting the same quota wall.
177
 
178
  ### Runner behavior
179
 
180
+ `runner.py::run_agent_on_questions` is currently sequential. This design assumes sequential batch execution and uses synchronous sleeps. If the runner later becomes concurrent, cooldown and pause sleeps must be revisited.
181
 
182
  For each GAIA task:
183
 
184
+ 1. Invoke the graph normally inside the existing `with ephemeral_memory():` task isolation block.
185
  2. If a `RateLimitCooldownError` escapes:
186
  - log the task, model, and cooldown duration,
187
  - sleep for the indicated cooldown,
188
+ - retry the same task once inside a new `with ephemeral_memory():` block.
189
  3. If the retry succeeds, format and checkpoint the answer normally.
190
  4. If the retry still fails with rate-limit exhaustion, append an `AGENT ERROR: RATE LIMITED` answer for this run and continue to the next question.
191
+ 5. If `QuestionRateLimitStreakError` escapes, append an answer beginning with `AGENT ERROR: RATE LIMITED` and continue to the next question.
192
+ 6. If `BatchAbortRateLimitError` escapes:
193
+ - do not write a normal success checkpoint for the current task,
194
+ - append an in-memory answer beginning with `AGENT ERROR: RATE LIMITED`,
195
+ - write a non-success diagnostic marker at `<checkpoint_dir>/rate_limit_abort.json`,
196
+ - stop the batch and return answers collected so far.
197
 
198
+ The `AGENT ERROR:` prefix matches the existing runner convention and ensures any shared checkpoint guard continues to treat the value as non-success.
199
 
200
  ## Data Flow
201
 
202
  ```text
203
  GAIA task
204
+ -> runner opens ephemeral_memory()
205
  -> runner invokes graph
206
  -> model node calls Gemini lane
207
  -> _RetryWrapper checks lane cooldown
208
+ -> tenacity retries using is_retryable_rate_limit
209
+ -> each observed 429 updates question scope + batch window
210
+ -> exhausted non-daily 429 records lane cooldown
211
  -> RateLimitCooldownError bubbles up
212
  -> runner waits cooldown
213
+ -> runner retries same GAIA task once in a fresh ephemeral_memory()
214
  -> success checkpoints answer OR failure continues without success checkpoint
215
  ```
216
 
217
+ For daily/RPD exhaustion:
218
+
219
+ ```text
220
+ Gemini 429 payload indicates PerDay or retryDelay > 600s
221
+ -> BatchAbortRateLimitError
222
+ -> runner writes diagnostic marker
223
+ -> runner stops batch
224
+ ```
225
+
226
  ## Error Handling
227
 
228
+ - Rate-limit exhaustion should be logged at warning level with provider, model, cooldown, and task id when available.
229
  - Generic graph/model/tool errors should continue through the existing `AGENT ERROR` path.
230
  - A failed retry after cooldown should not crash the whole batch.
231
  - A successful call after cooldown should reset that model lane's consecutive rate-limit count.
232
+ - Non-429 `google.genai.errors.ClientError` must not trigger retry, cooldown, question streak, or batch pause.
233
+ - Process restart resets in-process cooldown, streak, and sliding-window state by design.
234
 
235
  ## Testing Plan
236
 
237
  Add focused unit tests without hitting external APIs:
238
 
239
+ 1. `is_retryable_rate_limit` returns true for `google.genai.errors.ClientError` with code `429`.
240
+ 2. `is_retryable_rate_limit` returns false for `google.genai.errors.ClientError` with code `400`.
241
+ 3. `_RetryWrapper` sleeps when a lane cooldown is active.
242
+ 4. `_RetryWrapper` records a 60-second cooldown after first exhausted retryable error.
243
+ 5. Consecutive exhausted rate-limit errors escalate to 120 seconds and then 300 seconds.
244
+ 6. Successful calls reset the consecutive lane failure counter.
245
+ 7. Two `_RetryWrapper` instances on the same lane share cooldown.
246
+ 8. Cooldown on `gemini-3.1-pro` does not delay `gemini-3-flash-preview`.
247
+ 9. Cooldown uses `time.monotonic()` rather than wall-clock time.
248
+ 10. Daily quota metadata or `retryDelay > 600s` raises `BatchAbortRateLimitError`.
249
+ 11. `run_agent_on_questions` retries the same task once after `RateLimitCooldownError`.
250
+ 12. `run_agent_on_questions` opens a fresh `ephemeral_memory()` block for the retry.
251
+ 13. `run_agent_on_questions` does not write a normal success checkpoint when both attempts are rate-limited.
252
+ 14. A 50-event per-question 429 streak skips the current question.
253
+ 15. A 100-outcome sliding window with at least 70 rate-limited outcomes triggers a batch pause.
254
+ 16. Unknown/non-Google models do not receive Gemini-specific cooldown profiles.
255
+
256
+ Tests should monkeypatch sleep/time functions so the suite does not actually wait.
257
 
258
  ## Risks and Mitigations
259
 
260
  - **Risk: sleeping in tests slows the suite.**
261
  - Mitigation: inject or monkeypatch sleep/time functions in tests.
262
 
263
+ - **Risk: synchronous sleep blocks future concurrent runners.**
264
+ - Mitigation: document sequential runner assumption; current `run_agent_on_questions` loops tasks sequentially.
265
+
266
  - **Risk: cooldown is only in-process.**
267
+ - Mitigation: acceptable for current local batch runs; process restart resetting cooldown is a deliberate non-goal.
268
 
269
  - **Risk: same-question retry duplicates some work.**
270
  - Mitigation: retry only once and use a fresh ephemeral memory store, matching existing per-task isolation.
271
 
272
+ - **Risk: Gemini error payloads vary.**
273
+ - Mitigation: classify 429s by `exc.code` first; parse quota metadata defensively; fall back to short cooldown only when daily quota cannot be detected.
274
 
275
  ## Implementation Boundaries
276