mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
* fix(azure_sentinel): split batches under the 1MB ingestion cap and keep undelivered records queued Azure Monitor rejects any Logs Ingestion body over 1MB with a 413. The Sentinel logger posted the whole queue as one body and cleared it in a finally block, so an oversize batch, a transient 5xx, or a failed token call dropped every queued record, and records logged while a send was in flight were cleared with it. Both the standard and the audit queue share the sender. Move Datadog's proactive size split and 413 halving into a shared helper, litellm/integrations/batch_utils.send_batch_with_413_split, and route Sentinel through it with a 1MB size check. A lone record that still 413s is dropped, everything a transient failure leaves undelivered goes back to the front of its queue, and the retry queue is capped at max_queue_size so an unreachable workspace cannot grow memory without bound * fix(azure_sentinel): retry undelivered records on the flush timer only Requeued records made every later event cross the batch_size threshold, so a down ingestion endpoint got one full-queue resend per request. Threshold sends now go through flush_queue, so they take the flush lock instead of racing the timer, and they stand down while records are awaiting retry. A record that cannot be serialized raised out of the size probe and killed the periodic flush task. The probe now runs inside the failure handling, so the batch is split and only the record that cannot be serialized is dropped. * fix(azure_sentinel): decide threshold sends under the flush lock Concurrent callbacks all read logs_awaiting_retry before the first send finished, so each one resent the whole queue once that send failed. The flag and the batch_size threshold are now rechecked while holding the flush lock, and each queue sends only itself instead of going through flush_queue, which was retrying the other queue too. * test(azure_sentinel): cover successful threshold waiters * fix(azure_sentinel): preserve cancelled batches for retry * fix(azure_sentinel): requeue only the undelivered part of a cancelled split A batch over the ingestion cap goes out in pieces, so a cancellation partway through requeued pieces the destination had already accepted and sent them a second time on the next flush The split helper now raises a cancellation carrying the records it never delivered, and Azure Sentinel requeues those instead of the whole batch * fix(azure_sentinel): drop batches a permanent rejection will never accept A non-413 4xx from the ingestion endpoint or from the OAuth token call means the request will fail the same way on every retry, so requeueing it held the batch, and every record logged behind it, until the queue cap dropped them. Retryable statuses (5xx, 408, 429) still keep the whole batch, and a shared classifier gives Datadog the same rule The serialization probe now catches any exception, not just TypeError and ValueError, because safe_dumps hands pydantic models to model_dump and can raise anything. It also splits on record count, so a recovery flush sends batch_size records per request instead of serializing the whole requeued queue to measure it Both integrations re-raise a cancelled send as exactly asyncio.CancelledError. Python 3.12's asyncio.wait_for only translates the exact class into TimeoutError, so the BatchSendCancelled subclass escaped the logging worker as an unhandled error The awaiting-retry flag now follows the queue that survived the max_queue_size trim, so a deployment with the cap at zero is not left waiting for a timer flush with nothing queued to retry * chore(logging): document mutable queue ownership Annotate the queue detach and requeue constructions required by the logger's appendable queue contract so the type-discipline budget stays clean * fix(datadog): preserve non-413 retry behavior Keep Datadog's existing contract of requeuing every non-413 HTTP failure while Azure Sentinel applies its permanent-client-error policy through the shared splitter * fix(batch_utils): requeue by default and let Sentinel opt into dropping The shared splitter's default non-success handler is now requeue_after_http_error, the behavior Datadog had before the extraction, so a caller that omits the argument keeps its records. Azure Sentinel passes undelivered_after_http_error explicitly to drop permanent 4xx rejections Also drops an explicit return None the strict ruff gate flags in the test helper
160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
import asyncio
|
|
from collections.abc import Awaitable, Callable, Sequence
|
|
from typing import Final, Generic, TypeVar
|
|
|
|
import httpx
|
|
|
|
from litellm._logging import verbose_logger
|
|
from litellm.llms.custom_httpx.http_handler import MaskedHTTPStatusError
|
|
|
|
_BatchItem = TypeVar("_BatchItem")
|
|
|
|
_RETRYABLE_CLIENT_STATUS_CODES: Final = frozenset({408, 429})
|
|
|
|
|
|
def is_retryable_status(status_code: int) -> bool:
|
|
return not 400 <= status_code < 500 or status_code in _RETRYABLE_CLIENT_STATUS_CODES
|
|
|
|
|
|
def undelivered_after_http_error(
|
|
batch: Sequence[_BatchItem],
|
|
status_code: int,
|
|
integration_name: str,
|
|
detail: str,
|
|
) -> tuple[_BatchItem, ...]:
|
|
"""The records to requeue after a non-2xx: all of them on a status a retry can clear, none on
|
|
a 4xx that would only repeat, since retaining those retries a misconfiguration forever."""
|
|
if is_retryable_status(status_code):
|
|
verbose_logger.error(
|
|
"%s API error: status_code=%s, will retry %s records - %s",
|
|
integration_name,
|
|
status_code,
|
|
len(batch),
|
|
detail,
|
|
)
|
|
return tuple(batch)
|
|
verbose_logger.error(
|
|
"%s API error: status_code=%s is not retryable, dropped %s records - %s",
|
|
integration_name,
|
|
status_code,
|
|
len(batch),
|
|
detail,
|
|
)
|
|
return ()
|
|
|
|
|
|
def requeue_after_http_error(
|
|
batch: Sequence[_BatchItem],
|
|
status_code: int,
|
|
integration_name: str,
|
|
detail: str,
|
|
) -> tuple[_BatchItem, ...]:
|
|
verbose_logger.error(
|
|
"%s API error: status_code=%s, will retry %s records - %s",
|
|
integration_name,
|
|
status_code,
|
|
len(batch),
|
|
detail,
|
|
)
|
|
return tuple(batch)
|
|
|
|
|
|
class BatchSendCancelled(asyncio.CancelledError, Generic[_BatchItem]):
|
|
"""Cancellation of a batch send, carrying only the records the destination never accepted.
|
|
|
|
A batch split under the size cap is delivered in pieces, so requeueing all of it after a
|
|
cancellation partway through would send the accepted pieces a second time.
|
|
"""
|
|
|
|
def __init__(self, undelivered: tuple[_BatchItem, ...]) -> None:
|
|
super().__init__()
|
|
self.undelivered: Final = undelivered
|
|
|
|
|
|
async def _keep_the_remainder_on_cancel(
|
|
send: Awaitable[tuple[_BatchItem, ...]],
|
|
remainder: Sequence[_BatchItem],
|
|
) -> tuple[_BatchItem, ...]:
|
|
try:
|
|
return await send
|
|
except BatchSendCancelled as cancelled:
|
|
raise BatchSendCancelled((*cancelled.undelivered, *remainder)) from cancelled
|
|
|
|
|
|
async def send_batch_with_413_split(
|
|
batch: Sequence[_BatchItem],
|
|
send_batch: Callable[[Sequence[_BatchItem]], Awaitable[httpx.Response]],
|
|
exceeds_limits: Callable[[Sequence[_BatchItem]], bool],
|
|
success_status_codes: frozenset[int],
|
|
integration_name: str,
|
|
drop_error_message: str,
|
|
non_success_handler: Callable[
|
|
[Sequence[_BatchItem], int, str, str], tuple[_BatchItem, ...]
|
|
] = requeue_after_http_error,
|
|
) -> tuple[_BatchItem, ...]:
|
|
async def _halve() -> tuple[_BatchItem, ...]:
|
|
midpoint: Final = len(batch) // 2
|
|
left_batch: Final = batch[:midpoint]
|
|
right_batch: Final = batch[midpoint:]
|
|
left_undelivered: Final = await _keep_the_remainder_on_cancel(
|
|
send_batch_with_413_split(
|
|
batch=left_batch,
|
|
send_batch=send_batch,
|
|
exceeds_limits=exceeds_limits,
|
|
success_status_codes=success_status_codes,
|
|
integration_name=integration_name,
|
|
drop_error_message=drop_error_message,
|
|
non_success_handler=non_success_handler,
|
|
),
|
|
right_batch,
|
|
)
|
|
if left_undelivered:
|
|
return (*left_undelivered, *right_batch)
|
|
return await send_batch_with_413_split(
|
|
batch=right_batch,
|
|
send_batch=send_batch,
|
|
exceeds_limits=exceeds_limits,
|
|
success_status_codes=success_status_codes,
|
|
integration_name=integration_name,
|
|
drop_error_message=drop_error_message,
|
|
non_success_handler=non_success_handler,
|
|
)
|
|
|
|
async def _handle_413() -> tuple[_BatchItem, ...]:
|
|
if len(batch) == 1:
|
|
verbose_logger.error(drop_error_message)
|
|
return ()
|
|
return await _halve()
|
|
|
|
if not batch:
|
|
return ()
|
|
|
|
try:
|
|
oversized: Final = exceeds_limits(batch)
|
|
except Exception as e: # noqa: BLE001 # any record that cannot be serialized is isolated and dropped alone
|
|
if len(batch) > 1:
|
|
return await _halve()
|
|
verbose_logger.exception("%s dropped a record that cannot be serialized - %s", integration_name, e)
|
|
return ()
|
|
if oversized and len(batch) > 1:
|
|
return await _halve()
|
|
|
|
try:
|
|
response: Final = await send_batch(batch)
|
|
except MaskedHTTPStatusError as e:
|
|
if e.status_code == 413:
|
|
return await _handle_413()
|
|
return non_success_handler(batch, e.status_code, integration_name, str(e))
|
|
except asyncio.CancelledError as cancelled:
|
|
raise BatchSendCancelled(tuple(batch)) from cancelled
|
|
except Exception as e:
|
|
verbose_logger.exception("%s Error sending batch API - %s", integration_name, e)
|
|
return tuple(batch)
|
|
|
|
if response.status_code == 413:
|
|
return await _handle_413()
|
|
if response.status_code not in success_status_codes:
|
|
return non_success_handler(batch, response.status_code, integration_name, response.text)
|
|
|
|
verbose_logger.debug("%s delivered %s records, status_code=%s", integration_name, len(batch), response.status_code)
|
|
return ()
|