mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
* 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>
179 lines
5.5 KiB
Python
179 lines
5.5 KiB
Python
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
|