kash-shell 0.3.22__py3-none-any.whl → 0.3.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,43 @@ from __future__ import annotations
3
3
  import random
4
4
  from collections.abc import Callable
5
5
  from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ from kash.utils.api_utils.http_utils import extract_http_status_code
9
+
10
+
11
+ class HTTPRetryBehavior(Enum):
12
+ """HTTP status code retry behavior classification."""
13
+
14
+ FULL = "full"
15
+ """Fully retry these status codes (e.g., 429, 500, 502, 503, 504)"""
16
+
17
+ CONSERVATIVE = "conservative"
18
+ """Retry conservatively: may indicate rate limiting or temporary issues (e.g., 403, 408)"""
19
+
20
+ NEVER = "never"
21
+ """Never retry these status codes (e.g., 400, 401, 404, 410)"""
22
+
23
+
24
+ # Default HTTP status code retry classifications
25
+ DEFAULT_HTTP_RETRY_MAP: dict[int, HTTPRetryBehavior] = {
26
+ # Fully retriable: server errors and explicit rate limiting
27
+ 429: HTTPRetryBehavior.FULL, # Too Many Requests
28
+ 500: HTTPRetryBehavior.FULL, # Internal Server Error
29
+ 502: HTTPRetryBehavior.FULL, # Bad Gateway
30
+ 503: HTTPRetryBehavior.FULL, # Service Unavailable
31
+ 504: HTTPRetryBehavior.FULL, # Gateway Timeout
32
+ # Conservatively retriable: might be temporary
33
+ 403: HTTPRetryBehavior.CONSERVATIVE, # Forbidden (could be rate limiting)
34
+ 408: HTTPRetryBehavior.CONSERVATIVE, # Request Timeout
35
+ # Never retriable: client errors
36
+ 400: HTTPRetryBehavior.NEVER, # Bad Request
37
+ 401: HTTPRetryBehavior.NEVER, # Unauthorized
38
+ 404: HTTPRetryBehavior.NEVER, # Not Found
39
+ 405: HTTPRetryBehavior.NEVER, # Method Not Allowed
40
+ 410: HTTPRetryBehavior.NEVER, # Gone
41
+ 422: HTTPRetryBehavior.NEVER, # Unprocessable Entity
42
+ }
6
43
 
7
44
 
8
45
  class RetryException(RuntimeError):
@@ -29,7 +66,7 @@ class RetryExhaustedException(RetryException):
29
66
 
30
67
  def default_is_retriable(exception: Exception) -> bool:
31
68
  """
32
- Default retriable exception checker for common rate limit patterns.
69
+ Default retriable exception checker with HTTP status code awareness.
33
70
 
34
71
  Args:
35
72
  exception: The exception to check
@@ -51,12 +88,22 @@ def default_is_retriable(exception: Exception) -> bool:
51
88
  ):
52
89
  return True
53
90
  except ImportError:
54
- # LiteLLM not available, fall back to string-based detection
91
+ # LiteLLM not available, fall back to other detection methods
55
92
  pass
56
93
 
57
- # Fallback to string-based detection for general patterns
94
+ # Try to extract HTTP status code for more precise handling
95
+ status_code = extract_http_status_code(exception)
96
+ if status_code is not None:
97
+ return is_http_status_retriable(status_code, DEFAULT_HTTP_RETRY_MAP)
98
+
99
+ # Fallback to string-based detection for transient errors
58
100
  exception_str = str(exception).lower()
59
- rate_limit_indicators = [
101
+
102
+ # Check exception type names for common transient network errors
103
+ exception_type = type(exception).__name__.lower()
104
+
105
+ transient_error_indicators = [
106
+ # Rate limiting and quota errors
60
107
  "rate limit",
61
108
  "too many requests",
62
109
  "try again later",
@@ -65,9 +112,92 @@ def default_is_retriable(exception: Exception) -> bool:
65
112
  "throttled",
66
113
  "rate_limit_error",
67
114
  "ratelimiterror",
115
+ # Server errors
116
+ "server error",
117
+ "service unavailable",
118
+ "bad gateway",
119
+ "gateway timeout",
120
+ "internal server error",
121
+ "502",
122
+ "503",
123
+ "504",
124
+ "500",
125
+ # Network connectivity errors
126
+ "connection timeout",
127
+ "connection timed out",
128
+ "read timeout",
129
+ "timeout error",
130
+ "timed out",
131
+ "connection reset",
132
+ "connection refused",
133
+ "connection aborted",
134
+ "connection error",
135
+ "network error",
136
+ "network unreachable",
137
+ "network is unreachable",
138
+ "no route to host",
139
+ "temporary failure",
140
+ "name resolution failed",
141
+ "dns",
142
+ "resolver",
143
+ # SSL/TLS transient errors
144
+ "ssl error",
145
+ "certificate verify failed",
146
+ "handshake timeout",
147
+ # Common transient exception types
148
+ "connectionerror",
149
+ "timeouterror",
150
+ "connecttimeout",
151
+ "readtimeout",
152
+ "httperror",
153
+ "requestexception",
68
154
  ]
69
155
 
70
- return any(indicator in exception_str for indicator in rate_limit_indicators)
156
+ # Check both exception message and type name
157
+ return any(indicator in exception_str for indicator in transient_error_indicators) or any(
158
+ indicator in exception_type for indicator in transient_error_indicators
159
+ )
160
+
161
+
162
+ def is_http_status_retriable(
163
+ status_code: int,
164
+ retry_policy: dict[int, HTTPRetryBehavior] | None = None,
165
+ ) -> bool:
166
+ """
167
+ Determine if an HTTP status code should be retried.
168
+
169
+ Args:
170
+ status_code: HTTP status code
171
+ retry_policy: Custom retry behavior policy (uses default if None)
172
+
173
+ Returns:
174
+ True if the status code should be retried
175
+ """
176
+ if retry_policy is None:
177
+ retry_policy = DEFAULT_HTTP_RETRY_MAP
178
+
179
+ behavior = retry_policy.get(status_code)
180
+
181
+ if behavior == HTTPRetryBehavior.FULL:
182
+ return True
183
+ elif behavior == HTTPRetryBehavior.CONSERVATIVE:
184
+ return True # Conservative retries are enabled by default
185
+ elif behavior == HTTPRetryBehavior.NEVER:
186
+ return False
187
+
188
+ # Unknown status code: use heuristics
189
+ if 500 <= status_code <= 599:
190
+ # Server errors are generally retriable
191
+ return True
192
+ elif status_code == 429:
193
+ # Rate limiting is always retriable
194
+ return True
195
+ elif 400 <= status_code <= 499:
196
+ # Client errors are generally not retriable, except for specific cases
197
+ return False
198
+
199
+ # Default to not retriable for unknown codes
200
+ return False
71
201
 
72
202
 
73
203
  @dataclass(frozen=True)
@@ -92,19 +222,74 @@ class RetrySettings:
92
222
  """Exponential backoff multiplier"""
93
223
 
94
224
  is_retriable: Callable[[Exception], bool] = default_is_retriable
95
- """Function to determine if an exception should be retried"""
225
+ """Function to determine if non-HTTP exceptions should be retried (network errors, timeouts, etc.)"""
226
+
227
+ http_retry_policy: dict[int, HTTPRetryBehavior] | None = None
228
+ """Custom HTTP status code retry behavior policy (None = use defaults)"""
229
+
230
+ def should_retry(self, exception: Exception) -> bool:
231
+ """
232
+ Determine if an exception should be retried.
233
+
234
+ First checks for HTTP status codes and uses http_retry_policy if present.
235
+ For non-HTTP exceptions, uses the is_retriable function to determine
236
+ if other exception types (network errors, timeouts, etc.) should be retried.
237
+ """
238
+ # First check if this is an HTTP exception with a status code
239
+ status_code = extract_http_status_code(exception)
240
+ if status_code:
241
+ retry_policy = (
242
+ self.http_retry_policy
243
+ if self.http_retry_policy is not None
244
+ else DEFAULT_HTTP_RETRY_MAP
245
+ )
246
+ return is_http_status_retriable(status_code, retry_policy)
247
+
248
+ # Not an HTTP error - use is_retriable for other exception types
249
+ # (network errors, timeouts, connection issues, etc.)
250
+ return self.is_retriable(exception)
96
251
 
97
252
 
98
253
  DEFAULT_RETRIES = RetrySettings(
99
- max_task_retries=10,
100
- max_total_retries=100,
254
+ max_task_retries=15,
255
+ max_total_retries=1000,
101
256
  initial_backoff=1.0,
102
- max_backoff=128.0,
103
- backoff_factor=2.0,
257
+ max_backoff=60.0,
258
+ backoff_factor=1.5,
104
259
  is_retriable=default_is_retriable,
105
260
  )
106
261
  """Reasonable default retry settings with both per-task and global limits."""
107
262
 
263
+ # Conservative retry settings use a custom retry policy that excludes conservative retries
264
+ _CONSERVATIVE_HTTP_RETRY_POLICY = {
265
+ # Fully retriable: server errors and explicit rate limiting
266
+ 429: HTTPRetryBehavior.FULL,
267
+ 500: HTTPRetryBehavior.FULL,
268
+ 502: HTTPRetryBehavior.FULL,
269
+ 503: HTTPRetryBehavior.FULL,
270
+ 504: HTTPRetryBehavior.FULL,
271
+ # Conservative codes become NEVER for conservative mode
272
+ 403: HTTPRetryBehavior.NEVER,
273
+ 408: HTTPRetryBehavior.NEVER,
274
+ # Never retriable: client errors
275
+ 400: HTTPRetryBehavior.NEVER,
276
+ 401: HTTPRetryBehavior.NEVER,
277
+ 404: HTTPRetryBehavior.NEVER,
278
+ 405: HTTPRetryBehavior.NEVER,
279
+ 410: HTTPRetryBehavior.NEVER,
280
+ 422: HTTPRetryBehavior.NEVER,
281
+ }
282
+
283
+ CONSERVATIVE_RETRIES = RetrySettings(
284
+ max_task_retries=5,
285
+ max_total_retries=50,
286
+ initial_backoff=2.0,
287
+ max_backoff=60.0,
288
+ backoff_factor=2.5,
289
+ http_retry_policy=_CONSERVATIVE_HTTP_RETRY_POLICY,
290
+ )
291
+ """Conservative retry settings - fewer retries, longer backoff, no conservative HTTP retries."""
292
+
108
293
 
109
294
  NO_RETRIES = RetrySettings(
110
295
  max_task_retries=0,
@@ -190,9 +375,97 @@ def calculate_backoff(
190
375
  ## Tests
191
376
 
192
377
 
378
+ def test_extract_http_status_code():
379
+ """Test HTTP status code extraction from various exception types."""
380
+
381
+ class MockHTTPXResponse:
382
+ def __init__(self, status_code):
383
+ self.status_code = status_code
384
+
385
+ class MockHTTPXException(Exception):
386
+ def __init__(self, status_code):
387
+ self.response = MockHTTPXResponse(status_code)
388
+ super().__init__(f"HTTP {status_code} error")
389
+
390
+ class MockAioHTTPException(Exception):
391
+ def __init__(self, status):
392
+ self.status = status
393
+ super().__init__(f"HTTP {status} error")
394
+
395
+ # Test httpx-style exceptions
396
+ assert extract_http_status_code(MockHTTPXException(403)) == 403
397
+ assert extract_http_status_code(MockHTTPXException(429)) == 429
398
+
399
+ # Test aiohttp-style exceptions
400
+ assert extract_http_status_code(MockAioHTTPException(500)) == 500
401
+
402
+ # Test string parsing fallback
403
+ assert extract_http_status_code(Exception("Client error '403 Forbidden'")) == 403
404
+ assert extract_http_status_code(Exception("HTTP 429 Too Many Requests")) == 429
405
+ assert extract_http_status_code(Exception("500 error occurred")) == 500
406
+
407
+ # Test no status code
408
+ assert extract_http_status_code(Exception("Network error")) is None
409
+
410
+
411
+ def test_is_http_status_retriable():
412
+ """Test HTTP status code retry logic."""
413
+
414
+ # Fully retriable
415
+ assert is_http_status_retriable(429) # Too Many Requests
416
+ assert is_http_status_retriable(500) # Internal Server Error
417
+ assert is_http_status_retriable(502) # Bad Gateway
418
+ assert is_http_status_retriable(503) # Service Unavailable
419
+ assert is_http_status_retriable(504) # Gateway Timeout
420
+
421
+ # Conservative retriable (enabled by default)
422
+ assert is_http_status_retriable(403) # Forbidden
423
+ assert is_http_status_retriable(408) # Request Timeout
424
+
425
+ # Conservative retriable with custom conservative policy (disabled)
426
+ assert not is_http_status_retriable(403, _CONSERVATIVE_HTTP_RETRY_POLICY)
427
+ assert not is_http_status_retriable(408, _CONSERVATIVE_HTTP_RETRY_POLICY)
428
+
429
+ # Never retriable
430
+ assert not is_http_status_retriable(400) # Bad Request
431
+ assert not is_http_status_retriable(401) # Unauthorized
432
+ assert not is_http_status_retriable(404) # Not Found
433
+ assert not is_http_status_retriable(410) # Gone
434
+
435
+ # Unknown status codes - use heuristics
436
+ assert is_http_status_retriable(599) # Unknown 5xx - retriable
437
+ assert not is_http_status_retriable(499) # Unknown 4xx - not retriable
438
+ assert not is_http_status_retriable(299) # Unknown 2xx - not retriable
439
+
440
+
441
+ def test_default_is_retriable_with_http():
442
+ """Test enhanced default_is_retriable with HTTP status code awareness."""
443
+
444
+ class MockHTTPXResponse:
445
+ def __init__(self, status_code):
446
+ self.status_code = status_code
447
+
448
+ class MockHTTPXException(Exception):
449
+ def __init__(self, status_code):
450
+ self.response = MockHTTPXResponse(status_code)
451
+ super().__init__(f"HTTP {status_code} error")
452
+
453
+ # Test HTTP exceptions with known status codes
454
+ assert default_is_retriable(MockHTTPXException(429)) # Rate limit - retriable
455
+ assert default_is_retriable(MockHTTPXException(500)) # Server error - retriable
456
+ assert default_is_retriable(MockHTTPXException(403)) # Conditional - retriable by default
457
+ assert not default_is_retriable(MockHTTPXException(404)) # Not found - not retriable
458
+ assert not default_is_retriable(MockHTTPXException(401)) # Unauthorized - not retriable
459
+
460
+ # Test string-based fallback still works
461
+ assert default_is_retriable(Exception("Rate limit exceeded"))
462
+ assert default_is_retriable(Exception("503 Service Unavailable"))
463
+ assert not default_is_retriable(Exception("Authentication failed"))
464
+
465
+
193
466
  def test_default_is_retriable():
194
- """Test string-based rate limit detection."""
195
- # Positive cases
467
+ """Test string-based transient error detection."""
468
+ # Rate limiting cases
196
469
  assert default_is_retriable(Exception("Rate limit exceeded"))
197
470
  assert default_is_retriable(Exception("Too many requests"))
198
471
  assert default_is_retriable(Exception("HTTP 429 error"))
@@ -200,10 +473,30 @@ def test_default_is_retriable():
200
473
  assert default_is_retriable(Exception("throttled"))
201
474
  assert default_is_retriable(Exception("RateLimitError"))
202
475
 
203
- # Negative cases
476
+ # Network connectivity cases
477
+ assert default_is_retriable(Exception("Network error"))
478
+ assert default_is_retriable(Exception("Connection timeout"))
479
+ assert default_is_retriable(Exception("Connection timed out"))
480
+ assert default_is_retriable(Exception("Connection refused"))
481
+ assert default_is_retriable(Exception("Network unreachable"))
482
+ assert default_is_retriable(Exception("DNS resolution failed"))
483
+ assert default_is_retriable(Exception("SSL error"))
484
+
485
+ # Exception type-based detection
486
+ class ConnectionError(Exception):
487
+ pass
488
+
489
+ class TimeoutError(Exception):
490
+ pass
491
+
492
+ assert default_is_retriable(ConnectionError("Some connection issue"))
493
+ assert default_is_retriable(TimeoutError("Operation timed out"))
494
+
495
+ # Non-retriable cases
204
496
  assert not default_is_retriable(Exception("Authentication failed"))
205
497
  assert not default_is_retriable(Exception("Invalid API key"))
206
- assert not default_is_retriable(Exception("Network error"))
498
+ assert not default_is_retriable(Exception("Permission denied"))
499
+ assert not default_is_retriable(Exception("File not found"))
207
500
 
208
501
 
209
502
  def test_default_is_retriable_litellm():
@@ -303,3 +596,44 @@ def test_calculate_backoff():
303
596
  backoff_factor=2.0,
304
597
  )
305
598
  assert high_backoff <= 5.0
599
+
600
+
601
+ def test_retry_settings_should_retry():
602
+ """Test RetrySettings.should_retry method with custom HTTP maps."""
603
+
604
+ class MockHTTPXResponse:
605
+ def __init__(self, status_code):
606
+ self.status_code = status_code
607
+
608
+ class MockHTTPXException(Exception):
609
+ def __init__(self, status_code):
610
+ self.response = MockHTTPXResponse(status_code)
611
+ super().__init__(f"HTTP {status_code} error")
612
+
613
+ # Test with default settings (conservative retries enabled)
614
+ default_settings = RetrySettings(max_task_retries=3)
615
+ assert default_settings.should_retry(MockHTTPXException(429)) # Rate limit - retriable
616
+ assert default_settings.should_retry(MockHTTPXException(500)) # Server error - retriable
617
+ assert default_settings.should_retry(
618
+ MockHTTPXException(403)
619
+ ) # Conservative - retriable by default
620
+ assert not default_settings.should_retry(MockHTTPXException(404)) # Not found - not retriable
621
+
622
+ # Test with conservative settings (conservative retries disabled)
623
+ conservative_settings = CONSERVATIVE_RETRIES
624
+ assert conservative_settings.should_retry(
625
+ MockHTTPXException(429)
626
+ ) # Rate limit - still retriable
627
+ assert conservative_settings.should_retry(
628
+ MockHTTPXException(500)
629
+ ) # Server error - still retriable
630
+ assert not conservative_settings.should_retry(
631
+ MockHTTPXException(403)
632
+ ) # Conservative - now not retriable
633
+ assert not conservative_settings.should_retry(
634
+ MockHTTPXException(404)
635
+ ) # Not found - still not retriable
636
+
637
+ # Test with non-HTTP exception
638
+ assert default_settings.should_retry(Exception("Network error"))
639
+ assert not default_settings.should_retry(Exception("Authentication failed"))