yc1838 commited on
Commit
388e04b
·
1 Parent(s): 4103ce2

feat: track batch rate limit window

Browse files
Files changed (2) hide show
  1. src/lilith_agent/models.py +29 -1
  2. tests/test_models.py +30 -0
src/lilith_agent/models.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import asyncio
4
  import logging
5
  import time
 
6
  from contextlib import contextmanager
7
  from contextvars import ContextVar
8
  from dataclasses import dataclass
@@ -112,9 +113,14 @@ _NO_THINK = "/no_think"
112
  _GEMINI_COOLDOWN_MODELS = {"gemini-3-flash-preview", "gemini-3.1-pro"}
113
  _COOLDOWN_LADDER_SECONDS = (60, 120, 300)
114
  _QUESTION_STREAK_LIMIT = 50
 
 
 
115
  _cooldown_until: dict[tuple[str, str], float] = {}
116
  _rate_limit_exhaustions: dict[tuple[str, str], int] = {}
117
  _question_rate_limit_streak: ContextVar[int | None] = ContextVar("question_rate_limit_streak", default=None)
 
 
118
 
119
 
120
  @dataclass
@@ -149,8 +155,11 @@ class QuestionRateLimitStreakError(Exception):
149
 
150
 
151
  def _reset_rate_limit_state_for_tests() -> None:
 
152
  _cooldown_until.clear()
153
  _rate_limit_exhaustions.clear()
 
 
154
 
155
 
156
  @contextmanager
@@ -163,8 +172,11 @@ def rate_limit_question_scope():
163
 
164
 
165
  def record_rate_limit_observation(exc: BaseException) -> None:
 
 
 
166
  current = _question_rate_limit_streak.get()
167
- if current is None or not is_retryable_rate_limit(exc):
168
  return
169
  current += 1
170
  _question_rate_limit_streak.set(current)
@@ -173,10 +185,26 @@ def record_rate_limit_observation(exc: BaseException) -> None:
173
 
174
 
175
  def record_rate_limit_success() -> None:
 
176
  if _question_rate_limit_streak.get() is not None:
177
  _question_rate_limit_streak.set(0)
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
181
  if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
182
  return (provider, model_name)
 
3
  import asyncio
4
  import logging
5
  import time
6
+ from collections import deque
7
  from contextlib import contextmanager
8
  from contextvars import ContextVar
9
  from dataclasses import dataclass
 
113
  _GEMINI_COOLDOWN_MODELS = {"gemini-3-flash-preview", "gemini-3.1-pro"}
114
  _COOLDOWN_LADDER_SECONDS = (60, 120, 300)
115
  _QUESTION_STREAK_LIMIT = 50
116
+ _BATCH_WINDOW_SIZE = 100
117
+ _BATCH_WINDOW_RATE_LIMIT_THRESHOLD = 70
118
+ _BATCH_PAUSE_LADDER_SECONDS = (300, 600, 1200)
119
  _cooldown_until: dict[tuple[str, str], float] = {}
120
  _rate_limit_exhaustions: dict[tuple[str, str], int] = {}
121
  _question_rate_limit_streak: ContextVar[int | None] = ContextVar("question_rate_limit_streak", default=None)
122
+ _batch_rate_limit_window: deque[bool] = deque(maxlen=_BATCH_WINDOW_SIZE)
123
+ _batch_pause_count = 0
124
 
125
 
126
  @dataclass
 
155
 
156
 
157
  def _reset_rate_limit_state_for_tests() -> None:
158
+ global _batch_pause_count
159
  _cooldown_until.clear()
160
  _rate_limit_exhaustions.clear()
161
+ _batch_rate_limit_window.clear()
162
+ _batch_pause_count = 0
163
 
164
 
165
  @contextmanager
 
172
 
173
 
174
  def record_rate_limit_observation(exc: BaseException) -> None:
175
+ if not is_retryable_rate_limit(exc):
176
+ return
177
+ _batch_rate_limit_window.append(True)
178
  current = _question_rate_limit_streak.get()
179
+ if current is None:
180
  return
181
  current += 1
182
  _question_rate_limit_streak.set(current)
 
185
 
186
 
187
  def record_rate_limit_success() -> None:
188
+ _batch_rate_limit_window.append(False)
189
  if _question_rate_limit_streak.get() is not None:
190
  _question_rate_limit_streak.set(0)
191
 
192
 
193
+ def batch_rate_limit_pause_seconds() -> int | None:
194
+ global _batch_pause_count
195
+ if len(_batch_rate_limit_window) < _BATCH_WINDOW_SIZE:
196
+ return None
197
+ if sum(_batch_rate_limit_window) < _BATCH_WINDOW_RATE_LIMIT_THRESHOLD:
198
+ return None
199
+ _batch_pause_count += 1
200
+ idx = min(_batch_pause_count - 1, len(_BATCH_PAUSE_LADDER_SECONDS) - 1)
201
+ return _BATCH_PAUSE_LADDER_SECONDS[idx]
202
+
203
+
204
+ def clear_batch_rate_limit_window() -> None:
205
+ _batch_rate_limit_window.clear()
206
+
207
+
208
  def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
209
  if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
210
  return (provider, model_name)
tests/test_models.py CHANGED
@@ -13,6 +13,8 @@ from lilith_agent.models import (
13
  QuestionRateLimitStreakError,
14
  RateLimitCooldownError,
15
  _reset_rate_limit_state_for_tests,
 
 
16
  is_retryable_rate_limit,
17
  rate_limit_question_scope,
18
  )
@@ -344,3 +346,31 @@ def test_question_rate_limit_scope_resets_after_success():
344
  record_rate_limit_success()
345
  for _ in range(49):
346
  record_rate_limit_observation(exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  QuestionRateLimitStreakError,
14
  RateLimitCooldownError,
15
  _reset_rate_limit_state_for_tests,
16
+ batch_rate_limit_pause_seconds,
17
+ clear_batch_rate_limit_window,
18
  is_retryable_rate_limit,
19
  rate_limit_question_scope,
20
  )
 
346
  record_rate_limit_success()
347
  for _ in range(49):
348
  record_rate_limit_observation(exc)
349
+
350
+
351
+ def test_batch_window_triggers_pause_after_70_rate_limits_in_100_outcomes():
352
+ _reset_rate_limit_state_for_tests()
353
+ exc = _make_genai_client_error(429)
354
+ from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
355
+
356
+ for _ in range(70):
357
+ record_rate_limit_observation(exc)
358
+ for _ in range(30):
359
+ record_rate_limit_success()
360
+
361
+ assert batch_rate_limit_pause_seconds() == 300
362
+ clear_batch_rate_limit_window()
363
+ assert batch_rate_limit_pause_seconds() is None
364
+
365
+
366
+ def test_batch_window_does_not_trigger_below_threshold():
367
+ _reset_rate_limit_state_for_tests()
368
+ exc = _make_genai_client_error(429)
369
+ from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
370
+
371
+ for _ in range(69):
372
+ record_rate_limit_observation(exc)
373
+ for _ in range(31):
374
+ record_rate_limit_success()
375
+
376
+ assert batch_rate_limit_pause_seconds() is None