kash-shell 0.3.21__py3-none-any.whl → 0.3.23__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.
- kash/actions/core/markdownify_html.py +11 -0
- kash/actions/core/tabbed_webpage_generate.py +2 -2
- kash/commands/help/assistant_commands.py +2 -4
- kash/commands/help/logo.py +12 -17
- kash/commands/help/welcome.py +5 -4
- kash/docs/markdown/warning.md +3 -3
- kash/docs/markdown/welcome.md +2 -1
- kash/exec/fetch_url_items.py +23 -13
- kash/exec/preconditions.py +7 -2
- kash/file_storage/file_store.py +3 -3
- kash/model/items_model.py +14 -11
- kash/shell/output/shell_output.py +8 -4
- kash/utils/api_utils/api_retries.py +335 -9
- kash/utils/api_utils/gather_limited.py +204 -488
- kash/utils/text_handling/markdown_utils.py +158 -1
- kash/web_content/web_extract.py +1 -1
- kash/web_gen/tabbed_webpage.py +2 -2
- kash/xonsh_custom/load_into_xonsh.py +0 -3
- {kash_shell-0.3.21.dist-info → kash_shell-0.3.23.dist-info}/METADATA +1 -1
- {kash_shell-0.3.21.dist-info → kash_shell-0.3.23.dist-info}/RECORD +23 -23
- {kash_shell-0.3.21.dist-info → kash_shell-0.3.23.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.21.dist-info → kash_shell-0.3.23.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.21.dist-info → kash_shell-0.3.23.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,6 +3,41 @@ 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
|
+
|
|
9
|
+
class HTTPRetryBehavior(Enum):
|
|
10
|
+
"""HTTP status code retry behavior classification."""
|
|
11
|
+
|
|
12
|
+
FULL = "full"
|
|
13
|
+
"""Fully retry these status codes (e.g., 429, 500, 502, 503, 504)"""
|
|
14
|
+
|
|
15
|
+
CONSERVATIVE = "conservative"
|
|
16
|
+
"""Retry conservatively: may indicate rate limiting or temporary issues (e.g., 403, 408)"""
|
|
17
|
+
|
|
18
|
+
NEVER = "never"
|
|
19
|
+
"""Never retry these status codes (e.g., 400, 401, 404, 410)"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Default HTTP status code retry classifications
|
|
23
|
+
DEFAULT_HTTP_RETRY_MAP: dict[int, HTTPRetryBehavior] = {
|
|
24
|
+
# Fully retriable: server errors and explicit rate limiting
|
|
25
|
+
429: HTTPRetryBehavior.FULL, # Too Many Requests
|
|
26
|
+
500: HTTPRetryBehavior.FULL, # Internal Server Error
|
|
27
|
+
502: HTTPRetryBehavior.FULL, # Bad Gateway
|
|
28
|
+
503: HTTPRetryBehavior.FULL, # Service Unavailable
|
|
29
|
+
504: HTTPRetryBehavior.FULL, # Gateway Timeout
|
|
30
|
+
# Conservatively retriable: might be temporary
|
|
31
|
+
403: HTTPRetryBehavior.CONSERVATIVE, # Forbidden (could be rate limiting)
|
|
32
|
+
408: HTTPRetryBehavior.CONSERVATIVE, # Request Timeout
|
|
33
|
+
# Never retriable: client errors
|
|
34
|
+
400: HTTPRetryBehavior.NEVER, # Bad Request
|
|
35
|
+
401: HTTPRetryBehavior.NEVER, # Unauthorized
|
|
36
|
+
404: HTTPRetryBehavior.NEVER, # Not Found
|
|
37
|
+
405: HTTPRetryBehavior.NEVER, # Method Not Allowed
|
|
38
|
+
410: HTTPRetryBehavior.NEVER, # Gone
|
|
39
|
+
422: HTTPRetryBehavior.NEVER, # Unprocessable Entity
|
|
40
|
+
}
|
|
6
41
|
|
|
7
42
|
|
|
8
43
|
class RetryException(RuntimeError):
|
|
@@ -27,9 +62,54 @@ class RetryExhaustedException(RetryException):
|
|
|
27
62
|
)
|
|
28
63
|
|
|
29
64
|
|
|
65
|
+
def extract_http_status_code(exception: Exception) -> int | None:
|
|
66
|
+
"""
|
|
67
|
+
Extract HTTP status code from various exception types.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
exception: The exception to extract status code from
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
HTTP status code or None if not found
|
|
74
|
+
"""
|
|
75
|
+
# Check for httpx.HTTPStatusError and requests.HTTPError
|
|
76
|
+
if hasattr(exception, "response"):
|
|
77
|
+
response = getattr(exception, "response", None)
|
|
78
|
+
if response and hasattr(response, "status_code"):
|
|
79
|
+
return getattr(response, "status_code", None)
|
|
80
|
+
|
|
81
|
+
# Check for aiohttp errors
|
|
82
|
+
if hasattr(exception, "status"):
|
|
83
|
+
return getattr(exception, "status", None)
|
|
84
|
+
|
|
85
|
+
# Parse from exception message as fallback
|
|
86
|
+
exception_str = str(exception)
|
|
87
|
+
|
|
88
|
+
# Try to find status code patterns in the message
|
|
89
|
+
import re
|
|
90
|
+
|
|
91
|
+
# Pattern for "403 Forbidden", "HTTP 429", etc.
|
|
92
|
+
status_patterns = [
|
|
93
|
+
r"\b(\d{3})\s+(?:Forbidden|Unauthorized|Not Found|Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b",
|
|
94
|
+
r"\bHTTP\s+(\d{3})\b",
|
|
95
|
+
r"\b(\d{3})\s+error\b",
|
|
96
|
+
r"status\s*(?:code)?:\s*(\d{3})\b",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
for pattern in status_patterns:
|
|
100
|
+
match = re.search(pattern, exception_str, re.IGNORECASE)
|
|
101
|
+
if match:
|
|
102
|
+
try:
|
|
103
|
+
return int(match.group(1))
|
|
104
|
+
except (ValueError, IndexError):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
30
110
|
def default_is_retriable(exception: Exception) -> bool:
|
|
31
111
|
"""
|
|
32
|
-
Default retriable exception checker
|
|
112
|
+
Default retriable exception checker with HTTP status code awareness.
|
|
33
113
|
|
|
34
114
|
Args:
|
|
35
115
|
exception: The exception to check
|
|
@@ -51,12 +131,22 @@ def default_is_retriable(exception: Exception) -> bool:
|
|
|
51
131
|
):
|
|
52
132
|
return True
|
|
53
133
|
except ImportError:
|
|
54
|
-
# LiteLLM not available, fall back to
|
|
134
|
+
# LiteLLM not available, fall back to other detection methods
|
|
55
135
|
pass
|
|
56
136
|
|
|
57
|
-
#
|
|
137
|
+
# Try to extract HTTP status code for more precise handling
|
|
138
|
+
status_code = extract_http_status_code(exception)
|
|
139
|
+
if status_code is not None:
|
|
140
|
+
return is_http_status_retriable(status_code, DEFAULT_HTTP_RETRY_MAP)
|
|
141
|
+
|
|
142
|
+
# Fallback to string-based detection for transient errors
|
|
58
143
|
exception_str = str(exception).lower()
|
|
59
|
-
|
|
144
|
+
|
|
145
|
+
# Check exception type names for common transient network errors
|
|
146
|
+
exception_type = type(exception).__name__.lower()
|
|
147
|
+
|
|
148
|
+
transient_error_indicators = [
|
|
149
|
+
# Rate limiting and quota errors
|
|
60
150
|
"rate limit",
|
|
61
151
|
"too many requests",
|
|
62
152
|
"try again later",
|
|
@@ -65,9 +155,92 @@ def default_is_retriable(exception: Exception) -> bool:
|
|
|
65
155
|
"throttled",
|
|
66
156
|
"rate_limit_error",
|
|
67
157
|
"ratelimiterror",
|
|
158
|
+
# Server errors
|
|
159
|
+
"server error",
|
|
160
|
+
"service unavailable",
|
|
161
|
+
"bad gateway",
|
|
162
|
+
"gateway timeout",
|
|
163
|
+
"internal server error",
|
|
164
|
+
"502",
|
|
165
|
+
"503",
|
|
166
|
+
"504",
|
|
167
|
+
"500",
|
|
168
|
+
# Network connectivity errors
|
|
169
|
+
"connection timeout",
|
|
170
|
+
"connection timed out",
|
|
171
|
+
"read timeout",
|
|
172
|
+
"timeout error",
|
|
173
|
+
"timed out",
|
|
174
|
+
"connection reset",
|
|
175
|
+
"connection refused",
|
|
176
|
+
"connection aborted",
|
|
177
|
+
"connection error",
|
|
178
|
+
"network error",
|
|
179
|
+
"network unreachable",
|
|
180
|
+
"network is unreachable",
|
|
181
|
+
"no route to host",
|
|
182
|
+
"temporary failure",
|
|
183
|
+
"name resolution failed",
|
|
184
|
+
"dns",
|
|
185
|
+
"resolver",
|
|
186
|
+
# SSL/TLS transient errors
|
|
187
|
+
"ssl error",
|
|
188
|
+
"certificate verify failed",
|
|
189
|
+
"handshake timeout",
|
|
190
|
+
# Common transient exception types
|
|
191
|
+
"connectionerror",
|
|
192
|
+
"timeouterror",
|
|
193
|
+
"connecttimeout",
|
|
194
|
+
"readtimeout",
|
|
195
|
+
"httperror",
|
|
196
|
+
"requestexception",
|
|
68
197
|
]
|
|
69
198
|
|
|
70
|
-
|
|
199
|
+
# Check both exception message and type name
|
|
200
|
+
return any(indicator in exception_str for indicator in transient_error_indicators) or any(
|
|
201
|
+
indicator in exception_type for indicator in transient_error_indicators
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_http_status_retriable(
|
|
206
|
+
status_code: int,
|
|
207
|
+
retry_map: dict[int, HTTPRetryBehavior] | None = None,
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Determine if an HTTP status code should be retried.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
status_code: HTTP status code
|
|
214
|
+
retry_map: Custom retry behavior map (uses default if None)
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True if the status code should be retried
|
|
218
|
+
"""
|
|
219
|
+
if retry_map is None:
|
|
220
|
+
retry_map = DEFAULT_HTTP_RETRY_MAP
|
|
221
|
+
|
|
222
|
+
behavior = retry_map.get(status_code)
|
|
223
|
+
|
|
224
|
+
if behavior == HTTPRetryBehavior.FULL:
|
|
225
|
+
return True
|
|
226
|
+
elif behavior == HTTPRetryBehavior.CONSERVATIVE:
|
|
227
|
+
return True # Conservative retries are enabled by default
|
|
228
|
+
elif behavior == HTTPRetryBehavior.NEVER:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# Unknown status code: use heuristics
|
|
232
|
+
if 500 <= status_code <= 599:
|
|
233
|
+
# Server errors are generally retriable
|
|
234
|
+
return True
|
|
235
|
+
elif status_code == 429:
|
|
236
|
+
# Rate limiting is always retriable
|
|
237
|
+
return True
|
|
238
|
+
elif 400 <= status_code <= 499:
|
|
239
|
+
# Client errors are generally not retriable, except for specific cases
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# Default to not retriable for unknown codes
|
|
243
|
+
return False
|
|
71
244
|
|
|
72
245
|
|
|
73
246
|
@dataclass(frozen=True)
|
|
@@ -94,6 +267,9 @@ class RetrySettings:
|
|
|
94
267
|
is_retriable: Callable[[Exception], bool] = default_is_retriable
|
|
95
268
|
"""Function to determine if an exception should be retried"""
|
|
96
269
|
|
|
270
|
+
http_retry_map: dict[int, HTTPRetryBehavior] | None = None
|
|
271
|
+
"""Custom HTTP status code retry behavior (None = use defaults)"""
|
|
272
|
+
|
|
97
273
|
|
|
98
274
|
DEFAULT_RETRIES = RetrySettings(
|
|
99
275
|
max_task_retries=10,
|
|
@@ -106,6 +282,48 @@ DEFAULT_RETRIES = RetrySettings(
|
|
|
106
282
|
"""Reasonable default retry settings with both per-task and global limits."""
|
|
107
283
|
|
|
108
284
|
|
|
285
|
+
# Preset configurations for different use cases
|
|
286
|
+
AGGRESSIVE_RETRIES = RetrySettings(
|
|
287
|
+
max_task_retries=15,
|
|
288
|
+
max_total_retries=200,
|
|
289
|
+
initial_backoff=0.5,
|
|
290
|
+
max_backoff=64.0,
|
|
291
|
+
backoff_factor=1.8,
|
|
292
|
+
)
|
|
293
|
+
"""Aggressive retry settings - retry more often with shorter initial backoff."""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Conservative retry settings use a custom retry map that excludes conservative retries
|
|
297
|
+
_CONSERVATIVE_HTTP_RETRY_MAP = {
|
|
298
|
+
# Fully retriable: server errors and explicit rate limiting
|
|
299
|
+
429: HTTPRetryBehavior.FULL,
|
|
300
|
+
500: HTTPRetryBehavior.FULL,
|
|
301
|
+
502: HTTPRetryBehavior.FULL,
|
|
302
|
+
503: HTTPRetryBehavior.FULL,
|
|
303
|
+
504: HTTPRetryBehavior.FULL,
|
|
304
|
+
# Conservative codes become NEVER for conservative mode
|
|
305
|
+
403: HTTPRetryBehavior.NEVER,
|
|
306
|
+
408: HTTPRetryBehavior.NEVER,
|
|
307
|
+
# Never retriable: client errors
|
|
308
|
+
400: HTTPRetryBehavior.NEVER,
|
|
309
|
+
401: HTTPRetryBehavior.NEVER,
|
|
310
|
+
404: HTTPRetryBehavior.NEVER,
|
|
311
|
+
405: HTTPRetryBehavior.NEVER,
|
|
312
|
+
410: HTTPRetryBehavior.NEVER,
|
|
313
|
+
422: HTTPRetryBehavior.NEVER,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
CONSERVATIVE_RETRIES = RetrySettings(
|
|
317
|
+
max_task_retries=5,
|
|
318
|
+
max_total_retries=50,
|
|
319
|
+
initial_backoff=2.0,
|
|
320
|
+
max_backoff=60.0,
|
|
321
|
+
backoff_factor=2.5,
|
|
322
|
+
http_retry_map=_CONSERVATIVE_HTTP_RETRY_MAP,
|
|
323
|
+
)
|
|
324
|
+
"""Conservative retry settings - fewer retries, longer backoff, no conservative HTTP retries."""
|
|
325
|
+
|
|
326
|
+
|
|
109
327
|
NO_RETRIES = RetrySettings(
|
|
110
328
|
max_task_retries=0,
|
|
111
329
|
max_total_retries=0,
|
|
@@ -190,9 +408,97 @@ def calculate_backoff(
|
|
|
190
408
|
## Tests
|
|
191
409
|
|
|
192
410
|
|
|
411
|
+
def test_extract_http_status_code():
|
|
412
|
+
"""Test HTTP status code extraction from various exception types."""
|
|
413
|
+
|
|
414
|
+
class MockHTTPXResponse:
|
|
415
|
+
def __init__(self, status_code):
|
|
416
|
+
self.status_code = status_code
|
|
417
|
+
|
|
418
|
+
class MockHTTPXException(Exception):
|
|
419
|
+
def __init__(self, status_code):
|
|
420
|
+
self.response = MockHTTPXResponse(status_code)
|
|
421
|
+
super().__init__(f"HTTP {status_code} error")
|
|
422
|
+
|
|
423
|
+
class MockAioHTTPException(Exception):
|
|
424
|
+
def __init__(self, status):
|
|
425
|
+
self.status = status
|
|
426
|
+
super().__init__(f"HTTP {status} error")
|
|
427
|
+
|
|
428
|
+
# Test httpx-style exceptions
|
|
429
|
+
assert extract_http_status_code(MockHTTPXException(403)) == 403
|
|
430
|
+
assert extract_http_status_code(MockHTTPXException(429)) == 429
|
|
431
|
+
|
|
432
|
+
# Test aiohttp-style exceptions
|
|
433
|
+
assert extract_http_status_code(MockAioHTTPException(500)) == 500
|
|
434
|
+
|
|
435
|
+
# Test string parsing fallback
|
|
436
|
+
assert extract_http_status_code(Exception("Client error '403 Forbidden'")) == 403
|
|
437
|
+
assert extract_http_status_code(Exception("HTTP 429 Too Many Requests")) == 429
|
|
438
|
+
assert extract_http_status_code(Exception("500 error occurred")) == 500
|
|
439
|
+
|
|
440
|
+
# Test no status code
|
|
441
|
+
assert extract_http_status_code(Exception("Network error")) is None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_is_http_status_retriable():
|
|
445
|
+
"""Test HTTP status code retry logic."""
|
|
446
|
+
|
|
447
|
+
# Fully retriable
|
|
448
|
+
assert is_http_status_retriable(429) # Too Many Requests
|
|
449
|
+
assert is_http_status_retriable(500) # Internal Server Error
|
|
450
|
+
assert is_http_status_retriable(502) # Bad Gateway
|
|
451
|
+
assert is_http_status_retriable(503) # Service Unavailable
|
|
452
|
+
assert is_http_status_retriable(504) # Gateway Timeout
|
|
453
|
+
|
|
454
|
+
# Conservative retriable (enabled by default)
|
|
455
|
+
assert is_http_status_retriable(403) # Forbidden
|
|
456
|
+
assert is_http_status_retriable(408) # Request Timeout
|
|
457
|
+
|
|
458
|
+
# Conservative retriable with custom conservative map (disabled)
|
|
459
|
+
assert not is_http_status_retriable(403, _CONSERVATIVE_HTTP_RETRY_MAP)
|
|
460
|
+
assert not is_http_status_retriable(408, _CONSERVATIVE_HTTP_RETRY_MAP)
|
|
461
|
+
|
|
462
|
+
# Never retriable
|
|
463
|
+
assert not is_http_status_retriable(400) # Bad Request
|
|
464
|
+
assert not is_http_status_retriable(401) # Unauthorized
|
|
465
|
+
assert not is_http_status_retriable(404) # Not Found
|
|
466
|
+
assert not is_http_status_retriable(410) # Gone
|
|
467
|
+
|
|
468
|
+
# Unknown status codes - use heuristics
|
|
469
|
+
assert is_http_status_retriable(599) # Unknown 5xx - retriable
|
|
470
|
+
assert not is_http_status_retriable(499) # Unknown 4xx - not retriable
|
|
471
|
+
assert not is_http_status_retriable(299) # Unknown 2xx - not retriable
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_default_is_retriable_with_http():
|
|
475
|
+
"""Test enhanced default_is_retriable with HTTP status code awareness."""
|
|
476
|
+
|
|
477
|
+
class MockHTTPXResponse:
|
|
478
|
+
def __init__(self, status_code):
|
|
479
|
+
self.status_code = status_code
|
|
480
|
+
|
|
481
|
+
class MockHTTPXException(Exception):
|
|
482
|
+
def __init__(self, status_code):
|
|
483
|
+
self.response = MockHTTPXResponse(status_code)
|
|
484
|
+
super().__init__(f"HTTP {status_code} error")
|
|
485
|
+
|
|
486
|
+
# Test HTTP exceptions with known status codes
|
|
487
|
+
assert default_is_retriable(MockHTTPXException(429)) # Rate limit - retriable
|
|
488
|
+
assert default_is_retriable(MockHTTPXException(500)) # Server error - retriable
|
|
489
|
+
assert default_is_retriable(MockHTTPXException(403)) # Conditional - retriable by default
|
|
490
|
+
assert not default_is_retriable(MockHTTPXException(404)) # Not found - not retriable
|
|
491
|
+
assert not default_is_retriable(MockHTTPXException(401)) # Unauthorized - not retriable
|
|
492
|
+
|
|
493
|
+
# Test string-based fallback still works
|
|
494
|
+
assert default_is_retriable(Exception("Rate limit exceeded"))
|
|
495
|
+
assert default_is_retriable(Exception("503 Service Unavailable"))
|
|
496
|
+
assert not default_is_retriable(Exception("Authentication failed"))
|
|
497
|
+
|
|
498
|
+
|
|
193
499
|
def test_default_is_retriable():
|
|
194
|
-
"""Test string-based
|
|
195
|
-
#
|
|
500
|
+
"""Test string-based transient error detection."""
|
|
501
|
+
# Rate limiting cases
|
|
196
502
|
assert default_is_retriable(Exception("Rate limit exceeded"))
|
|
197
503
|
assert default_is_retriable(Exception("Too many requests"))
|
|
198
504
|
assert default_is_retriable(Exception("HTTP 429 error"))
|
|
@@ -200,10 +506,30 @@ def test_default_is_retriable():
|
|
|
200
506
|
assert default_is_retriable(Exception("throttled"))
|
|
201
507
|
assert default_is_retriable(Exception("RateLimitError"))
|
|
202
508
|
|
|
203
|
-
#
|
|
509
|
+
# Network connectivity cases
|
|
510
|
+
assert default_is_retriable(Exception("Network error"))
|
|
511
|
+
assert default_is_retriable(Exception("Connection timeout"))
|
|
512
|
+
assert default_is_retriable(Exception("Connection timed out"))
|
|
513
|
+
assert default_is_retriable(Exception("Connection refused"))
|
|
514
|
+
assert default_is_retriable(Exception("Network unreachable"))
|
|
515
|
+
assert default_is_retriable(Exception("DNS resolution failed"))
|
|
516
|
+
assert default_is_retriable(Exception("SSL error"))
|
|
517
|
+
|
|
518
|
+
# Exception type-based detection
|
|
519
|
+
class ConnectionError(Exception):
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
class TimeoutError(Exception):
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
assert default_is_retriable(ConnectionError("Some connection issue"))
|
|
526
|
+
assert default_is_retriable(TimeoutError("Operation timed out"))
|
|
527
|
+
|
|
528
|
+
# Non-retriable cases
|
|
204
529
|
assert not default_is_retriable(Exception("Authentication failed"))
|
|
205
530
|
assert not default_is_retriable(Exception("Invalid API key"))
|
|
206
|
-
assert not default_is_retriable(Exception("
|
|
531
|
+
assert not default_is_retriable(Exception("Permission denied"))
|
|
532
|
+
assert not default_is_retriable(Exception("File not found"))
|
|
207
533
|
|
|
208
534
|
|
|
209
535
|
def test_default_is_retriable_litellm():
|