better rate limit forwarding

This commit is contained in:
9qeklajc
2026-06-20 20:03:40 +02:00
parent c0a34a391f
commit f408785409
9 changed files with 790 additions and 42 deletions
+17 -2
View File
@@ -7,11 +7,26 @@ logger = get_logger(__name__)
class UpstreamError(Exception): class UpstreamError(Exception):
"""Exception raised when an upstream provider fails.""" """Exception raised when an upstream provider fails.
def __init__(self, message: str, status_code: int = 502): ``code`` carries a stable, machine-readable classification (e.g.
``UPSTREAM_RATE_LIMIT``) so callers can distinguish failure kinds without
string-matching the message. ``details`` holds optional structured,
redaction-safe context. Both default to ``None`` for backwards
compatibility.
"""
def __init__(
self,
message: str,
status_code: int = 502,
code: str | None = None,
details: dict[str, object] | None = None,
):
self.message = message self.message = message
self.status_code = status_code self.status_code = status_code
self.code = code
self.details = details
super().__init__(message) super().__init__(message)
+44
View File
@@ -51,6 +51,8 @@ from pythonjsonlogger import jsonlogger
from rich.console import Console from rich.console import Console
from rich.logging import RichHandler from rich.logging import RichHandler
from .redaction import redact_obj, redact_org_ids
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts # Only use RichHandler when stdout is a real TTY. In non-TTY contexts
# (docker logs, pipes, CI) Rich pads every line to width and wraps long # (docker logs, pipes, CI) Rich pads every line to width and wraps long
# records, producing visually-empty trailing whitespace and split records. # records, producing visually-empty trailing whitespace and split records.
@@ -180,6 +182,37 @@ class RequestIdFilter(logging.Filter):
return True return True
# Standard ``LogRecord`` attributes that are never user-supplied ``extra``
# fields; skipped when redacting structured extras (``msg``/``message`` are
# handled separately above).
_NON_EXTRA_RECORD_ATTRS = frozenset(
{
"name",
"msg",
"args",
"levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"taskName",
"message",
}
)
class SecurityFilter(logging.Filter): class SecurityFilter(logging.Filter):
"""Filter to remove sensitive information from logs.""" """Filter to remove sensitive information from logs."""
@@ -203,6 +236,7 @@ class SecurityFilter(logging.Filter):
"""Filter out sensitive information from log records.""" """Filter out sensitive information from log records."""
try: try:
message = record.getMessage() message = record.getMessage()
message = redact_org_ids(message)
standalone_patterns = [ standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives) r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
@@ -224,6 +258,16 @@ class SecurityFilter(logging.Filter):
record.msg = message record.msg = message
record.args = () record.args = ()
# Structured `extra={...}` fields are emitted by the JSON formatter
# straight from the record dict and never pass through the message
# formatting above. Redact organization IDs from any string-valued
# extra so they cannot leak via structured logs.
for attr, value in list(record.__dict__.items()):
if attr in _NON_EXTRA_RECORD_ATTRS:
continue
if isinstance(value, (str, dict, list, tuple)):
record.__dict__[attr] = redact_obj(value)
except Exception: except Exception:
pass pass
+51
View File
@@ -0,0 +1,51 @@
"""Redaction helpers for sensitive provider identifiers.
Single source of truth for stripping account-scoped identifiers (e.g. OpenAI
organization IDs) from any text before it is logged, returned to a caller, or
written to an audit entry.
"""
from __future__ import annotations
import re
from typing import Any
# OpenAI-style organization identifiers look like ``org-<base62>``. Require at
# least 6 trailing chars so the already-redacted literal ``org-[REDACTED]`` is
# never re-matched (``[`` is not in the character class).
_ORG_ID_PATTERN = re.compile(r"\borg-[A-Za-z0-9]{6,}\b")
ORG_ID_PLACEHOLDER = "org-[REDACTED]"
def redact_org_ids(text: str) -> str:
"""Replace OpenAI-style organization IDs with ``org-[REDACTED]``.
Args:
text: Arbitrary text that may embed an ``org-*`` identifier.
Returns:
The text with every organization ID replaced. Non-string input is
returned unchanged after coercion to ``str``.
"""
if not text:
return text
return _ORG_ID_PATTERN.sub(ORG_ID_PLACEHOLDER, text)
def redact_obj(obj: Any) -> Any:
"""Recursively redact organization IDs in arbitrary nested structures.
Strings are redacted in place; dicts and lists/tuples are walked so that
identifiers nested inside structured payloads (e.g. log ``extra`` fields or
error ``details``) are also stripped. Other types are returned unchanged.
"""
if isinstance(obj, str):
return redact_org_ids(obj)
if isinstance(obj, dict):
return {key: redact_obj(value) for key, value in obj.items()}
if isinstance(obj, list):
return [redact_obj(value) for value in obj]
if isinstance(obj, tuple):
return tuple(redact_obj(value) for value in obj)
return obj
+36 -6
View File
@@ -11,6 +11,8 @@ from PIL import Image
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..core import get_logger from ..core import get_logger
from ..core.exceptions import UpstreamError
from ..core.redaction import redact_org_ids
from ..core.settings import settings from ..core.settings import settings
from ..wallet import deserialize_token_from_string from ..wallet import deserialize_token_from_string
@@ -418,16 +420,27 @@ def create_error_response(
status_code: int, status_code: int,
request: Request, request: Request,
token: str | None = None, token: str | None = None,
code: str | int | None = None,
details: dict[str, object] | None = None,
) -> Response: ) -> Response:
"""Create a standardized error response.""" """Create a standardized error response.
``code`` is a stable, machine-readable classification (e.g.
``UPSTREAM_RATE_LIMIT``); when omitted it defaults to the HTTP status code
for backwards compatibility. ``details`` carries optional structured,
redaction-safe context.
"""
error_obj: dict[str, object] = {
"message": redact_org_ids(message),
"type": error_type,
"code": code if code is not None else status_code,
}
if details is not None:
error_obj["details"] = details
return Response( return Response(
content=json.dumps( content=json.dumps(
{ {
"error": { "error": error_obj,
"message": message,
"type": error_type,
"code": status_code,
},
"request_id": getattr(request.state, "request_id", "unknown"), "request_id": getattr(request.state, "request_id", "unknown"),
} }
), ),
@@ -435,3 +448,20 @@ def create_error_response(
media_type="application/json", media_type="application/json",
headers={"X-Cashu": token} if token else {}, headers={"X-Cashu": token} if token else {},
) )
def create_upstream_error_response(
error: UpstreamError,
request: Request,
fallback_status: int = 502,
) -> Response:
"""Build an error response from an :class:`UpstreamError`, preserving its
structured ``code``, ``details``, and original ``status_code``."""
return create_error_response(
"upstream_error",
str(error),
error.status_code or fallback_status,
request=request,
code=getattr(error, "code", None),
details=getattr(error, "details", None),
)
+7 -13
View File
@@ -24,6 +24,7 @@ from .payment.helpers import (
calculate_discounted_max_cost, calculate_discounted_max_cost,
check_token_balance, check_token_balance,
create_error_response, create_error_response,
create_upstream_error_response,
get_max_cost_for_model, get_max_cost_for_model,
) )
from .payment.models import Model from .payment.models import Model
@@ -211,9 +212,7 @@ async def proxy(
e, e,
) )
if i == len(all_upstreams) - 1: if i == len(all_upstreams) - 1:
last_error_response = create_error_response( last_error_response = create_upstream_error_response(e, request)
"upstream_error", str(e), 502, request=request
)
continue continue
return last_error_response or create_error_response( return last_error_response or create_error_response(
"upstream_error", "All upstreams failed", 502, request=request "upstream_error", "All upstreams failed", 502, request=request
@@ -283,11 +282,10 @@ async def proxy(
last_error = e last_error = e
continue continue
if last_error is not None:
return create_upstream_error_response(last_error, request)
return create_error_response( return create_error_response(
"upstream_error", "upstream_error", "All upstreams failed", 502, request=request
str(last_error) if last_error else "All upstreams failed",
502,
request=request,
) )
elif auth := headers.get("authorization", None): elif auth := headers.get("authorization", None):
@@ -343,9 +341,7 @@ async def proxy(
except UpstreamError as e: except UpstreamError as e:
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}") logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
if i == len(upstreams) - 1: if i == len(upstreams) - 1:
last_error_response = create_error_response( last_error_response = create_upstream_error_response(e, request)
"upstream_error", str(e), 502, request=request
)
continue continue
return last_error_response or create_error_response( return last_error_response or create_error_response(
"upstream_error", "All upstreams failed", 502, request=request "upstream_error", "All upstreams failed", 502, request=request
@@ -525,9 +521,7 @@ async def proxy(
# If this was the last provider # If this was the last provider
if i == len(upstreams) - 1: if i == len(upstreams) - 1:
await revert_pay_for_request(key, session, max_cost_for_model) await revert_pay_for_request(key, session, max_cost_for_model)
return create_error_response( return create_upstream_error_response(e, request)
"upstream_error", str(e), 502, request=request
)
# Otherwise loop continues to next provider # Otherwise loop continues to next provider
continue continue
+85 -17
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import math
import traceback import traceback
import uuid import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Iterator from collections.abc import AsyncGenerator, AsyncIterator, Iterator
@@ -23,6 +24,7 @@ from ..core.db import (
store_cashu_transaction, store_cashu_transaction,
) )
from ..core.exceptions import UpstreamError from ..core.exceptions import UpstreamError
from ..core.redaction import redact_org_ids
from ..payment.cost_calculation import ( from ..payment.cost_calculation import (
CostData, CostData,
CostDataError, CostDataError,
@@ -47,6 +49,7 @@ from .cache_breakpoints import (
) )
from .count_tokens import count_tokens_locally from .count_tokens import count_tokens_locally
from .litellm_routing import detect_litellm_prefix from .litellm_routing import detect_litellm_prefix
from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -582,7 +585,7 @@ class BaseUpstreamProvider:
preview = body_bytes.decode("utf-8", errors="ignore").strip() preview = body_bytes.decode("utf-8", errors="ignore").strip()
if preview: if preview:
message = preview[:500] message = preview[:500]
return message, upstream_code return redact_org_ids(message), upstream_code
async def on_upstream_error_redirect( async def on_upstream_error_redirect(
self, status_code: int, error_message: str self, status_code: int, error_message: str
@@ -625,10 +628,23 @@ class BaseUpstreamProvider:
body_bytes = b"" body_bytes = b""
body_read_error = f"{type(exc).__name__}: {exc}" body_read_error = f"{type(exc).__name__}: {exc}"
# ``message`` is already redacted by ``_extract_upstream_error_message``;
# the raw body preview is redacted here before it reaches logs or the
# forwarded envelope so provider account identifiers never leak.
message, upstream_code = self._extract_upstream_error_message(body_bytes) message, upstream_code = self._extract_upstream_error_message(body_bytes)
body_preview = body_bytes.decode("utf-8", errors="ignore").strip()[:500] body_preview = redact_org_ids(
body_bytes.decode("utf-8", errors="ignore").strip()[:500]
)
is_json_body = _is_json_content_type(content_type) is_json_body = _is_json_content_type(content_type)
# Classify upstream rate-limit failures into a stable, structured error.
rate_limit = classify_rate_limit(status_code, message, headers)
error_code: str | int = upstream_code or status_code
error_details: dict[str, object] | None = None
if rate_limit is not None:
error_code = UPSTREAM_RATE_LIMIT
error_details = rate_limit.as_details()
logger.warning( logger.warning(
"Upstream %s returned %s for model=%s path=%s: %s", "Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type, self.provider_type,
@@ -642,6 +658,7 @@ class BaseUpstreamProvider:
"model": model_id or "unknown", "model": model_id or "unknown",
"upstream_status": status_code, "upstream_status": status_code,
"upstream_code": upstream_code, "upstream_code": upstream_code,
"error_code": error_code,
"upstream_content_type": content_type, "upstream_content_type": content_type,
"upstream_request_id": upstream_request_id, "upstream_request_id": upstream_request_id,
"message_preview": message[:200], "message_preview": message[:200],
@@ -676,13 +693,41 @@ class BaseUpstreamProvider:
): ):
headers.pop(header_name, None) headers.pop(header_name, None)
# Propagate a usable retry hint to the caller when the upstream supplied
# one but did not echo a ``Retry-After`` header. RFC 7231 delta-seconds
# is an integer, so round sub-second hints up to a usable ``1``.
if (
rate_limit is not None
and rate_limit.retry_after_seconds is not None
and "retry-after" not in {k.lower() for k in headers}
):
headers["Retry-After"] = str(max(1, math.ceil(rate_limit.retry_after_seconds)))
if is_json_body: if is_json_body:
if not content_type: if not content_type:
headers.pop("content-type", None) headers.pop("content-type", None)
headers.pop("Content-Type", None) headers.pop("Content-Type", None)
media_type = content_type or None media_type = content_type or None
# Re-serialise the body with organization IDs stripped. The narrow
# ``org-*`` regex preserves the surrounding JSON structure.
redacted_text = redact_org_ids(body_bytes.decode("utf-8", errors="ignore"))
redacted_body = redacted_text.encode()
# Surface the stable rate-limit classification on the forwarded
# body so callers can switch on ``error.code`` without parsing the
# provider-specific message. Fall back to the redacted bytes if the
# body is not a JSON object with an ``error`` mapping.
if rate_limit is not None:
try:
parsed = json.loads(redacted_text)
err = parsed.get("error") if isinstance(parsed, dict) else None
if isinstance(err, dict):
err["code"] = UPSTREAM_RATE_LIMIT
err["details"] = error_details
redacted_body = json.dumps(parsed).encode()
except (ValueError, AttributeError):
pass
return Response( return Response(
content=body_bytes, content=redacted_body,
status_code=status_code, status_code=status_code,
headers=headers, headers=headers,
media_type=media_type, media_type=media_type,
@@ -693,15 +738,18 @@ class BaseUpstreamProvider:
for header_name in ("content-type", "Content-Type"): for header_name in ("content-type", "Content-Type"):
headers.pop(header_name, None) headers.pop(header_name, None)
error_obj: dict[str, object] = {
"message": message or "Upstream returned a non-JSON error response",
"type": "upstream_error",
"code": error_code,
"upstream_status": status_code,
"upstream_content_type": content_type or None,
"upstream_body_preview": body_preview or None,
}
if error_details is not None:
error_obj["details"] = error_details
envelope = { envelope = {
"error": { "error": error_obj,
"message": message or "Upstream returned a non-JSON error response",
"type": "upstream_error",
"code": upstream_code or status_code,
"upstream_status": status_code,
"upstream_content_type": content_type or None,
"upstream_body_preview": body_preview or None,
},
"request_id": getattr(request.state, "request_id", None), "request_id": getattr(request.state, "request_id", None),
} }
@@ -2494,9 +2542,16 @@ class BaseUpstreamProvider:
body_bytes = await response.aread() body_bytes = await response.aread()
except Exception: except Exception:
body_bytes = b"" body_bytes = b""
body_preview = body_bytes.decode( # Redact provider account identifiers before the body text
"utf-8", errors="ignore" # reaches logs or the raised error.
).strip()[:500] body_preview = redact_org_ids(
body_bytes.decode("utf-8", errors="ignore").strip()[:500]
)
rate_limit = classify_rate_limit(
response.status_code,
body_preview,
dict(response.headers),
)
logger.error( logger.error(
"Upstream %s returned %s for model=%s path=%s: %s", "Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type, self.provider_type,
@@ -2508,6 +2563,7 @@ class BaseUpstreamProvider:
"provider": self.provider_type, "provider": self.provider_type,
"model": original_model_id or "unknown", "model": original_model_id or "unknown",
"status_code": response.status_code, "status_code": response.status_code,
"error_code": rate_limit.code if rate_limit else None,
"reason_phrase": response.reason_phrase, "reason_phrase": response.reason_phrase,
"path": path, "path": path,
"body_preview": body_preview, "body_preview": body_preview,
@@ -2520,6 +2576,8 @@ class BaseUpstreamProvider:
f"for model {original_model_id or 'unknown'}: " f"for model {original_model_id or 'unknown'}: "
f"{body_preview[:200] or '<empty>'}", f"{body_preview[:200] or '<empty>'}",
status_code=response.status_code, status_code=response.status_code,
code=rate_limit.code if rate_limit else None,
details=rate_limit.as_details() if rate_limit else None,
) )
try: try:
@@ -2816,9 +2874,16 @@ class BaseUpstreamProvider:
body_bytes = await response.aread() body_bytes = await response.aread()
except Exception: except Exception:
body_bytes = b"" body_bytes = b""
body_preview = body_bytes.decode( # Redact provider account identifiers before the body text
"utf-8", errors="ignore" # reaches logs or the raised error.
).strip()[:500] body_preview = redact_org_ids(
body_bytes.decode("utf-8", errors="ignore").strip()[:500]
)
rate_limit = classify_rate_limit(
response.status_code,
body_preview,
dict(response.headers),
)
logger.error( logger.error(
"Upstream %s returned %s for model=%s path=%s: %s", "Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type, self.provider_type,
@@ -2830,6 +2895,7 @@ class BaseUpstreamProvider:
"provider": self.provider_type, "provider": self.provider_type,
"model": original_model_id or "unknown", "model": original_model_id or "unknown",
"status_code": response.status_code, "status_code": response.status_code,
"error_code": rate_limit.code if rate_limit else None,
"path": path, "path": path,
"body_preview": body_preview, "body_preview": body_preview,
}, },
@@ -2841,6 +2907,8 @@ class BaseUpstreamProvider:
f"for model {original_model_id or 'unknown'}: " f"for model {original_model_id or 'unknown'}: "
f"{body_preview[:200] or '<empty>'}", f"{body_preview[:200] or '<empty>'}",
status_code=response.status_code, status_code=response.status_code,
code=rate_limit.code if rate_limit else None,
details=rate_limit.as_details() if rate_limit else None,
) )
try: try:
+18 -4
View File
@@ -29,7 +29,9 @@ import litellm
from ..core import get_logger from ..core import get_logger
from ..core.exceptions import UpstreamError from ..core.exceptions import UpstreamError
from ..core.redaction import redact_org_ids
from ..payment.models import Model from ..payment.models import Model
from .rate_limit import classify_rate_limit
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -505,23 +507,33 @@ async def dispatch_anthropic_messages(
try: try:
result = await litellm.anthropic.messages.acreate(**kwargs) result = await litellm.anthropic.messages.acreate(**kwargs)
except Exception as exc: except Exception as exc:
exc_message = getattr(exc, "message", None) or str(exc) or repr(exc) raw_message = getattr(exc, "message", None) or str(exc) or repr(exc)
# Redact provider account identifiers before the message reaches logs
# or the surfaced error.
exc_message = redact_org_ids(raw_message)
exc_status = getattr(exc, "status_code", None) exc_status = getattr(exc, "status_code", None)
exc_response = getattr(exc, "response", None) exc_response = getattr(exc, "response", None)
response_text = None response_text = None
if exc_response is not None: if exc_response is not None:
try: try:
response_text = getattr(exc_response, "text", str(exc_response)) response_text = redact_org_ids(
getattr(exc_response, "text", str(exc_response))
)
except Exception: except Exception:
response_text = "<unreadable>" response_text = "<unreadable>"
status_for_classify = exc_status if isinstance(exc_status, int) else 502
rate_limit = classify_rate_limit(
status_for_classify, exc_message, getattr(exc, "headers", None)
)
logger.error( logger.error(
"litellm dispatch failed", "litellm dispatch failed",
extra={ extra={
"error": exc_message, "error": exc_message,
"error_type": type(exc).__name__, "error_type": type(exc).__name__,
"status_code": exc_status, "status_code": exc_status,
"error_code": rate_limit.code if rate_limit else None,
"llm_provider": getattr(exc, "llm_provider", None), "llm_provider": getattr(exc, "llm_provider", None),
"body": getattr(exc, "body", None), "body": redact_org_ids(str(getattr(exc, "body", "") or "")) or None,
"response_text": response_text, "response_text": response_text,
"model": litellm_model, "model": litellm_model,
"api_base": base_url, "api_base": base_url,
@@ -529,7 +541,9 @@ async def dispatch_anthropic_messages(
) )
raise UpstreamError( raise UpstreamError(
f"Upstream error via litellm: {exc_message}", f"Upstream error via litellm: {exc_message}",
status_code=exc_status if isinstance(exc_status, int) else 502, status_code=status_for_classify,
code=rate_limit.code if rate_limit else None,
details=rate_limit.as_details() if rate_limit else None,
) from exc ) from exc
if not client_stream and hasattr(result, "__aiter__"): if not client_stream and hasattr(result, "__aiter__"):
+136
View File
@@ -0,0 +1,136 @@
"""Detection and parsing of upstream provider rate-limit errors.
Upstream OpenAI-compatible providers signal rate limits via HTTP 429 and/or a
human-readable message such as::
Rate limit reached for gpt-5.5-2026-04-23 (for limit gpt-5.5) in organization
org-XXXX on tokens per min (TPM): Limit 180000000, Used 180000000,
Requested 8929. Please try again in 2ms.
This module classifies those failures into a stable :data:`UPSTREAM_RATE_LIMIT`
code and extracts useful debugging fields. All retained text is redacted of
organization IDs first.
"""
from __future__ import annotations
import re
from dataclasses import asdict, dataclass
from ..core.redaction import redact_org_ids
# Stable error code callers can switch on to distinguish upstream rate limits
# from generic request failures. The literal value matches the identifier named
# in issue #555 ("UPSTREAM_RATE_LIMIT") so the public API contract is exact.
UPSTREAM_RATE_LIMIT = "UPSTREAM_RATE_LIMIT"
# Message fragments that indicate a rate-limit even when the status code is not
# 429 (some providers wrap it in a 400/500 envelope).
_RATE_LIMIT_MARKERS = (
"rate limit reached",
"rate_limit_exceeded",
"rate limit exceeded",
"too many requests",
)
_MODEL_RE = re.compile(r"Rate limit reached for ([^\s(]+)", re.IGNORECASE)
_LIMIT_NAME_RE = re.compile(r"\(for limit ([^)]+)\)", re.IGNORECASE)
_METRIC_RE = re.compile(r"on ([a-z ]+\((?:TPM|RPM|TPD|RPD|IPM)\))", re.IGNORECASE)
_LIMIT_RE = re.compile(r"Limit (\d+)", re.IGNORECASE)
_USED_RE = re.compile(r"Used (\d+)", re.IGNORECASE)
_REQUESTED_RE = re.compile(r"Requested (\d+)", re.IGNORECASE)
_RETRY_RE = re.compile(r"try again in ([\d.]+)\s*(ms|s)", re.IGNORECASE)
@dataclass
class RateLimitInfo:
"""Structured, redaction-safe view of an upstream rate-limit error."""
code: str
message: str
model: str | None = None
limit_name: str | None = None
metric: str | None = None
limit: int | None = None
used: int | None = None
requested: int | None = None
retry_after_seconds: float | None = None
def as_details(self) -> dict[str, object]:
"""Return a JSON-serialisable dict for embedding in an error envelope."""
return {k: v for k, v in asdict(self).items() if v is not None}
def _looks_like_rate_limit(status_code: int, message: str) -> bool:
if status_code == 429:
return True
lowered = message.lower()
return any(marker in lowered for marker in _RATE_LIMIT_MARKERS)
def _parse_retry_after_header(headers: dict[str, str] | None) -> float | None:
"""Parse a ``Retry-After`` header (delta-seconds form) into seconds."""
if not headers:
return None
raw = headers.get("retry-after") or headers.get("Retry-After")
if raw is None:
return None
try:
return float(str(raw).strip())
except (TypeError, ValueError):
return None
def _int_or_none(match: re.Match[str] | None) -> int | None:
if match is None:
return None
try:
return int(match.group(1))
except (TypeError, ValueError):
return None
def classify_rate_limit(
status_code: int,
message: str,
headers: dict[str, str] | None = None,
) -> RateLimitInfo | None:
"""Classify an upstream error as a rate-limit and extract its fields.
Args:
status_code: HTTP status code from the upstream response.
message: Upstream error message (may contain sensitive identifiers).
headers: Optional upstream response headers, used for ``Retry-After``.
Returns:
A :class:`RateLimitInfo` when the error is a rate-limit, else ``None``.
"""
message = message or ""
if not _looks_like_rate_limit(status_code, message):
return None
redacted = redact_org_ids(message)
model_match = _MODEL_RE.search(redacted)
metric_match = _METRIC_RE.search(redacted)
retry_after = _parse_retry_after_header(headers)
if retry_after is None:
retry_match = _RETRY_RE.search(redacted)
if retry_match is not None:
value = float(retry_match.group(1))
retry_after = value / 1000.0 if retry_match.group(2).lower() == "ms" else value
limit_name_match = _LIMIT_NAME_RE.search(redacted)
return RateLimitInfo(
code=UPSTREAM_RATE_LIMIT,
message=redacted,
model=model_match.group(1) if model_match else None,
limit_name=limit_name_match.group(1).strip() if limit_name_match else None,
metric=metric_match.group(1).strip() if metric_match else None,
limit=_int_or_none(_LIMIT_RE.search(redacted)),
used=_int_or_none(_USED_RE.search(redacted)),
requested=_int_or_none(_REQUESTED_RE.search(redacted)),
retry_after_seconds=retry_after,
)
+396
View File
@@ -0,0 +1,396 @@
"""Tests for upstream rate-limit detection, classification, and org-ID redaction.
Covers issue #555: upstream OpenAI-compatible providers return rate-limit
errors that embed a sensitive organization ID. The proxy must classify these
distinctly (``UPSTREAM_RATE_LIMIT``), preserve useful debugging fields, and
never emit a raw ``org-*`` identifier in logs, errors, or returned bodies.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
from routstr.core.redaction import redact_org_ids
from routstr.upstream.base import BaseUpstreamProvider
from routstr.upstream.rate_limit import (
UPSTREAM_RATE_LIMIT,
RateLimitInfo,
classify_rate_limit,
)
# The exact scenario from the issue, with a realistic (fake) org identifier.
RAW_ORG_ID = "org-abc123XYZ456def"
RATE_LIMIT_MESSAGE = (
f"Rate limit reached for gpt-5.5-2026-04-23 (for limit gpt-5.5) in "
f"organization {RAW_ORG_ID} on tokens per min (TPM): Limit 180000000, "
f"Used 180000000, Requested 8929. Please try again in 2ms. Visit "
f"https://platform.openai.com/account/rate-limits to learn more."
)
def _make_request(request_id: str = "req-123") -> Mock:
request = Mock(spec=["method", "state"])
request.method = "POST"
request.state = Mock()
request.state.request_id = request_id
return request
def _make_upstream_response(
*,
body: bytes,
status_code: int = 429,
content_type: str | None = "application/json",
extra_headers: dict[str, str] | None = None,
) -> httpx.Response:
headers: dict[str, str] = {}
if content_type is not None:
headers["content-type"] = content_type
if extra_headers:
headers.update(extra_headers)
return httpx.Response(status_code=status_code, headers=headers, content=body)
@pytest.fixture
def provider() -> BaseUpstreamProvider:
return BaseUpstreamProvider(
base_url="https://privateprovider.xyz", api_key="k", provider_fee=1.0
)
# --------------------------------------------------------------------------- #
# Redaction
# --------------------------------------------------------------------------- #
def test_redact_org_ids_replaces_identifier() -> None:
assert RAW_ORG_ID not in redact_org_ids(RATE_LIMIT_MESSAGE)
assert "org-[REDACTED]" in redact_org_ids(RATE_LIMIT_MESSAGE)
def test_redact_org_ids_is_idempotent() -> None:
once = redact_org_ids(RATE_LIMIT_MESSAGE)
assert redact_org_ids(once) == once
def test_redact_org_ids_leaves_unrelated_text() -> None:
assert redact_org_ids("organize the org-chart") == "organize the org-chart"
assert redact_org_ids("") == ""
# --------------------------------------------------------------------------- #
# Classification
# --------------------------------------------------------------------------- #
def test_classify_exact_scenario() -> None:
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE)
assert isinstance(info, RateLimitInfo)
assert info.code == UPSTREAM_RATE_LIMIT
assert info.model == "gpt-5.5-2026-04-23"
assert info.limit_name == "gpt-5.5"
assert info.metric == "tokens per min (TPM)"
assert info.limit == 180000000
assert info.used == 180000000
assert info.requested == 8929
assert info.retry_after_seconds == pytest.approx(0.002)
# Redaction-safe: no raw org id survives into the structured view.
assert RAW_ORG_ID not in info.message
assert RAW_ORG_ID not in json.dumps(info.as_details())
def test_classify_by_status_code_without_marker() -> None:
info = classify_rate_limit(429, "slow down")
assert info is not None
assert info.code == UPSTREAM_RATE_LIMIT
def test_classify_by_message_marker_without_429() -> None:
info = classify_rate_limit(400, "rate_limit_exceeded for this key")
assert info is not None
def test_retry_after_header_takes_precedence() -> None:
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE, {"Retry-After": "12"})
assert info is not None
assert info.retry_after_seconds == pytest.approx(12.0)
def test_non_rate_limit_error_is_not_classified() -> None:
assert classify_rate_limit(400, "invalid request: missing field 'model'") is None
assert classify_rate_limit(500, "internal server error") is None
# --------------------------------------------------------------------------- #
# forward_upstream_error_response integration
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_json_rate_limit_body_is_redacted_and_forwarded(
provider: BaseUpstreamProvider,
) -> None:
body = json.dumps(
{"error": {"message": RATE_LIMIT_MESSAGE, "type": "rate_limit_exceeded"}}
).encode()
upstream = _make_upstream_response(body=body, status_code=429)
response = await provider.forward_upstream_error_response(
_make_request(), "v1/chat/completions", upstream
)
assert response.status_code == 429
raw = bytes(response.body).decode()
# No raw organization id may survive in the forwarded body.
assert RAW_ORG_ID not in raw
assert "org-[REDACTED]" in raw
# Body remains valid JSON; the original type is preserved while a stable
# rate-limit code is injected so callers can switch on it.
payload: dict[str, Any] = json.loads(raw)
assert payload["error"]["type"] == "rate_limit_exceeded"
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
assert payload["error"]["details"]["model"] == "gpt-5.5-2026-04-23"
# A retry hint extracted from the message is surfaced as a header.
assert "retry-after" in {k.lower() for k in response.headers}
@pytest.mark.asyncio
async def test_non_json_rate_limit_envelope_uses_stable_code(
provider: BaseUpstreamProvider,
) -> None:
upstream = _make_upstream_response(
body=RATE_LIMIT_MESSAGE.encode(),
status_code=429,
content_type="text/plain",
)
response = await provider.forward_upstream_error_response(
_make_request(), "v1/chat/completions", upstream
)
payload: dict[str, Any] = json.loads(bytes(response.body))
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
assert payload["error"]["details"]["model"] == "gpt-5.5-2026-04-23"
serialized = json.dumps(payload)
assert RAW_ORG_ID not in serialized
assert "org-[REDACTED]" in serialized
@pytest.mark.asyncio
async def test_non_rate_limit_json_error_unchanged(
provider: BaseUpstreamProvider,
) -> None:
body = json.dumps(
{"error": {"message": "missing field 'model'", "type": "invalid_request"}}
).encode()
upstream = _make_upstream_response(body=body, status_code=400)
response = await provider.forward_upstream_error_response(
_make_request(), "v1/chat/completions", upstream
)
assert response.status_code == 400
payload: dict[str, Any] = json.loads(bytes(response.body))
assert payload["error"]["type"] == "invalid_request"
assert "retry-after" not in {k.lower() for k in response.headers}
# --------------------------------------------------------------------------- #
# UpstreamError -> proxy response (preserves code/details/status)
# --------------------------------------------------------------------------- #
def test_create_upstream_error_response_preserves_structure() -> None:
from routstr.core.exceptions import UpstreamError
from routstr.payment.helpers import create_upstream_error_response
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE)
assert info is not None
err = UpstreamError(
f"Upstream error via litellm: {RATE_LIMIT_MESSAGE}",
status_code=429,
code=info.code,
details=info.as_details(),
)
response = create_upstream_error_response(err, _make_request())
# Original upstream status is preserved (not flattened to 502).
assert response.status_code == 429
payload: dict[str, Any] = json.loads(bytes(response.body))
assert payload["error"]["type"] == "upstream_error"
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
assert payload["error"]["details"]["requested"] == 8929
serialized = json.dumps(payload)
assert RAW_ORG_ID not in serialized
assert "org-[REDACTED]" in serialized
def test_generic_upstream_error_still_defaults_to_502() -> None:
from routstr.core.exceptions import UpstreamError
from routstr.payment.helpers import create_upstream_error_response
err = UpstreamError("connection refused") # status_code defaults to 502
response = create_upstream_error_response(err, _make_request())
assert response.status_code == 502
payload: dict[str, Any] = json.loads(bytes(response.body))
assert payload["error"]["type"] == "upstream_error"
assert payload["error"]["code"] == 502
assert "details" not in payload["error"]
# --------------------------------------------------------------------------- #
# Structured log-extra redaction
# --------------------------------------------------------------------------- #
def test_security_filter_redacts_org_id_in_extra() -> None:
import logging
from routstr.core.logging import SecurityFilter
record = logging.LogRecord(
name="test",
level=logging.ERROR,
pathname=__file__,
lineno=1,
msg="upstream failed",
args=(),
exc_info=None,
)
# Simulate an ``extra={"body_preview": ...}`` field carrying an org id.
setattr(record, "body_preview", RATE_LIMIT_MESSAGE)
assert SecurityFilter().filter(record) is True
redacted: str = getattr(record, "body_preview")
assert RAW_ORG_ID not in redacted
assert "org-[REDACTED]" in redacted
def test_security_filter_redacts_org_id_in_nested_extra() -> None:
import logging
from routstr.core.logging import SecurityFilter
record = logging.LogRecord(
name="test",
level=logging.ERROR,
pathname=__file__,
lineno=1,
msg="upstream failed",
args=(),
exc_info=None,
)
# Nested structures: dict containing a list containing the org id.
setattr(record, "body", {"error": {"messages": [RATE_LIMIT_MESSAGE]}})
assert SecurityFilter().filter(record) is True
serialized = json.dumps(getattr(record, "body"))
assert RAW_ORG_ID not in serialized
assert "org-[REDACTED]" in serialized
# --------------------------------------------------------------------------- #
# 5xx-wrapped rate limit through forward_upstream_error_response
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_5xx_wrapped_rate_limit_is_classified(
provider: BaseUpstreamProvider,
) -> None:
# Some providers wrap a rate-limit in a 5xx envelope; classification must
# key off the message marker, not only the 429 status.
body = json.dumps({"error": {"message": RATE_LIMIT_MESSAGE}}).encode()
upstream = _make_upstream_response(body=body, status_code=500)
response = await provider.forward_upstream_error_response(
_make_request(), "v1/chat/completions", upstream
)
assert response.status_code == 500
payload: dict[str, Any] = json.loads(bytes(response.body))
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
serialized = json.dumps(payload)
assert RAW_ORG_ID not in serialized
assert "org-[REDACTED]" in serialized
# --------------------------------------------------------------------------- #
# Real proxy loop: structured error surfaced + reservation reverted once
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_proxy_loop_surfaces_rate_limit_and_reverts_once() -> None:
from routstr import proxy as proxy_module
from routstr.core.db import ApiKey
from routstr.core.exceptions import UpstreamError
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE)
assert info is not None
key = ApiKey(hashed_key="rlkey", balance=10_000)
request = MagicMock()
request.method = "POST"
request.headers = {"authorization": "Bearer sk-rlkey"}
request.body = AsyncMock(return_value=b'{"model": "test-model"}')
request.state = MagicMock()
request.state.request_id = "req-rl"
upstream = MagicMock()
upstream.provider_type = "test"
upstream.prepare_headers = MagicMock(side_effect=lambda h: h)
upstream.forward_request = AsyncMock(
side_effect=UpstreamError(
f"Upstream error via litellm: {RATE_LIMIT_MESSAGE}",
status_code=429,
code=info.code,
details=info.as_details(),
)
)
session = MagicMock()
revert_mock = AsyncMock(return_value=True)
with (
patch.object(proxy_module, "get_model_instance", return_value=MagicMock()),
patch.object(proxy_module, "get_provider_for_model", return_value=[upstream]),
patch.object(
proxy_module, "get_max_cost_for_model", AsyncMock(return_value=1_000)
),
patch.object(
proxy_module,
"calculate_discounted_max_cost",
AsyncMock(return_value=1_000),
),
patch.object(proxy_module, "check_token_balance", MagicMock()),
patch.object(
proxy_module, "get_bearer_token_key", AsyncMock(return_value=key)
),
patch.object(proxy_module, "pay_for_request", AsyncMock(return_value=1_000)),
patch.object(proxy_module, "revert_pay_for_request", revert_mock),
):
response = await proxy_module.proxy(
request, "v1/chat/completions", session=session
)
# Original 429 status and the stable code/details survive to the client.
assert response.status_code == 429
payload: dict[str, Any] = json.loads(bytes(response.body))
assert payload["error"]["type"] == "upstream_error"
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
assert payload["error"]["details"]["requested"] == 8929
serialized = json.dumps(payload)
assert RAW_ORG_ID not in serialized
assert "org-[REDACTED]" in serialized
# Single upstream failed -> reservation reverted exactly once (no double-charge).
revert_mock.assert_awaited_once_with(key, session, 1_000)