fix(s3_v2): upload fresh events first, drop terminal failures and hour-old retries by default, opt-in adaptive concurrency (#43022)

* fix(s3_v2): drop terminal upload failures, bound retries per flush and enforce the queue cap at enqueue

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): keep retrying credential-rotation 403s, only AccessDenied-style errors are terminal

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(env_keys): exclude DEFAULT_S3_MAX_FLUSH_ATTEMPTS as an internal tuning var

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): retry every 5xx, warn on first queue overflow, validate the flush budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): read the queue cap defensively so un-initialized loggers still enqueue

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): drop the getattr in _enqueue and tighten the retry tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): keep the constructor flush budget when the callback override is invalid

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(s3_v2): adapt per-object upload concurrency to sink latency and throttling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(s3_v2): tidy adaptive limiter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* perf(s3_v2): wake one waiter per released upload slot

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(s3_v2): make the enqueue queue cap configurable with s3_max_queue_size

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): audit cells for cache hits, coded 403 and callback modes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): retry bucket-wide failures by default, age-budget requeues and make terminal drops and adaptive concurrency opt-in

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(s3_v2): suppress the missing-waiter ValueError explicitly in the adaptive limiter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): count oldest events trimmed after a failed flush as callback failures

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(s3_v2): move the mutable-ok marker onto the list literal it suppresses

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): report post-flush overflow drops once and grow adaptive concurrency above the floor before asserting back-off

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): fail the SlowDown back-off test when the measured window sees no PUTs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): restore base retry defaults, opt-in age budget, no enqueue cap, back off outside the limiter slot

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): hoist the default no-op upload slot to a module constant

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): drop unused mutable-ok suppressions on queue appends

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): keep the retry queue oldest-first and prioritise fresh events at upload time

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(s3_v2): keep the mutable-ok marker on the queue list literal

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): build request bodies inside the upload slot and keep the sync retry set at base parity

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): drop wall-clock sleeps from the unit tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): rebuild the request body inside the slot on every retry attempt

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): anchor the backoff window on the first observed failure and tighten shard assertions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): default the upload slot to the logger limiter so monkeypatched doubles keep working

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): clear ambient AWS env credentials so the rotating profile signs the sync retry test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): shrink the linear send-batch perf test to 2k/8k elements

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): fix stale batch sizes in the perf test assert message

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): keep async in-call retries on the base 403/500/503 set

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): hold the upload slot across retries, restore the bool upload contract, and fail safe on bool config

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): mark dropped uploads by element identity so a shared key cannot mask a retryable sibling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): make the per-flush drop lookup constant time

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): take the upload slot in the caller like base, build the body once per attempt loop

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): match base retry, logging and hook behaviour unless the new options are opted in

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): assert the wire key in the init-bypassed sync upload test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): assert the signed headers and wire key in the init-bypassed sync upload test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): default the upload limiter at class level instead of reading it with getattr

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): drop the duplicate annotations that redeclare the class-level counters

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(s3_v2): drop terminal-failed uploads by default and bound retry age to one hour

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(s3_v2): fall back to the configured retry age on invalid values

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): drive retry-age tests from a fixed clock

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(s3_v2): audit cells for retry-age opt-out and 429 single-put parity

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-26 14:58:28 -07:00 • committed by GitHub
parent 3743c8563e
commit e47b1f2a3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 3083 additions and 144 deletions

View file

@ -49,6 +49,7 @@ DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SE
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
DEFAULT_S3_BATCH_SIZE: Final = int(os.getenv("DEFAULT_S3_BATCH_SIZE", 512))
DEFAULT_S3_MAX_CONCURRENT_UPLOADS: Final = int(os.getenv("DEFAULT_S3_MAX_CONCURRENT_UPLOADS", "16"))
DEFAULT_S3_MAX_ADAPTIVE_CONCURRENCY: Final = get_env_int("DEFAULT_S3_MAX_ADAPTIVE_CONCURRENCY", 200)
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
MAX_S3_OBJECT_KEY_BYTES: Final = 1024
S3_BOUNDED_OBJECT_KEY_HEAD_BYTES: Final = 64

View file

