yc1838 commited on
Commit
03425af
·
1 Parent(s): be749ea

feat: classify gemini client rate limits

Browse files
Files changed (2) hide show
  1. src/lilith_agent/models.py +57 -28
  2. tests/test_models.py +31 -0
src/lilith_agent/models.py CHANGED
@@ -1,5 +1,8 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  from typing import Any, Sequence
4
 
5
  from langchain_anthropic import ChatAnthropic
@@ -11,43 +14,69 @@ from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
11
  from langchain_ollama import ChatOllama
12
  from langchain_openai import ChatOpenAI
13
 
14
- import logging
15
- from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type, Retrying, AsyncRetrying
16
  from lilith_agent.config import Config
17
 
18
- # Identify retryable exceptions (429s) across providers
19
- RETRY_EXCEPTIONS = []
20
  try:
21
  from google.api_core.exceptions import ResourceExhausted
22
- RETRY_EXCEPTIONS.append(ResourceExhausted)
23
  except ImportError:
24
- pass
 
 
 
 
 
25
 
26
  try:
27
  from anthropic import RateLimitError as AnthropicRateLimitError
28
- RETRY_EXCEPTIONS.append(AnthropicRateLimitError)
29
  except ImportError:
30
- pass
31
 
32
  try:
33
  from openai import RateLimitError as OpenAIRateLimitError
34
- RETRY_EXCEPTIONS.append(OpenAIRateLimitError)
35
  except ImportError:
