mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-23 21:12:22 +02:00
100 lines
4.0 KiB
Python
100 lines
4.0 KiB
Python
import re
|
|
|
|
# Patterns that indicate an upstream transient problem (overload / rate limit /
|
|
# infra blip), safe to silently retry with backoff. Checked against the
|
|
# stringified exception from claude_agent_sdk / Claude CLI.
|
|
_TRANSIENT_CAPACITY_PATTERNS = re.compile(
|
|
r"(?:\b(?:429|500|502|503|504|529)\b"
|
|
r"|overloaded"
|
|
r"|service\s+(?:temporarily\s+)?unavailable"
|
|
r"|at\s+capacity"
|
|
r"|try\s+again\s+shortly"
|
|
r"|internal\s+server\s+error"
|
|
r"|rate[_\s-]?limit(?:_error)?"
|
|
r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed"
|
|
r"|upstream\s+connect\s+error)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
|
|
# auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or
|
|
# trimming context is required. The long-context-required variant is what
|
|
# Anthropic returns when an OAuth Pro/Max account ships a request whose input
|
|
# exceeds the 200K standard tier and would need the "extra usage" tier; the
|
|
# user can't recover by waiting, so we surface it instead of looping.
|
|
_NON_TRANSIENT_PATTERNS = re.compile(
|
|
r"(?:usage\s+cap\s+exceeded"
|
|
r"|reached\s+your\s+OpenSwarm.*plan\s+limit"
|
|
r"|no\s+active\s+subscription"
|
|
r"|subscription\s+(?:canceled|past_due)"
|
|
r"|invalid.*token"
|
|
r"|missing\s+bearer\s+token"
|
|
r"|extra\s+usage\s+is\s+required\s+for\s+long\s+context"
|
|
r"|long\s+context\s+(?:requests?\s+)?(?:requires?|not\s+(?:available|enabled))"
|
|
r"|401|403)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
|
|
"""True when the upstream error is the 'long context tier required' 429.
|
|
|
|
Used by the catch-all error path to emit a friendly context-overflow
|
|
event instead of a generic system-error message.
|
|
"""
|
|
combined = f"{exc!s}\n{extra_text}".strip()
|
|
if not combined:
|
|
return False
|
|
return bool(re.search(
|
|
r"extra\s+usage\s+is\s+required\s+for\s+long\s+context"
|
|
r"|long\s+context\s+(?:requests?\s+)?(?:requires?|not\s+(?:available|enabled))",
|
|
combined,
|
|
re.IGNORECASE,
|
|
))
|
|
|
|
|
|
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
|
"""True when the upstream error is a 401/403 auth failure.
|
|
|
|
Used by the catch-all error path to surface a friendly "subscription
|
|
expired / reconnect" card instead of dumping the raw 401 JSON. The most
|
|
common cause: the OpenSwarm Pro bearer or 9Router OAuth token has expired
|
|
while the UI still shows the connection as 'connected'.
|
|
"""
|
|
combined = f"{exc!s}\n{extra_text}".strip()
|
|
if not combined:
|
|
return False
|
|
return bool(re.search(
|
|
r"\b(401|403)\b"
|
|
r"|invalid\s+authentication\s+credentials"
|
|
r"|invalid.*api[_\s-]?key"
|
|
r"|missing\s+bearer\s+token"
|
|
r"|unauthori[sz]ed"
|
|
r"|no\s+credentials\s+for\s+provider"
|
|
r"|provider\s+not\s+(?:configured|connected|authorized)",
|
|
combined,
|
|
re.IGNORECASE,
|
|
))
|
|
|
|
|
|
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
|
# The Claude CLI's underlying ProcessError stringifies to a generic
|
|
# "Command failed with exit code 1 / Check stderr output for details";
|
|
# the real cause (rate_limit_error / No pool capacity available / 429
|
|
# / overloaded) only surfaces in the subprocess's stderr stream, which
|
|
# we capture via the SDK's `stderr` callback and pass in as extra_text.
|
|
# Classify against both so we catch capacity errors regardless of which
|
|
# channel carried the message.
|
|
combined = f"{exc!s}\n{extra_text}".strip()
|
|
if not combined:
|
|
return False
|
|
if _NON_TRANSIENT_PATTERNS.search(combined):
|
|
return False
|
|
if _TRANSIENT_CAPACITY_PATTERNS.search(combined):
|
|
return True
|
|
# Pool-exhaustion copy from the OpenSwarm proxy ("No pool capacity
|
|
# available. Try again shortly."), matches the capacity family too.
|
|
if re.search(r"no\s+pool\s+capacity", combined, re.IGNORECASE):
|
|
return True
|
|
return False
|