@ -0,0 +1,78 @@
"""
Adaptive in-flight concurrency limiter (AIMD, Vector ARC style).
Grows the limit additively after `limit` consecutive clean completions and
halves it only on an explicit throttle signal (429, 503, SlowDown, or a
transport error out of the PUT). With floor == ceiling it degenerates to a
fixed-width semaphore.
"""
import asyncio
from collections import deque
from contextlib import suppress
from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True, slots=True)
class PutSample:
throttled: bool
class AdaptiveConcurrencyLimiter:
"""AIMD in-flight limiter used as `async with limiter:`."""
def __init__(self, initial: int, floor: int, ceiling: int) -> None:
if not 1 <= floor <= ceiling:
raise ValueError(f"adaptive limiter bounds must satisfy 1 <= floor <= ceiling, got {floor}..{ceiling}")
self._limit: int = min(max(initial, floor), ceiling)
self._floor: Final[int] = floor
self._ceiling: Final[int] = ceiling
self._clean_streak: int = 0
self._in_flight: int = 0
self._waiters: deque[asyncio.Future[None]] = deque() # mutable-ok: waiters queue up behind a full limit
@property
def limit(self) -> int:
return self._limit
async def __aenter__(self) -> "AdaptiveConcurrencyLimiter":
if self._in_flight < self._limit:
self._in_flight += 1
return self
waiter: Final = asyncio.get_running_loop().create_future()
self._waiters.append(waiter)
try:
await waiter
except asyncio.CancelledError:
if waiter.done() and not waiter.cancelled():
self._in_flight -= 1
self._grant()
else:
with suppress(ValueError):
self._waiters.remove(waiter)
raise
return self
def _grant(self) -> None:
while self._in_flight < self._limit and self._waiters:
waiter = self._waiters.popleft()
if waiter.done():
continue
self._in_flight += 1
waiter.set_result(None)
async def __aexit__(self, *_: object) -> None:
self._in_flight -= 1
self._grant()
def record(self, sample: PutSample) -> None:
if sample.throttled:
self._limit = max(self._floor, self._limit // 2)
self._clean_streak = 0
return
self._clean_streak += 1
if self._clean_streak >= self._limit and self._limit < self._ceiling:
self._limit += 1
self._clean_streak = 0
self._grant()

View file

@ -36,22 +36,86 @@ def resolve_s3_log_prompts_only(configured: object, environ: Mapping[str, str] |
return True
def resolve_s3_max_concurrent_uploads(configured: object, fallback: int) -> int:
def _resolve_positive_int(setting: str, configured: object, fallback: int, *, reject_bool: bool) -> int:
if configured is None or configured == "":
return fallback
if reject_bool and isinstance(configured, bool):
verbose_logger.warning(
"s3 logging: %s=%r is a boolean, not an integer, using %s", setting, configured, fallback
)
return fallback
try:
bound: Final = _UPLOAD_BOUND.validate_python(configured.strip() if isinstance(configured, str) else configured)
except ValidationError:
verbose_logger.warning("s3 logging: %s=%r is not an integer, using %s", setting, configured, fallback)
return fallback
if bound < 1:
verbose_logger.warning("s3 logging: %s=%r must be at least 1, using %s", setting, configured, fallback)
return fallback
return bound
def resolve_s3_max_concurrent_uploads(configured: object, fallback: int) -> int:
return _resolve_positive_int("s3_max_concurrent_uploads", configured, fallback, reject_bool=False)
def resolve_s3_max_queue_size(configured: object, fallback: int) -> int:
return _resolve_positive_int("s3_max_queue_size", configured, fallback, reject_bool=True)
def resolve_s3_max_retry_age_seconds(configured: object, fallback: int | None) -> int | None:
if configured is None or configured == "":
return None
if isinstance(configured, bool):
verbose_logger.warning(
"s3 logging: s3_max_retry_age_seconds=%r is a boolean, not an integer, falling back to %r",
configured,
fallback,
)
return fallback
try:
bound: Final = _UPLOAD_BOUND.validate_python(configured.strip() if isinstance(configured, str) else configured)
except ValidationError:
verbose_logger.warning(
"s3 logging: s3_max_concurrent_uploads=%r is not an integer, using %s", configured, fallback
"s3 logging: s3_max_retry_age_seconds=%r is not an integer, falling back to %r", configured, fallback
)
return fallback
if bound < 1:
if bound < 0:
verbose_logger.warning(
"s3 logging: s3_max_concurrent_uploads=%r must be at least 1, using %s", configured, fallback
"s3 logging: s3_max_retry_age_seconds=%r must be at least 0, falling back to %r", configured, fallback
)
return fallback
return bound
return bound or None
def resolve_s3_max_adaptive_concurrency(configured: object, fallback: int) -> int:
return _resolve_positive_int("s3_max_adaptive_concurrency", configured, fallback, reject_bool=True)
def resolve_s3_drop_on_terminal_error(configured: object) -> bool:
if configured is None or configured == "":
return True
try:
return _S3_BOOL.validate_python(configured.strip() if isinstance(configured, str) else configured)
except ValidationError:
verbose_logger.warning(
"s3 logging: s3_drop_on_terminal_error=%r is not a boolean, dropping terminal-failed uploads",
configured,
)
return True
def resolve_s3_adaptive_concurrency(configured: object) -> bool:
if configured is None or configured == "":
return False
try:
return _S3_BOOL.validate_python(configured.strip() if isinstance(configured, str) else configured)
except ValidationError:
verbose_logger.warning(
"s3 logging: s3_adaptive_concurrency=%r is not a boolean, keeping the fixed upload width",
configured,
)
return False
def resolve_s3_batch_file_upload(configured: object) -> bool:

View file

@ -3,14 +3,19 @@ s3 Bucket Logging Integration
async_log_success_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3
async_log_failure_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3
NOTE 1: S3 does not provide a BATCH PUT API endpoint; by default each element is uploaded concurrently (bounded by s3_max_concurrent_uploads), or with s3_batch_file_upload the whole flush is written as one .jsonl file
NOTE 1: S3 does not provide a BATCH PUT API endpoint; by default each element is uploaded concurrently with the fixed s3_max_concurrent_uploads bound (or an adaptive bound when s3_adaptive_concurrency is on, backing off only on throttling), or with s3_batch_file_upload the whole flush is written as one .jsonl file
"""
import asyncio
import contextvars
import logging
import re
import time
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final, cast
from functools import partial
from typing import TYPE_CHECKING, Final, Literal, cast
from urllib.parse import quote
from uuid import uuid4
@ -21,15 +26,22 @@ from litellm._logging import print_verbose, verbose_logger
from litellm.constants import (
DEFAULT_S3_BATCH_SIZE,
DEFAULT_S3_FLUSH_INTERVAL_SECONDS,
DEFAULT_S3_MAX_ADAPTIVE_CONCURRENCY,
DEFAULT_S3_MAX_CONCURRENT_UPLOADS,
)
from litellm.integrations.adaptive_concurrency import AdaptiveConcurrencyLimiter, PutSample
from litellm.integrations.s3 import (
get_s3_object_download_filename,
get_s3_object_key,
prompts_only_payload,
resolve_s3_adaptive_concurrency,
resolve_s3_batch_file_upload,
resolve_s3_drop_on_terminal_error,
resolve_s3_log_prompts_only,
resolve_s3_max_adaptive_concurrency,
resolve_s3_max_concurrent_uploads,
resolve_s3_max_queue_size,
resolve_s3_max_retry_age_seconds,
resolve_sse_params,
)
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
@ -50,6 +62,42 @@ if TYPE_CHECKING:
from botocore.credentials import Credentials
UploadOutcome = Literal["delivered", "retry", "dropped"]
_TERMINAL_ERROR_CODES: Final = frozenset(
{
"EntityTooLarge",
"InvalidArgument",
"MalformedXML",
"InvalidDigest",
"KeyTooLongError",
"BadDigest",
"InvalidRequest",
}
)
_BODY_CODED_STATUSES: Final = frozenset({400, 403})
_RETRYABLE_STATUSES: Final = frozenset({403, 500, 503})
_S3_ERROR_CODE: Final = re.compile(r"<Code>([^<]+)</Code>")
@dataclass(frozen=True, slots=True)
class _PreparedPut:
json_string: str
headers: Mapping[str, str]
def _s3_error_code(response: httpx.Response) -> str | None:
text: Final = response.text
match: Final = _S3_ERROR_CODE.search(text) if isinstance(text, str) else None
return match.group(1) if match else None
def _is_terminal(response: httpx.Response) -> bool:
"""True only for object-specific, unrecoverable rejections (400/403 with a terminal XML code).
Unknown codes, empty or non-XML bodies, and every other status fail safe toward retry."""
return response.status_code in _BODY_CODED_STATUSES and _s3_error_code(response) in _TERMINAL_ERROR_CODES
def _s3_key_parent(s3_object_key: str) -> str:
return s3_object_key.rsplit("/", 1)[0] if "/" in s3_object_key else ""
@ -58,11 +106,19 @@ class S3BatchUploadError(Exception):
def __init__(self, failed: int, total: int) -> None:
self.failed = failed
self.total = total
super().__init__(f"{failed} of {total} S3 uploads failed; events kept in queue for the next flush")
super().__init__(f"{failed} of {total} S3 uploads failed; transient failures kept in queue for the next flush")
_in_flush: Final[contextvars.ContextVar[bool]] = contextvars.ContextVar("s3_v2_in_flush", default=False)
class S3Logger(CustomBatchLogger, BaseAWSLLM):
preserve_events_added_during_flush = True
_flush_retries: int = 0
_requeued_count: int = 0
_upload_limiter: asyncio.Semaphore | AdaptiveConcurrencyLimiter | None = None
s3_drop_on_terminal_error: bool = True
s3_max_retry_age_seconds: int | None = 3600
def __init__(
self,
@ -92,6 +148,11 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
s3_sse_kms_key_id: str | None = None,
s3_log_prompts_only: bool | None = None,
s3_max_concurrent_uploads: int = DEFAULT_S3_MAX_CONCURRENT_UPLOADS,
s3_max_queue_size: int | None = None,
s3_max_retry_age_seconds: int | None = 3600,
s3_drop_on_terminal_error: bool = True,
s3_adaptive_concurrency: bool = False,
s3_max_adaptive_concurrency: int | None = None,
s3_batch_file_upload: bool = False,
s3_callback_params_override: dict | None = None,
**kwargs,
@ -135,9 +196,22 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
s3_sse_kms_key_id=s3_sse_kms_key_id,
s3_log_prompts_only=s3_log_prompts_only,
s3_max_concurrent_uploads=s3_max_concurrent_uploads,
s3_max_queue_size=s3_max_queue_size,
s3_max_retry_age_seconds=s3_max_retry_age_seconds,
s3_drop_on_terminal_error=s3_drop_on_terminal_error,
s3_adaptive_concurrency=s3_adaptive_concurrency,
s3_max_adaptive_concurrency=s3_max_adaptive_concurrency,
s3_batch_file_upload=s3_batch_file_upload,
)
self._upload_semaphore = asyncio.Semaphore(self.s3_max_concurrent_uploads)
self._upload_limiter = (
AdaptiveConcurrencyLimiter(
initial=self.s3_max_concurrent_uploads,
floor=self.s3_max_concurrent_uploads,
ceiling=max(self.s3_max_concurrent_uploads, self.s3_max_adaptive_concurrency),
)
if self.s3_adaptive_concurrency
else asyncio.Semaphore(self.s3_max_concurrent_uploads)
)
verbose_logger.debug("s3 logger using endpoint url %s", s3_endpoint_url)
# IMPORTANT
@ -158,8 +232,12 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
flush_lock=self.flush_lock,
flush_interval=s3_flush_interval,
batch_size=s3_batch_size,
max_queue_size=self.s3_max_queue_size,
)
self.log_queue: list[s3BatchLoggingElement] = []
self._requeued_count = 0
self._flush_retries = 0
self._flush_dropped: dict[int, s3BatchLoggingElement] = {}
# Call BaseAWSLLM's __init__
BaseAWSLLM.__init__(self)
@ -194,6 +272,11 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
s3_sse_kms_key_id: str | None = None,
s3_log_prompts_only: bool | None = None,
s3_max_concurrent_uploads: int = DEFAULT_S3_MAX_CONCURRENT_UPLOADS,
s3_max_queue_size: int | None = None,
s3_max_retry_age_seconds: int | None = 3600,
s3_drop_on_terminal_error: bool = True,
s3_adaptive_concurrency: bool = False,
s3_max_adaptive_concurrency: int | None = None,
s3_batch_file_upload: bool = False,
params_source: dict | None = None,
):
@ -259,6 +342,37 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
DEFAULT_S3_MAX_CONCURRENT_UPLOADS,
)
configured_queue_size: Final = params.get("s3_max_queue_size")
constructor_queue_size: Final = resolve_s3_max_queue_size(
s3_max_queue_size, CustomBatchLogger.DEFAULT_MAX_QUEUE_SIZE
)
self.s3_max_queue_size = resolve_s3_max_queue_size(configured_queue_size, constructor_queue_size)
configured_retry_age: Final = params.get("s3_max_retry_age_seconds")
constructor_retry_age: Final = resolve_s3_max_retry_age_seconds(s3_max_retry_age_seconds, 3600)
self.s3_max_retry_age_seconds = (
constructor_retry_age
if configured_retry_age is None or configured_retry_age == ""
else resolve_s3_max_retry_age_seconds(configured_retry_age, constructor_retry_age)
)
configured_drop: Final = params.get("s3_drop_on_terminal_error")
self.s3_drop_on_terminal_error = resolve_s3_drop_on_terminal_error(
configured_drop if configured_drop is not None else s3_drop_on_terminal_error
)
self.s3_adaptive_concurrency = s3_adaptive_concurrency or resolve_s3_adaptive_concurrency(
params.get("s3_adaptive_concurrency")
)
configured_adaptive_ceiling: Final = params.get("s3_max_adaptive_concurrency")
self.s3_max_adaptive_concurrency = resolve_s3_max_adaptive_concurrency(
s3_max_adaptive_concurrency
if configured_adaptive_ceiling is None or configured_adaptive_ceiling == ""
else configured_adaptive_ceiling,
DEFAULT_S3_MAX_ADAPTIVE_CONCURRENCY,
)
self.s3_batch_file_upload = s3_batch_file_upload or resolve_s3_batch_file_upload(
params.get("s3_batch_file_upload")
)
@ -310,6 +424,35 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
}
return {key: value for key, value in candidates.items() if value}
def _prepare_put(self, batch_logging_element: s3BatchLoggingElement) -> _PreparedPut:
try:
import base64
import hashlib
except ImportError:
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
json_string: Final = (
batch_logging_element.body
if batch_logging_element.body is not None
else safe_dumps(batch_logging_element.payload)
)
content_hash: Final = hashlib.sha256(json_string.encode("utf-8")).hexdigest()
content_md5: Final = base64.b64encode(
hashlib.md5(json_string.encode("utf-8"), usedforsecurity=False).digest()
).decode()
return _PreparedPut(
json_string=json_string,
headers={
"Content-Type": batch_logging_element.content_type,
"Content-MD5": content_md5,
"x-amz-content-sha256": content_hash,
"Content-Language": "en",
"Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"',
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
**self._sse_headers(),
},
)
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
await self._async_log_event_base(
kwargs=kwargs,
@ -384,12 +527,21 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
verbose_logger.exception("s3 Layer Error - %s", e)
self.handle_callback_failure(callback_name="S3Logger")
async def async_upload_data_to_s3(self, batch_logging_element: s3BatchLoggingElement) -> bool:
try:
import base64
import hashlib
except ImportError:
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
@property
def _upload_semaphore(self) -> asyncio.Semaphore | AdaptiveConcurrencyLimiter:
limiter: Final = self._upload_limiter
if limiter is None:
raise AttributeError("_upload_semaphore")
return limiter
@_upload_semaphore.setter
def _upload_semaphore(self, value: asyncio.Semaphore | AdaptiveConcurrencyLimiter) -> None:
self._upload_limiter = value
async def async_upload_data_to_s3(
self,
batch_logging_element: s3BatchLoggingElement,
) -> bool:
try:
from litellm.litellm_core_utils.asyncify import asyncify
@ -400,31 +552,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
url: Final = self._build_object_url(batch_logging_element.s3_object_key)
# Convert JSON to string
json_string: Final = (
batch_logging_element.body
if batch_logging_element.body is not None
else safe_dumps(batch_logging_element.payload)
)
# Calculate SHA256 hash of the content
content_hash: Final = hashlib.sha256(json_string.encode("utf-8")).hexdigest()
content_md5: Final = base64.b64encode(
hashlib.md5(json_string.encode("utf-8"), usedforsecurity=False).digest()
).decode()
# Prepare the request
headers: Final = {
"Content-Type": batch_logging_element.content_type,
"Content-MD5": content_md5,
"x-amz-content-sha256": content_hash,
"Content-Language": "en",
"Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"',
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
**self._sse_headers(),
}
async def signed_put() -> httpx.Response:
async def signed_put(prepared: _PreparedPut) -> httpx.Response:
credentials: Final = await asyncified_get_credentials(
aws_access_key_id=self.s3_aws_access_key_id,
aws_secret_access_key=self.s3_aws_secret_access_key,
@ -436,18 +564,26 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
aws_web_identity_token=self.s3_aws_web_identity_token,
aws_sts_endpoint=self.s3_aws_sts_endpoint,
)
signed_headers: Final = await run_aws_signing(self._sign_put, credentials, url, json_string, headers)
signed_headers: Final = await run_aws_signing(
self._sign_put, credentials, url, prepared.json_string, prepared.headers
)
try:
return await self.async_httpx_client.put(url, data=json_string, headers=signed_headers)
return await self.async_httpx_client.put(url, data=prepared.json_string, headers=signed_headers)
except httpx.HTTPStatusError as error:
return error.response
max_retries: Final = 3
prepared: Final = self._prepare_put(batch_logging_element)
for attempt in range(max_retries):
response = await signed_put()
if response.status_code in (403, 500, 503) and attempt < max_retries - 1:
response = await self._recorded_put(partial(signed_put, prepared))
if (
response.status_code in _RETRYABLE_STATUSES
and not (self.s3_drop_on_terminal_error and _is_terminal(response))
and attempt < max_retries - 1
):
wait_time = 2**attempt # 1s, 2s
verbose_logger.warning(
verbose_logger.log(
logging.DEBUG if _in_flush.get() else logging.WARNING,
"S3 upload returned %s, retrying in %ss (attempt %s/%s) key=%s",
response.status_code,
wait_time,
@ -455,6 +591,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
max_retries,
batch_logging_element.s3_object_key,
)
self._flush_retries += 1
await asyncio.sleep(wait_time)
continue
response.raise_for_status()
@ -462,6 +599,13 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
except Exception as e:
verbose_logger.exception("Error uploading to s3: %s", e)
self.handle_callback_failure(callback_name="S3Logger")
if isinstance(e, httpx.HTTPStatusError) and self.s3_drop_on_terminal_error and _is_terminal(e.response):
verbose_logger.warning(
"s3 logging: dropping object %s after terminal status %s",
batch_logging_element.s3_object_key,
e.response.status_code,
)
self._flush_dropped[id(batch_logging_element)] = batch_logging_element
return False
return True
@ -483,11 +627,64 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
# see custom_batch_logger.py which triggers the flush
#########################################################
uploads: Final = self._batch_file_elements(batch) if self._batch_file_mode_active() else batch
results: Final = await asyncio.gather(*(self._upload_bounded(element) for element in uploads))
failed: Final = tuple(element for element, ok in zip(uploads, results, strict=True) if not ok)
if not failed:
self._flush_retries = 0
self._flush_dropped = {} # mutable-ok: per-flush drop marks read back by _upload_bounded
stale: Final = min(self._requeued_count, len(uploads)) if len(uploads) == len(batch) else 0
order: Final = (*range(stale, len(uploads)), *range(stale))
ordered: Final = await asyncio.gather(*(self._upload_outcome(uploads[i]) for i in order))
outcomes: Final = dict(zip(order, ordered, strict=True))
results: Final = tuple(outcomes[i] for i in range(len(uploads)))
if self._flush_retries:
verbose_logger.warning(
"s3 logging: %s in-call retries across %s uploads this flush",
self._flush_retries,
len(uploads),
)
delivered: Final = sum(1 for outcome in results if outcome == "delivered")
bucket_wide: Final = delivered == 0
failed: Final = tuple(
(element, outcome) for element, outcome in zip(uploads, results, strict=True) if outcome != "delivered"
)
now: Final = time.monotonic()
requeued: Final = (
tuple(element for element, _ in failed)
if bucket_wide
else tuple(
element
if element.retrying_since is not None or self.s3_max_retry_age_seconds is None
else element.model_copy(update={"retrying_since": now})
for element, outcome in failed
if outcome != "dropped"
and not (
self.s3_max_retry_age_seconds is not None
and element.retrying_since is not None
and now - element.retrying_since > self.s3_max_retry_age_seconds
)
)
)
dropped: Final = len(failed) - len(requeued)
if dropped:
verbose_logger.warning(
"s3 logging: %s uploads dropped (terminal or retrying longer than s3_max_retry_age_seconds=%s)",
dropped,
self.s3_max_retry_age_seconds,
)
if not requeued:
self._requeued_count = 0
return
self.log_queue = [*failed, *self.log_queue[len(batch) :]]
arrivals: Final = self.log_queue[len(batch) :]
overflow: Final = max(0, len(requeued) + len(arrivals) - self.max_queue_size)
if overflow:
verbose_logger.warning(
"s3 logging: queue exceeded max_queue_size=%s after a failed flush, dropped %s oldest events",
self.max_queue_size,
overflow,
)
self.log_queue = [ # mutable-ok: log_queue is the flush buffer shared with custom_batch_logger
*requeued,
*arrivals,
][overflow:]
self._requeued_count = max(0, len(requeued) - overflow)
raise S3BatchUploadError(failed=len(failed), total=len(uploads))
def _batch_file_mode_active(self) -> bool:
@ -502,8 +699,37 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
return True
async def _upload_bounded(self, element: s3BatchLoggingElement) -> bool:
async with self._upload_semaphore:
return await self.async_upload_data_to_s3(element)
token: Final = _in_flush.set(True)
try:
async with self._upload_semaphore:
return await self.async_upload_data_to_s3(element)
finally:
_in_flush.reset(token)
async def _upload_outcome(self, element: s3BatchLoggingElement) -> UploadOutcome:
delivered: Final = await self._upload_bounded(element)
if delivered:
return "delivered"
if id(element) in self._flush_dropped:
return "dropped"
return "retry"
async def _recorded_put(self, signed_put: Callable[[], Awaitable[httpx.Response]]) -> httpx.Response:
limiter: Final = self._upload_limiter
adaptive: Final = limiter if isinstance(limiter, AdaptiveConcurrencyLimiter) else None
try:
response: Final = await signed_put()
except Exception:
if adaptive is not None:
adaptive.record(PutSample(throttled=True))
raise
if adaptive is not None:
adaptive.record(
PutSample(
throttled=response.status_code in (429, 503) or _s3_error_code(response) == "SlowDown",
)
)
return response
def _batch_file_elements(self, batch: tuple[s3BatchLoggingElement, ...]) -> tuple[s3BatchLoggingElement, ...]:
now: Final = datetime.now(timezone.utc)
@ -527,6 +753,9 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
content_type="application/x-ndjson",
s3_object_key=f"{parent}/{batch_name}.jsonl" if parent else f"{batch_name}.jsonl",
s3_object_download_filename=f"{batch_name}.jsonl",
retrying_since=min(
(element.retrying_since for element in elements if element.retrying_since is not None), default=None
),
)
def create_s3_batch_logging_element(
@ -596,58 +825,35 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
)
def upload_data_to_s3(self, batch_logging_element: s3BatchLoggingElement):
try:
import base64
import hashlib
except ImportError:
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
try:
verbose_logger.debug("s3_v2 logger - uploading data to s3 - %s", batch_logging_element.s3_object_key)
url: Final = self._build_object_url(batch_logging_element.s3_object_key)
# Convert JSON to string
json_string: Final = (
batch_logging_element.body
if batch_logging_element.body is not None
else safe_dumps(batch_logging_element.payload)
)
# Calculate SHA256 hash of the content
content_hash: Final = hashlib.sha256(json_string.encode("utf-8")).hexdigest()
content_md5: Final = base64.b64encode(
hashlib.md5(json_string.encode("utf-8"), usedforsecurity=False).digest()
).decode()
# Prepare the request
headers: Final = {
"Content-Type": batch_logging_element.content_type,
"Content-MD5": content_md5,
"x-amz-content-sha256": content_hash,
"Content-Language": "en",
"Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"',
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
**self._sse_headers(),
}
prepared: Final = self._prepare_put(batch_logging_element)
httpx_client: Final = _get_httpx_client(
params=({"ssl_verify": self.s3_verify} if self.s3_verify is not None else None)
)
def signed_put() -> httpx.Response:
def signed_put(prepared_put: _PreparedPut) -> httpx.Response:
credentials: Final = self.get_credentials(
aws_access_key_id=self.s3_aws_access_key_id,
aws_secret_access_key=self.s3_aws_secret_access_key,
aws_session_token=self.s3_aws_session_token,
aws_region_name=self.s3_region_name,
)
signed_headers: Final = self._sign_put(credentials, url, json_string, headers)
return httpx_client.put(url, data=json_string, headers=signed_headers)
signed_headers: Final = self._sign_put(credentials, url, prepared_put.json_string, prepared_put.headers)
return httpx_client.put(url, data=prepared_put.json_string, headers=signed_headers)
max_retries: Final = 3
for attempt in range(max_retries):
response = signed_put()
if response.status_code in (403, 500, 503) and attempt < max_retries - 1:
response = signed_put(prepared)
if (
response.status_code in _RETRYABLE_STATUSES
and not (self.s3_drop_on_terminal_error and _is_terminal(response))
and attempt < max_retries - 1
):
wait_time = 2**attempt # 1s, 2s
verbose_logger.warning(
"S3 upload returned %s, retrying in %ss (attempt %s/%s) key=%s",
@ -664,6 +870,12 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
except Exception as e:
verbose_logger.exception("Error uploading to s3: %s", e)
self.handle_callback_failure(callback_name="S3Logger")
if isinstance(e, httpx.HTTPStatusError) and self.s3_drop_on_terminal_error and _is_terminal(e.response):
verbose_logger.warning(
"s3 logging: dropping object %s after terminal status %s",
batch_logging_element.s3_object_key,
e.response.status_code,
)
async def _download_object_from_s3(self, s3_object_key: str) -> dict | None:
"""

View file

@ -11,3 +11,4 @@ class s3BatchLoggingElement(BaseModel):
s3_object_download_filename: str
body: str | None = None
content_type: str = "application/json"
retrying_since: float | None = None

View file

@ -38,6 +38,7 @@ EXCLUDED_ROLLOUT_FLAGS = {
EXCLUDED_INTERNAL_TUNING_VARS = {
"ANTHROPIC_MESSAGES_MAX_DETACHED_STREAM_DRAINS",
"ANTHROPIC_MESSAGES_STREAM_RELAY_QUEUE_MAXSIZE",
"DEFAULT_S3_MAX_ADAPTIVE_CONCURRENCY",
}
EXCLUDED_TERMINAL_VARS = {

View file

@ -27,11 +27,15 @@ class RecordingS3Sink:
fail_attempts: int = 0
fail_until: float = 0.0
fail_status: int = 503
fail_code: str = "SinkFailure"
fail_body: bytes | None = None
delay_seconds: float = 0.5
lock: threading.Lock = field(default_factory=threading.Lock)
in_flight: int = 0
peak: int = 0
attempts: int = 0
attempt_log: list[tuple[float, int]] = field(default_factory=list) # mutable-ok: appended under lock per PUT
attempt_counts: dict[str, int] = field(default_factory=dict) # mutable-ok: per-target PUT counts under lock
store: dict[str, bytes] = field(default_factory=dict) # mutable-ok: GET reads must see writes from earlier PUTs
def respond(self, request: Request) -> Reply:
@ -44,20 +48,31 @@ class RecordingS3Sink:
assert request.target.startswith(f"/{BUCKET}/{PREFIX}/"), request.target
with self.lock:
self.attempts += 1
if self.attempts <= self.fail_attempts or time.time() < self.fail_until:
return Reply(
status=self.fail_status,
body=b"<Error><Code>SinkFailure</Code></Error>",
content_type="application/xml",
)
self.attempt_counts[request.target] = self.attempt_counts.get(request.target, 0) + 1
self.in_flight += 1
self.peak = max(self.peak, self.in_flight)
self.store[request.target] = request.body
self.attempt_log.append((time.time(), self.in_flight))
failing: Final = self.attempts <= self.fail_attempts or time.time() < self.fail_until
if not failing:
self.store[request.target] = request.body
time.sleep(self.delay_seconds)
with self.lock:
self.in_flight -= 1
if failing:
return Reply(
status=self.fail_status,
body=self.fail_body
if self.fail_body is not None
else f"<Error><Code>{self.fail_code}</Code></Error>".encode(),
content_type="application/xml",
)
return Reply()
def peak_between(self, start: float, end: float) -> int:
with self.lock:
samples: Final = tuple(in_flight for when, in_flight in self.attempt_log if start <= when < end)
return max(samples, default=0)
def objects(self) -> Mapping[str, bytes]:
with self.lock:
return MappingProxyType(dict(self.store))
@ -240,7 +255,13 @@ SURFACES: Final = ("chat", "chat_stream", "messages", "messages_stream", "respon
def call_surface(
candidate: Gateway, surface: str, openai_model: str, anthropic_model: str, key: str, marker: str
candidate: Gateway,
surface: str,
openai_model: str,
anthropic_model: str,
key: str,
marker: str,
no_cache: bool = True,
) -> tuple[str, str | None]:
"""Drive one request through the given surface; return (client-visible response id, x-litellm-call-id)."""
base: Final = str(candidate.client.base_url).rstrip("/")
@ -249,7 +270,7 @@ def call_surface(
reply: Final = openai.OpenAI(base_url=f"{base}/v1", api_key=key).chat.completions.create(
model=openai_model,
messages=[{"role": "user", "content": marker}],
extra_body={"cache": {"no-cache": True}},
extra_body={"cache": {"no-cache": True}} if no_cache else {},
)
return reply.id, None
@ -258,7 +279,7 @@ def call_surface(
model=openai_model,
messages=[{"role": "user", "content": marker}],
stream=True,
extra_body={"cache": {"no-cache": True}},
extra_body={"cache": {"no-cache": True}} if no_cache else {},
)
seen = ""
async for chunk in stream:
@ -283,7 +304,7 @@ def call_surface(
response: Final = candidate.request(
"POST",
"/v1/responses",
{"model": openai_model, "input": marker, "cache": {"no-cache": True}},
{"model": openai_model, "input": marker, **({"cache": {"no-cache": True}} if no_cache else {})},
key=key,
)
assert response.status_code == 200, response.text
@ -339,6 +360,10 @@ def matched_ids(
if payload["id"] in response_ids:
landed.append(payload["id"])
continue
uncached: Final = str(payload["id"]).rsplit("_cache_hit", 1)[0]
if uncached in response_ids:
landed.append(str(payload["id"]))
continue
assert payload["litellm_call_id"] in call_ids, f"unmatched payload {payload['id']!r}"
landed.append(str(payload["id"]))
return frozenset(landed)

View file

@ -1,20 +1,24 @@
import os
import re
import uuid
from pathlib import Path
from typing import Final
import pytest
from redis import Redis
from _s3_v2_support import (
BUCKET,
PREFIX,
SURFACES,
RecordingS3Sink,
call_surface,
collect_payloads,
matched_ids,
mixed_burst,
s3_config,
surface_reply,
)
from integration._support.client import Gateway
from integration._support.client import Gateway, eventually
from integration._support.process import owned_proxy
from integration._support.wire import wire_server
@ -95,3 +99,38 @@ def test_s3_v2_sink_outage_mid_mixed_burst_recovers_every_response_id(gateway: G
assert sum(1 for r in provider.drain() if r.method == "POST") == 48
assert matched_ids(payloads, answered)
assert len(payloads) == 48, "a stored id was overwritten or duplicated"
def test_s3_v2_cache_hit_twins_log_one_object_per_request(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3cache" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(delay_seconds=0.1)
with wire_server(surface_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = s3_config(tmp_path, bucket.url, {})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "3"}, config=config) as candidate,
candidate.scenario() as scenario,
):
openai_model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
anthropic_model: Final = scenario.model(
model="anthropic/claude-sonnet-4-5-20250929", api_base=provider.url, api_key="synthetic-provider-key"
)
key: Final = scenario.key(models=[openai_model, anthropic_model])
cache: Final = Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]))
keys_before: Final = cache.dbsize()
warmed: Final = tuple(
call_surface(candidate, surface, openai_model, anthropic_model, key, f"{marker}-{surface}")
for surface in SURFACES
)
eventually(cache.dbsize, lambda size: size >= keys_before + len(SURFACES), seconds=30)
repeated: Final = tuple(
call_surface(candidate, surface, openai_model, anthropic_model, key, f"{marker}-{surface}", False)
for surface in SURFACES
)
payloads: Final = collect_payloads(sink, 2 * len(SURFACES))
assert sum(1 for r in provider.drain() if r.method == "POST") == len(SURFACES), (
"a repeated request reached the upstream; the six repeats must all be served from cache"
)
assert len(payloads) == 12
assert sum(1 for payload in payloads if payload["cache_hit"] is True) == 6
assert sum(1 for payload in payloads if payload["cache_hit"] is not True) == 6
assert matched_ids(payloads, warmed + repeated)

View file

@ -119,8 +119,8 @@ BATCH_KEY: Final = re.compile(
)
@pytest.mark.covers("other.observability.s3_v2.flush_bounds_concurrent_puts_to_default_and_keeps_every_log")
def test_s3_v2_flush_bounds_concurrent_puts_to_the_default_of_sixteen(gateway: Gateway, tmp_path: Path) -> None:
@pytest.mark.covers("other.observability.s3_v2.flush_bounds_concurrent_puts_to_default_ceiling_and_keeps_every_log")
def test_s3_v2_flush_bounds_concurrent_puts_to_the_default_ceiling(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3fan" + uuid.uuid4().hex[:8]
sink: Final = S3Sink()
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
@ -134,7 +134,9 @@ def test_s3_v2_flush_bounds_concurrent_puts_to_the_default_of_sixteen(gateway: G
ids: Final = _burst(candidate, model, key, marker)
puts: Final = _collect(bucket, count_lines=False, expected=REQUESTS)
assert sum(1 for r in provider.drain() if r.method == "POST") == REQUESTS
assert sink.peak <= 16, f"peak concurrent PUTs {sink.peak} exceeded the default bound for {REQUESTS} queued logs"
assert sink.peak <= 16, (
f"peak concurrent PUTs {sink.peak} exceeded the default width of 16 for {REQUESTS} queued logs"
)
assert all(PER_REQUEST_KEY.match(put.target) for put in puts), [put.target for put in puts]
assert frozenset(json.loads(put.body)["id"] for put in puts) == ids
assert len({put.target for put in puts}) == REQUESTS
@ -272,7 +274,7 @@ def test_s3_v2_upstream_failure_events_land_alongside_successes(gateway: Gateway
assert all("synthetic upstream rejection" in json.dumps(payload["error_information"]) for payload in failures)
@pytest.mark.covers("other.observability.s3_v2.invalid_or_empty_bound_falls_back_to_sixteen")
@pytest.mark.covers("other.observability.s3_v2.invalid_or_empty_bound_falls_back_to_default_ceiling")
@pytest.mark.parametrize(
("bad", "warns"),
[
@ -281,7 +283,7 @@ def test_s3_v2_upstream_failure_events_land_alongside_successes(gateway: Gateway
pytest.param("", False, id="empty"),
],
)
def test_s3_v2_invalid_or_empty_bound_falls_back_to_sixteen(
def test_s3_v2_invalid_or_empty_bound_falls_back_to_default_ceiling(
gateway: Gateway, tmp_path: Path, bad: JsonValue, warns: bool
) -> None:
marker: Final = "s3bound" + uuid.uuid4().hex[:8]
@ -305,14 +307,14 @@ def test_s3_v2_invalid_or_empty_bound_falls_back_to_sixteen(
else:
assert "s3_max_concurrent_uploads" not in owned.log.read_text()
assert sum(1 for r in provider.drain() if r.method == "POST") == REQUESTS
assert sink.peak <= 16, f"peak concurrent PUTs {sink.peak} exceeded the fallback bound"
assert sink.peak <= 16, f"peak concurrent PUTs {sink.peak} exceeded the fallback width of 16"
assert frozenset(payload["id"] for payload in payloads) == ids
@pytest.mark.covers("other.observability.s3_v2.sink_rejection_requeues_and_delivers_every_id_once")
def test_s3_v2_sink_rejection_requeues_and_delivers_every_id_once(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3deny" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(fail_status=403, delay_seconds=0.2)
sink: Final = RecordingS3Sink(fail_status=503, delay_seconds=0.2)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {})
with (
@ -336,6 +338,283 @@ def test_s3_v2_sink_rejection_requeues_and_delivers_every_id_once(gateway: Gatew
assert frozenset(payload["id"] for payload in payloads) == ids
@dataclass(slots=True)
class RejectingS3Sink:
"""Answers every PUT whose body carries `reject_marker` with `reject_status`, accepts the rest,
and counts the rejected attempts so a test can see whether the proxy keeps re-sending them."""
reject_marker: str
reject_status: int
reject_code: str = "AccessDenied"
reject_until: float = float("inf")
lock: threading.Lock = field(default_factory=threading.Lock)
rejected_attempts: int = 0
rejected_times: list[float] = field(default_factory=list) # mutable-ok: appended under lock per rejected PUT
store: dict[str, bytes] = field(default_factory=dict) # mutable-ok: later PUTs must be visible to earlier polls
def respond(self, request: Request) -> Reply:
assert request.method == "PUT", request.method
with self.lock:
if self.reject_marker.encode() in request.body and time.time() < self.reject_until:
self.rejected_attempts += 1
self.rejected_times.append(time.time())
return Reply(status=self.reject_status, body=f"<Error><Code>{self.reject_code}</Code></Error>".encode())
self.store[request.target] = request.body
return Reply()
def landed_ids(self) -> frozenset[str]:
with self.lock:
bodies: Final = tuple(self.store.values())
return frozenset(json.loads(line)["id"] for body in bodies for line in body.splitlines())
def _send(candidate: Gateway, model: str, key: str, identity: str) -> None:
response: Final = candidate.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": identity}], "cache": {"no-cache": True}},
key=key,
)
assert response.status_code == 200, response.text
def _send_and_wait_until_landed(candidate: Gateway, model: str, key: str, sink: RejectingS3Sink, identity: str) -> None:
_send(candidate, model, key, identity)
eventually(sink.landed_ids, lambda landed: identity in landed, seconds=60)
@pytest.mark.parametrize(
("status", "code"),
[
pytest.param(403, "AccessDenied", id="access_denied"),
pytest.param(404, "NoSuchBucket", id="no_such_bucket"),
pytest.param(400, "KMS.DisabledException", id="kms_disabled"),
],
)
def test_s3_v2_object_rejected_with_a_bucket_wide_code_is_delivered_once_the_fault_clears(
gateway: Gateway, tmp_path: Path, status: int, code: str
) -> None:
marker: Final = "s3fault" + uuid.uuid4().hex[:8]
sink: Final = RejectingS3Sink(reject_marker=f"{marker}-denied", reject_status=status, reject_code=code)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(candidate, model, key, f"{marker}-denied")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-first-flush")
eventually(lambda: sink.rejected_attempts, lambda attempts: attempts >= 2, seconds=30)
sink.reject_until = time.time()
eventually(sink.landed_ids, lambda landed: f"{marker}-denied" in landed, seconds=60)
readiness: Final = candidate.client.get("/health/readiness")
assert readiness.status_code == 200, readiness.text
assert sum(1 for r in provider.drain() if r.method == "POST") == 2
assert sink.landed_ids() == {f"{marker}-denied", f"{marker}-first-flush"}
def test_s3_v2_terminal_object_is_put_once_and_dropped_by_default(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3toolarge" + uuid.uuid4().hex[:8]
sink: Final = RejectingS3Sink(reject_marker=f"{marker}-huge", reject_status=400, reject_code="EntityTooLarge")
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(candidate, model, key, f"{marker}-huge")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-sibling")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-second-flush")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-third-flush")
assert sum(1 for r in provider.drain() if r.method == "POST") == 4
assert sink.rejected_attempts == 1, (
f"an EntityTooLarge object was PUT {sink.rejected_attempts} times next to delivered siblings; "
"with the default s3_drop_on_terminal_error it must be attempted once and dropped"
)
def test_s3_v2_terminal_object_keeps_retrying_when_opted_out(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3keep" + uuid.uuid4().hex[:8]
sink: Final = RejectingS3Sink(reject_marker=f"{marker}-huge", reject_status=400, reject_code="EntityTooLarge")
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(
tmp_path, bucket.url, {"s3_batch_file_upload": False, "s3_drop_on_terminal_error": False}
)
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(candidate, model, key, f"{marker}-huge")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-sibling")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-second-flush")
eventually(lambda: sink.rejected_attempts, lambda attempts: attempts >= 2, seconds=30)
assert sum(1 for r in provider.drain() if r.method == "POST") == 3
def test_s3_v2_aged_out_object_is_dropped_next_to_delivered_siblings(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3aged" + uuid.uuid4().hex[:8]
sink: Final = RejectingS3Sink(reject_marker=f"{marker}-doomed", reject_status=503, reject_code="InternalError")
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False, "s3_max_retry_age_seconds": 1})
with (
owned_proxy_process(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as owned,
owned.gateway.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(owned.gateway, model, key, f"{marker}-doomed")
_send_and_wait_until_landed(owned.gateway, model, key, sink, f"{marker}-sibling")
_send(owned.gateway, model, key, f"{marker}-trigger")
eventually(
lambda: owned.log.read_text(),
lambda text: "retrying longer than s3_max_retry_age_seconds=1)" in text,
seconds=60,
)
exhausted: Final = sink.rejected_attempts
_send_and_wait_until_landed(owned.gateway, model, key, sink, f"{marker}-one-flush-later")
_send_and_wait_until_landed(owned.gateway, model, key, sink, f"{marker}-two-flushes-later")
assert sum(1 for r in provider.drain() if r.method == "POST") == 5
assert 3 <= exhausted <= 3 * 3, f"{exhausted} PUTs for an object that aged out after its second flush"
assert sink.rejected_attempts == exhausted, (
f"a 503 object kept being PUT after ageing out: {exhausted} -> {sink.rejected_attempts}"
)
def test_s3_v2_aged_out_object_stays_queued_while_the_whole_sink_is_down(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3down" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(fail_status=503, delay_seconds=0.1)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False, "s3_max_retry_age_seconds": 1})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
sink.fail_until = time.time() + 12
ids: Final = _push(candidate, model, key, marker, 4)
payloads: Final = collect_payloads(sink, 4, seconds=90)
assert sum(1 for r in provider.drain() if r.method == "POST") == 4
assert frozenset(payload["id"] for payload in payloads) == ids, (
"a bucket-wide outage longer than the age budget lost events"
)
def test_s3_v2_failing_sink_trims_the_oldest_failed_events_past_the_queue_cap(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3cap" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(fail_status=503, delay_seconds=0.05)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False, "s3_max_queue_size": 4})
with (
owned_proxy_process(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as owned,
owned.gateway.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
sink.fail_until = time.time() + 15
_send(owned.gateway, model, key, f"{marker}-probe")
eventually(lambda: owned.log.read_text(), lambda text: "S3BatchUploadError" in text, seconds=30)
for index in range(24):
_send(owned.gateway, model, key, f"{marker}-{index}")
eventually(
lambda: owned.log.read_text(),
lambda text: "after a failed flush, dropped" in text,
seconds=30,
)
payloads: Final = collect_payloads(sink, 4, seconds=90)
landed: Final = frozenset(payload["id"] for payload in payloads)
assert sum(1 for r in provider.drain() if r.method == "POST") == 25
assert len(landed) == 4, f"{len(landed)} objects landed with s3_max_queue_size=4"
assert f"{marker}-probe" not in landed and f"{marker}-0" not in landed, (
f"the oldest events survived the cap: {landed}"
)
assert f"{marker}-23" in landed, f"the newest event was dropped: {landed}"
def test_s3_v2_retry_age_zero_keeps_aged_object_queued(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3agezero" + uuid.uuid4().hex[:8]
sink: Final = RejectingS3Sink(reject_marker=f"{marker}-doomed", reject_status=503, reject_code="SlowDown")
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False, "s3_max_retry_age_seconds": 0})
with (
owned_proxy_process(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as owned,
owned.gateway.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(owned.gateway, model, key, f"{marker}-doomed")
_send_and_wait_until_landed(owned.gateway, model, key, sink, f"{marker}-sibling")
_send_and_wait_until_landed(owned.gateway, model, key, sink, f"{marker}-second-flush")
_send_and_wait_until_landed(owned.gateway, model, key, sink, f"{marker}-third-flush")
attempts_before_clear: Final = sink.rejected_attempts
log_text: Final = owned.log.read_text()
assert "uploads dropped" not in log_text, log_text
assert "retrying longer than" not in log_text, log_text
sink.reject_until = time.time()
eventually(sink.landed_ids, lambda landed: f"{marker}-doomed" in landed, seconds=60)
assert sum(1 for r in provider.drain() if r.method == "POST") == 4
assert attempts_before_clear >= 3, (
f"only {attempts_before_clear} PUTs for an object that stayed queued through three delivered flushes; "
"with s3_max_retry_age_seconds=0 it must keep retrying longer than any enabled budget"
)
def test_s3_v2_throttled_429_object_is_put_once_per_flush(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3throttle" + uuid.uuid4().hex[:8]
sink: Final = RejectingS3Sink(reject_marker=f"{marker}-throttled", reject_status=429, reject_code="TooManyRequests")
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(candidate, model, key, f"{marker}-throttled")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-sibling")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-second-flush")
_send_and_wait_until_landed(candidate, model, key, sink, f"{marker}-third-flush")
sink.reject_until = time.time()
eventually(sink.landed_ids, lambda landed: f"{marker}-throttled" in landed, seconds=60)
assert sum(1 for r in provider.drain() if r.method == "POST") == 4
times: Final = tuple(sink.rejected_times)
gaps: Final = tuple(round(later - earlier, 3) for earlier, later in zip(times, times[1:]))
assert len(times) >= 3 and min(gaps) >= 1.5, (
f"PUTs for a 429 object ran {gaps} apart; the 2 s flush interval allows exactly one attempt per flush "
"because 429 is not an in-call retry status"
)
def test_s3_v2_default_config_retries_access_denied_and_every_event_lands(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3denied" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(fail_status=403, fail_code="AccessDenied", delay_seconds=0.05)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
sink.fail_until = time.time() + 20
ids: Final = _push(candidate, model, key, marker, 8)
payloads: Final = collect_payloads(sink, 8, seconds=120)
assert sum(1 for r in provider.drain() if r.method == "POST") == 8
assert frozenset(payload["id"] for payload in payloads) == ids, (
f"a default-config run lost events through a 20s AccessDenied outage: {len(payloads)} landed"
)
attempt_totals: Final = tuple(sorted(sink.attempt_counts.values()))
assert len(attempt_totals) == 8 and all(count >= 4 for count in attempt_totals), (
f"each object must see at least one full 3-PUT retry burst before landing: {attempt_totals}"
)
@pytest.mark.covers("other.observability.s3_v2.batch_retry_resends_identical_key_and_body")
def test_s3_v2_batch_retry_resends_identical_key_and_body(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3retry" + uuid.uuid4().hex[:8]
@ -628,3 +907,187 @@ def test_s3_v2_sigterm_mid_burst_loses_only_inflight_without_duplicates(gateway:
)
targets: Final = tuple(sink.objects())
assert len(set(targets)) == len(targets), "the same object was PUT more than once"
RAMP_REQUESTS: Final = 400
RAMP_PUT_DELAY_SECONDS: Final = 1.0
def _push(candidate: Gateway, model: str, key: str, marker: str, count: int) -> frozenset[str]:
ids: Final = tuple(f"{marker}-{index}" for index in range(count))
def request(identity: str) -> str:
response: Final = candidate.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": identity}], "cache": {"no-cache": True}},
key=key,
)
assert response.status_code == 200, response.text
return response.json()["id"]
with ThreadPoolExecutor(max_workers=64) as pool:
returned: Final = frozenset(pool.map(request, ids))
assert returned == frozenset(ids)
return returned
def test_s3_v2_slow_sink_ramps_concurrency_and_drains_the_backlog(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3ramp" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(delay_seconds=RAMP_PUT_DELAY_SECONDS)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(
tmp_path, bucket.url, {"s3_batch_file_upload": False, "s3_adaptive_concurrency": True}
)
with (
owned_proxy(
gateway,
tmp_path,
{"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "1", "DEFAULT_S3_BATCH_SIZE": "5000"},
config=config,
) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
ids: Final = _push(candidate, model, key, marker, RAMP_REQUESTS)
drain_started: Final = time.monotonic()
payloads: Final = collect_payloads(sink, RAMP_REQUESTS, seconds=180)
drained_seconds: Final = time.monotonic() - drain_started
fixed_sixteen_estimate: Final = RAMP_REQUESTS * RAMP_PUT_DELAY_SECONDS / 16
assert sum(1 for r in provider.drain() if r.method == "POST") == RAMP_REQUESTS
assert frozenset(payload["id"] for payload in payloads) == ids
assert sink.peak > 16, f"adaptive limiter never ramped past the old fixed bound: peak {sink.peak}"
assert drained_seconds < 2 * fixed_sixteen_estimate, (
f"backlog of {RAMP_REQUESTS} drained in {drained_seconds:.1f}s with peak concurrency {sink.peak}; "
f"even a fixed bound of 16 would need only ~{fixed_sixteen_estimate:.1f}s, so the uploads stalled"
)
def test_s3_v2_throttled_sink_halves_in_flight_puts(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3throt" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(fail_status=503, fail_code="SlowDown", delay_seconds=0.3)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(
tmp_path,
bucket.url,
{"s3_batch_file_upload": False, "s3_adaptive_concurrency": True, "s3_max_concurrent_uploads": 4},
)
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
healthy_ids: Final = _push(candidate, model, key, f"{marker}-healthy", REQUESTS)
collect_payloads(sink, REQUESTS)
healthy_peak: Final = sink.peak
window_start: Final = time.time()
sink.fail_until = window_start + 60
throttled_ids: Final = _push(candidate, model, key, f"{marker}-throttled", REQUESTS)
first_fail_at: Final = eventually(
lambda: next((when for when, _ in sink.attempt_log if when >= window_start), None),
lambda when: when is not None,
seconds=30,
)
window_end: Final = first_fail_at + 8.0
sink.fail_until = window_end
payloads: Final = collect_payloads(sink, 2 * REQUESTS, seconds=120)
throttled_peak: Final = sink.peak_between(first_fail_at + 5.0, window_end)
throttled_attempts: Final = sum(
1 for when, _ in sink.attempt_log if first_fail_at + 5.0 <= when < window_end
)
assert sum(1 for r in provider.drain() if r.method == "POST") == 2 * REQUESTS
assert healthy_peak > 4, (
f"healthy peak {healthy_peak} never rose above the configured width 4; nothing to back off from"
)
assert throttled_attempts > 0, (
"no PUTs observed in the measured SlowDown window; the back-off assertion would be vacuous"
)
assert throttled_peak < healthy_peak, (
f"in-flight PUTs during the SlowDown window peaked at {throttled_peak}, not below the healthy peak "
f"{healthy_peak}; the limiter did not back off"
)
assert frozenset(payload["id"] for payload in payloads) == healthy_ids | throttled_ids
assert len(sink.objects()) == 2 * REQUESTS
def test_s3_v2_coded_403_is_transient_and_every_id_lands_once(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3coded" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink(fail_attempts=3, fail_status=403, fail_code="RequestTimeout", delay_seconds=0.1)
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _s3_config(tmp_path, bucket.url, {"s3_batch_file_upload": False})
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
ids: Final = _push(candidate, model, key, marker, 4)
payloads: Final = collect_payloads(sink, 4)
assert frozenset(payload["id"] for payload in payloads) == ids
assert sink.attempts >= 7, (
f"only {sink.attempts} PUT attempts for 4 objects whose first 3 uploads 403 RequestTimeout; "
"coded 403s must be retried"
)
def test_s3_v2_success_callback_mode_logs_only_successes(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3succ" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink()
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _recording_s3_config(
tmp_path,
bucket.url,
{},
{"callbacks": [], "success_callback": ["s3_v2"]},
)
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
ghost: Final = candidate.request(
"POST",
"/v1/chat/completions",
{"model": f"ghost-{uuid.uuid4().hex}", "messages": [{"role": "user", "content": "hi"}]},
key=key,
)
assert ghost.status_code in (400, 403, 404), ghost.text
_send(candidate, model, key, marker)
payloads: Final = collect_payloads(sink, 1)
assert len(payloads) == 1
assert payloads[0]["id"] == marker
assert payloads[0]["status"] == "success"
def test_s3_v2_failure_callback_mode_logs_only_failures(gateway: Gateway, tmp_path: Path) -> None:
marker: Final = "s3failcb" + uuid.uuid4().hex[:8]
sink: Final = RecordingS3Sink()
with wire_server(_chat_reply) as provider, wire_server(sink.respond) as bucket:
config: Final = _recording_s3_config(
tmp_path,
bucket.url,
{},
{"callbacks": [], "failure_callback": ["s3_v2"]},
)
with (
owned_proxy(gateway, tmp_path, {"DEFAULT_S3_FLUSH_INTERVAL_SECONDS": "2"}, config=config) as candidate,
candidate.scenario() as scenario,
):
model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
key: Final = scenario.key(models=[model])
_send(candidate, model, key, marker)
ghost: Final = candidate.request(
"POST",
"/v1/chat/completions",
{"model": f"ghost-{uuid.uuid4().hex}", "messages": [{"role": "user", "content": "hi"}]},
key=key,
)
assert ghost.status_code in (400, 403, 404), ghost.text
payloads: Final = collect_payloads(sink, 1)
assert len(payloads) == 1
assert payloads[0]["status"] == "failure"
assert payloads[0]["id"] != marker
assert isinstance(payloads[0]["litellm_call_id"], str) and payloads[0]["litellm_call_id"]

View file

@ -322,6 +322,7 @@ class TestS3LoggerAuditLogEvent:
logger.s3_path = "my-prefix"
logger.log_queue = []
logger.batch_size = 100
logger.max_queue_size = 100
audit_log = StandardAuditLogPayload(
id="audit-123",
@ -355,6 +356,7 @@ class TestS3LoggerAuditLogEvent:
logger.s3_path = None
logger.log_queue = []
logger.batch_size = 100
logger.max_queue_size = 100
audit_log = StandardAuditLogPayload(
id="audit-456",

View file

@ -0,0 +1,179 @@
import asyncio
from typing import Final
import pytest
from litellm.integrations.adaptive_concurrency import AdaptiveConcurrencyLimiter, PutSample
_real_sleep: Final = asyncio.sleep
def _limiter(initial: int = 4, floor: int = 1, ceiling: int = 16) -> AdaptiveConcurrencyLimiter:
return AdaptiveConcurrencyLimiter(initial=initial, floor=floor, ceiling=ceiling)
@pytest.mark.asyncio
async def test_limit_grows_after_limit_clean_samples() -> None:
limiter: Final = _limiter(initial=4)
for _ in range(4):
limiter.record(PutSample(throttled=False))
assert limiter.limit == 5
@pytest.mark.asyncio
async def test_limit_does_not_grow_before_the_streak_completes() -> None:
limiter: Final = _limiter(initial=4)
for _ in range(3):
limiter.record(PutSample(throttled=False))
assert limiter.limit == 4
@pytest.mark.asyncio
async def test_throttled_sample_halves_the_limit() -> None:
limiter: Final = _limiter(initial=16)
limiter.record(PutSample(throttled=True))
assert limiter.limit == 8
@pytest.mark.asyncio
async def test_limit_clamps_at_the_floor() -> None:
limiter: Final = _limiter(initial=4, floor=4)
limiter.record(PutSample(throttled=True))
assert limiter.limit == 4
@pytest.mark.asyncio
async def test_limit_clamps_at_the_ceiling() -> None:
limiter: Final = _limiter(initial=15, ceiling=16)
for _ in range(1000):
limiter.record(PutSample(throttled=False))
assert limiter.limit == 16
@pytest.mark.asyncio
async def test_throttled_sample_resets_the_clean_streak() -> None:
limiter: Final = _limiter(initial=4, ceiling=32)
for _ in range(3):
limiter.record(PutSample(throttled=False))
limiter.record(PutSample(throttled=True))
limiter.record(PutSample(throttled=False))
assert limiter.limit == 2
@pytest.mark.asyncio
async def test_growing_the_limit_wakes_a_waiting_acquirer() -> None:
limiter: Final = AdaptiveConcurrencyLimiter(initial=1, floor=1, ceiling=4)
acquired: Final[list[str]] = [] # mutable-ok: the waiter task appends to it across the await boundary
released: Final = asyncio.Event()
async def hold() -> None:
async with limiter:
await released.wait()
holder: Final = asyncio.create_task(hold())
async def waiter() -> None:
async with limiter:
acquired.append("waiter")
pending: Final = asyncio.create_task(waiter())
await _real_sleep(0)
assert not acquired
limiter.record(PutSample(throttled=False))
await asyncio.wait_for(asyncio.shield(pending), timeout=5)
released.set()
await asyncio.wait_for(holder, timeout=5)
assert tuple(acquired) == ("waiter",)
@pytest.mark.asyncio
async def test_releasing_a_slot_wakes_exactly_one_waiter() -> None:
limiter: Final = AdaptiveConcurrencyLimiter(initial=1, floor=1, ceiling=4)
acquired: Final[list[str]] = [] # mutable-ok: the waiter tasks append to it across the await boundary
release: Final = asyncio.Event()
async def hold() -> None:
async with limiter:
await _real_sleep(0)
async def waiter(name: str) -> None:
async with limiter:
acquired.append(name)
await release.wait()
holder: Final = asyncio.create_task(hold())
waiters: Final = tuple(asyncio.create_task(waiter(f"w{i}")) for i in range(3))
await _real_sleep(0)
await asyncio.wait_for(holder, timeout=5)
await _real_sleep(0)
assert len(acquired) == 1
for _ in range(3):
limiter.record(PutSample(throttled=False))
await _real_sleep(0)
assert len(acquired) == 3
release.set()
await asyncio.gather(*waiters)
@pytest.mark.asyncio
async def test_double_cancel_during_release_leaves_in_flight_at_zero() -> None:
limiter: Final = AdaptiveConcurrencyLimiter(initial=1, floor=1, ceiling=1)
entered: Final = asyncio.Event()
release: Final = asyncio.Event()
async def hold() -> None:
async with limiter:
entered.set()
await release.wait()
holder: Final = asyncio.create_task(hold())
await entered.wait()
waiter: Final = asyncio.create_task(hold())
await _real_sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
release.set()
await asyncio.wait_for(holder, timeout=5)
holder.cancel()
try:
await holder
except asyncio.CancelledError:
pass
assert limiter._in_flight == 0
@pytest.mark.asyncio
async def test_a_cancelled_waiter_is_skipped_when_a_slot_frees() -> None:
limiter: Final = AdaptiveConcurrencyLimiter(initial=1, floor=1, ceiling=1)
acquired: Final[list[str]] = [] # mutable-ok: waiter tasks append across the await boundary
first_entered: Final = asyncio.Event()
release: Final = asyncio.Event()
async def hold(name: str, entered: asyncio.Event | None = None) -> None:
async with limiter:
acquired.append(name)
if entered is not None:
entered.set()
await release.wait()
holder: Final = asyncio.create_task(hold("holder", first_entered))
await first_entered.wait()
doomed: Final = asyncio.create_task(hold("doomed"))
next_waiter: Final = asyncio.create_task(hold("next"))
await _real_sleep(0)
doomed.cancel()
with pytest.raises(asyncio.CancelledError):
await doomed
release.set()
await asyncio.wait_for(holder, timeout=5)
await asyncio.wait_for(next_waiter, timeout=5)
assert "doomed" not in acquired
assert "next" in acquired
assert limiter._in_flight == 0

File diff suppressed because it is too large Load diff