36
- pass
37
-
38
- RETRY_EXCEPTIONS = tuple(RETRY_EXCEPTIONS)
39
-
40
- # Shared retry configuration for context managers
41
- _RETRY_PARAMS = dict(
42
- retry=retry_if_exception_type(RETRY_EXCEPTIONS) if RETRY_EXCEPTIONS else lambda e: False,
43
- wait=wait_exponential(multiplier=2, min=4, max=60),
44
- stop=stop_after_attempt(5),
45
- before_sleep=lambda retry_state: log.warning(
46
- f"LLM Rate Limit (429) hit. Retrying in {retry_state.next_action.sleep}s... "
47
- f"(Attempt {retry_state.attempt_number}/5)"
48
- ),
49
- reraise=True
50
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  try:
53
  from langchain_community.cache import SQLiteCache
@@ -147,12 +176,12 @@ class _RetryWrapper(BaseChatModel):
147
  return f"retry-{self.inner._llm_type}"
148
 
149
  def _generate(self, *args, **kwargs):
150
- for attempt in Retrying(**_RETRY_PARAMS):
151
  with attempt:
152
  return self.inner._generate(*args, **kwargs)
153
 
154
  async def _agenerate(self, *args, **kwargs):
155
- async for attempt in AsyncRetrying(**_RETRY_PARAMS):
156
  with attempt:
157
  return await self.inner._agenerate(*args, **kwargs)
158
 
@@ -169,12 +198,12 @@ class _BoundRetryWrapper(Runnable):
169
  self._bound = bound
170
 
171
  def invoke(self, input, config=None, **kwargs):
172
- for attempt in Retrying(**_RETRY_PARAMS):
173
  with attempt:
174
  return self._bound.invoke(input, config=config, **kwargs)
175
 
176
  async def ainvoke(self, input, config=None, **kwargs):
177
- async for attempt in AsyncRetrying(**_RETRY_PARAMS):
178
  with attempt:
179
  return await self._bound.ainvoke(input, config=config, **kwargs)
180
 
 
1
  from __future__ import annotations
2
 
3
+ import asyncio
4
+ import logging
5
+ import time
6
  from typing import Any, Sequence
7
 
8
  from langchain_anthropic import ChatAnthropic
 
14
  from langchain_ollama import ChatOllama
15
  from langchain_openai import ChatOpenAI
16
 
17
+ from tenacity import AsyncRetrying, Retrying, retry_if_exception, stop_after_attempt, wait_exponential
 
18
  from lilith_agent.config import Config
19
 
 
 
20
  try:
21
  from google.api_core.exceptions import ResourceExhausted
 
22
  except ImportError:
23
+ ResourceExhausted = None
24
+
25
+ try:
26
+ from google.genai.errors import ClientError as GenAIClientError
27
+ except ImportError:
28
+ GenAIClientError = None
29
 
30
  try:
31
  from anthropic import RateLimitError as AnthropicRateLimitError
 
32
  except ImportError:
33
+ AnthropicRateLimitError = None
34
 
35
  try:
36
  from openai import RateLimitError as OpenAIRateLimitError
 
37
  except ImportError:
38
+ OpenAIRateLimitError = None
39
+
40
+
41
+ def is_retryable_rate_limit(exc: BaseException) -> bool:
42
+ if ResourceExhausted is not None and isinstance(exc, ResourceExhausted):
43
+ return True
44
+ if GenAIClientError is not None and isinstance(exc, GenAIClientError):
45
+ return getattr(exc, "code", None) == 429
46
+ if AnthropicRateLimitError is not None and isinstance(exc, AnthropicRateLimitError):
47
+ return True
48
+ if OpenAIRateLimitError is not None and isinstance(exc, OpenAIRateLimitError):
49
+ return True
50
+ return False
51
+
52
+
53
+ def _tenacity_sleep(seconds: float) -> None:
54
+ time.sleep(seconds)
55
+
56
+
57
+ async def _async_tenacity_sleep(seconds: float) -> None:
58
+ await asyncio.sleep(seconds)
59
+
60
+
61
+ def _base_retry_params() -> dict[str, Any]:
62
+ return dict(
63
+ retry=retry_if_exception(is_retryable_rate_limit),
64
+ wait=wait_exponential(multiplier=2, min=4, max=60),
65
+ stop=stop_after_attempt(5),
66
+ before_sleep=lambda retry_state: log.warning(
67
+ f"LLM Rate Limit (429) hit. Retrying in {retry_state.next_action.sleep}s... "
68
+ f"(Attempt {retry_state.attempt_number}/5)"
69
+ ),
70
+ reraise=True,
71
+ )
72
+
73
+
74
+ def _sync_retry_params() -> dict[str, Any]:
75
+ return {**_base_retry_params(), "sleep": _tenacity_sleep}
76
+
77
+
78
+ def _async_retry_params() -> dict[str, Any]:
79
+ return {**_base_retry_params(), "sleep": _async_tenacity_sleep}
80
 
81
  try:
82
  from langchain_community.cache import SQLiteCache
 
176
  return f"retry-{self.inner._llm_type}"
177
 
178
  def _generate(self, *args, **kwargs):
179
+ for attempt in Retrying(**_sync_retry_params()):
180
  with attempt:
181
  return self.inner._generate(*args, **kwargs)
182
 
183
  async def _agenerate(self, *args, **kwargs):
184
+ async for attempt in AsyncRetrying(**_async_retry_params()):
185
  with attempt:
186
  return await self.inner._agenerate(*args, **kwargs)
187
 
 
198
  self._bound = bound
199
 
200
  def invoke(self, input, config=None, **kwargs):
201
+ for attempt in Retrying(**_sync_retry_params()):
202
  with attempt:
203
  return self._bound.invoke(input, config=config, **kwargs)
204
 
205
  async def ainvoke(self, input, config=None, **kwargs):
206
+ async for attempt in AsyncRetrying(**_async_retry_params()):
207
  with attempt:
208
  return await self._bound.ainvoke(input, config=config, **kwargs)
209
 
tests/test_models.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  from langchain_core.runnables import Runnable, RunnableLambda
2
  from langchain_core.messages import AIMessage
3
 
@@ -6,6 +8,7 @@ from lilith_agent.models import (
6
  _NoThinkWrapper,
7
  _BoundRetryWrapper,
8
  _BoundNoThinkWrapper,
 
9
  )
10
 
11
 
@@ -53,3 +56,31 @@ def test_no_think_wrapper_bind_tools_returns_runnable():
53
 
54
  assert isinstance(bound, _BoundNoThinkWrapper)
55
  assert isinstance(bound, Runnable)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
  from langchain_core.runnables import Runnable, RunnableLambda
4
  from langchain_core.messages import AIMessage
5
 
 
8
  _NoThinkWrapper,
9
  _BoundRetryWrapper,
10
  _BoundNoThinkWrapper,
11
+ is_retryable_rate_limit,
12
  )
13
 
14
 
 
56
 
57
  assert isinstance(bound, _BoundNoThinkWrapper)
58
  assert isinstance(bound, Runnable)
59
+
60
+
61
+ def _make_genai_client_error(code: int):
62
+ pytest.importorskip("google.genai.errors")
63
+ from google.genai.errors import ClientError
64
+
65
+ return ClientError(
66
+ code,
67
+ {
68
+ "error": {
69
+ "code": code,
70
+ "status": "RESOURCE_EXHAUSTED" if code == 429 else "INVALID_ARGUMENT",
71
+ "message": "test error",
72
+ }
73
+ },
74
+ )
75
+
76
+
77
+ def test_genai_client_error_429_is_retryable():
78
+ exc = _make_genai_client_error(429)
79
+
80
+ assert is_retryable_rate_limit(exc) is True
81
+
82
+
83
+ def test_genai_client_error_400_is_not_retryable():
84
+ exc = _make_genai_client_error(400)
85
+
86
+ assert is_retryable_rate_limit(exc) is False