litellm/tests/llm_translation/test_vcr_redis_persister.py
Mateo Wang 939cff0455
test(vcr): stop refreshing cassette TTL on read so cassettes lapse after 24h (#29784)
The Redis cassette persister slid the 24h TTL forward on every successful
read, so any cassette replayed at least once per day never expired. With CI
running more than once a day that means a recorded response is replayed
forever and the suite never re-hits the provider, so a changed request or
response contract goes undetected indefinitely.

Drop the refresh-on-read. The TTL now counts down from the last write, so a
cassette lapses 24h after it was recorded and the next run past that point
re-records live and catches provider drift. Per-commit runs in between still
replay from cache; only the one boundary-crossing run goes live.
2026-06-05 10:22:41 -07:00

447 lines
14 KiB
Python

from __future__ import annotations
import os
import sys
import fakeredis
import pytest
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import OutOfMemoryError as RedisOutOfMemoryError
from redis.exceptions import TimeoutError as RedisTimeoutError
from vcr.persisters.filesystem import CassetteNotFoundError
from vcr.request import Request
from vcr.serializers import yamlserializer
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from tests._vcr_redis_persister import ( # noqa: E402
CASSETTE_TTL_SECONDS,
MAX_EPISODES_PER_CASSETTE,
VCRCassetteCacheWarning,
cassette_cache_capacity_snapshot,
cassette_cache_health,
filter_non_2xx_response,
make_redis_persister,
mark_test_outcome_for_cassette,
redis_key_for,
reset_cassette_cache_health,
)
def _sample_cassette_dict():
request = Request(
method="POST",
uri="https://api.anthropic.com/v1/messages",
body=b'{"model":"claude","messages":[{"role":"user","content":"hi"}]}',
headers={"content-type": "application/json"},
)
response = {
"status": {"code": 200, "message": "OK"},
"headers": {"content-type": ["application/json"]},
"body": {"string": b'{"id":"msg_1","type":"message"}'},
}
return {"requests": [request], "responses": [response]}
def _persister_with_fake_redis():
fake = fakeredis.FakeStrictRedis()
return fake, make_redis_persister(client=fake)
def test_save_then_load_roundtrips_cassette_content():
_, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_y"
persister.save_cassette(cassette_id, _sample_cassette_dict(), yamlserializer)
requests, responses = persister.load_cassette(cassette_id, yamlserializer)
assert len(requests) == 1
assert len(responses) == 1
assert requests[0].method == "POST"
assert requests[0].uri == "https://api.anthropic.com/v1/messages"
assert responses[0]["status"]["code"] == 200
assert responses[0]["body"]["string"] == b'{"id":"msg_1","type":"message"}'
def test_saved_key_has_24h_ttl():
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_ttl"
persister.save_cassette(cassette_id, _sample_cassette_dict(), yamlserializer)
ttl = fake.ttl(redis_key_for(cassette_id))
assert CASSETTE_TTL_SECONDS - 5 <= ttl <= CASSETTE_TTL_SECONDS
def test_load_missing_key_raises_cassette_not_found():
_, persister = _persister_with_fake_redis()
with pytest.raises(CassetteNotFoundError):
persister.load_cassette("never/recorded", yamlserializer)
def test_load_does_not_refresh_ttl_so_cassettes_lapse_after_write():
"""A successful read must not slide the cassette's expiry forward.
The TTL deliberately counts down from the last *write*: a cassette that
is only ever replayed must still lapse ``CASSETTE_TTL_SECONDS`` after it
was recorded, so the next run past that point re-records live and catches
provider request/response contract drift. Refreshing the TTL on read
would keep an actively-used cassette alive forever and that drift check
would never run.
"""
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_ttl_no_refresh"
key = redis_key_for(cassette_id)
persister.save_cassette(cassette_id, _sample_cassette_dict(), yamlserializer)
# Simulate a cassette written ~most-of-a-day ago: only a little TTL left.
fake.expire(key, 60)
persister.load_cassette(cassette_id, yamlserializer)
assert fake.ttl(key) <= 60
def test_redis_key_normalizes_path_passed_by_pytest_recording():
raw = "tests/llm_translation/cassettes/test_anthropic/test_streaming.yaml"
assert (
redis_key_for(raw)
== "litellm:vcr:cassette:tests/llm_translation/test_anthropic/test_streaming"
)
def test_redis_key_is_stable_across_working_directories(tmp_path, monkeypatch):
repo_root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
abs_cassette = os.path.join(
repo_root,
"tests/llm_translation/cassettes/test_anthropic/test_streaming.yaml",
)
monkeypatch.chdir(repo_root)
key_from_root = redis_key_for(abs_cassette)
monkeypatch.chdir(os.path.join(repo_root, "tests", "llm_translation"))
key_from_subdir = redis_key_for(abs_cassette)
monkeypatch.chdir(tmp_path)
key_from_tmp = redis_key_for(abs_cassette)
assert key_from_root == key_from_subdir == key_from_tmp
assert (
key_from_root
== "litellm:vcr:cassette:tests/llm_translation/test_anthropic/test_streaming"
)
class _FlakyRedis:
def __init__(self, inner, fail_on: str, exc=None):
self._inner = inner
self._fail_on = fail_on
self._exc = exc if exc is not None else RedisConnectionError("simulated outage")
def get(self, *args, **kwargs):
if self._fail_on == "get":
raise self._exc
return self._inner.get(*args, **kwargs)
def set(self, *args, **kwargs):
if self._fail_on == "set":
raise self._exc
return self._inner.set(*args, **kwargs)
@pytest.mark.parametrize(
"exc",
[
RedisConnectionError("simulated outage"),
RedisTimeoutError("simulated timeout"),
RedisOutOfMemoryError("command not allowed when used memory > 'maxmemory'."),
],
ids=["connection_error", "timeout", "out_of_memory"],
)
def test_save_swallows_redis_errors_so_teardown_does_not_fail(exc):
"""Redis-side failures during cassette persistence must never fail
the test on teardown.
Regression: previously the persister only swallowed
ConnectionError/TimeoutError, so OutOfMemoryError (raised by Redis
Cloud when the cassette cache hit its maxmemory cap) propagated out
of vcrpy's autouse fixture and failed otherwise-passing tests on
teardown.
"""
flaky = _FlakyRedis(fakeredis.FakeStrictRedis(), fail_on="set", exc=exc)
persister = make_redis_persister(client=flaky)
persister.save_cassette(
"tests/llm_translation/test_x/test_save_outage",
_sample_cassette_dict(),
yamlserializer,
)
def test_save_skipped_when_test_marked_failed_and_prior_cassette_preserved():
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_flaky"
key = redis_key_for(cassette_id)
good = _sample_cassette_dict()
persister.save_cassette(cassette_id, good, yamlserializer)
good_payload = fake.get(key)
assert good_payload is not None
mark_test_outcome_for_cassette(cassette_id, passed=False)
bad_response = {
"status": {"code": 200, "message": "OK"},
"headers": {},
"body": {"string": b'{"id":"BAD","type":"message"}'},
}
bad = {"requests": good["requests"], "responses": [bad_response]}
persister.save_cassette(cassette_id, bad, yamlserializer)
assert fake.get(key) == good_payload
def test_save_proceeds_when_test_marked_passed():
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_passed"
key = redis_key_for(cassette_id)
mark_test_outcome_for_cassette(cassette_id, passed=True)
persister.save_cassette(cassette_id, _sample_cassette_dict(), yamlserializer)
assert fake.get(key) is not None
def test_save_refused_when_cassette_exceeds_max_episodes():
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_runaway"
key = redis_key_for(cassette_id)
persister.save_cassette(cassette_id, _sample_cassette_dict(), yamlserializer)
seed_payload = fake.get(key)
request = Request(
method="POST",
uri="https://api.anthropic.com/v1/messages",
body=b"x",
headers={"content-type": "application/json"},
)
response = {
"status": {"code": 200, "message": "OK"},
"headers": {},
"body": {"string": b"{}"},
}
bloated = {
"requests": [request] * (MAX_EPISODES_PER_CASSETTE + 1),
"responses": [response] * (MAX_EPISODES_PER_CASSETTE + 1),
}
persister.save_cassette(cassette_id, bloated, yamlserializer)
assert fake.get(key) == seed_payload
def test_save_proceeds_at_max_episodes_threshold():
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_at_threshold"
key = redis_key_for(cassette_id)
request = Request(
method="POST",
uri="https://api.anthropic.com/v1/messages",
body=b"x",
headers={"content-type": "application/json"},
)
response = {
"status": {"code": 200, "message": "OK"},
"headers": {},
"body": {"string": b"{}"},
}
at_threshold = {
"requests": [request] * MAX_EPISODES_PER_CASSETTE,
"responses": [response] * MAX_EPISODES_PER_CASSETTE,
}
persister.save_cassette(cassette_id, at_threshold, yamlserializer)
assert fake.get(key) is not None
def test_save_proceeds_when_outcome_unknown():
fake, persister = _persister_with_fake_redis()
cassette_id = "tests/llm_translation/test_x/test_no_marker"
key = redis_key_for(cassette_id)
persister.save_cassette(cassette_id, _sample_cassette_dict(), yamlserializer)
assert fake.get(key) is not None
@pytest.mark.parametrize(
"exc",
[
RedisConnectionError("simulated outage"),
RedisTimeoutError("simulated timeout"),
RedisOutOfMemoryError("command not allowed when used memory > 'maxmemory'."),
],
ids=["connection_error", "timeout", "out_of_memory"],
)
def test_load_treats_redis_errors_as_cassette_miss(exc):
flaky = _FlakyRedis(fakeredis.FakeStrictRedis(), fail_on="get", exc=exc)
persister = make_redis_persister(client=flaky)
with pytest.raises(CassetteNotFoundError):
persister.load_cassette(
"tests/llm_translation/test_x/test_load_outage", yamlserializer
)
@pytest.mark.parametrize(
("status_code", "expect_dropped"),
[
(200, False),
(201, False),
(204, False),
(299, False),
(300, True),
(400, True),
(401, True),
(404, True),
(429, True),
(500, True),
(502, True),
(503, True),
],
)
def test_only_2xx_responses_are_cached(status_code, expect_dropped):
response = {
"status": {"code": status_code, "message": "X"},
"headers": {},
"body": {"string": ""},
}
result = filter_non_2xx_response(response)
assert (result is None) == expect_dropped
if not expect_dropped:
assert result is response
@pytest.fixture
def reset_health():
reset_cassette_cache_health()
yield
reset_cassette_cache_health()
def test_save_failure_increments_health_counter_and_emits_warning(reset_health):
flaky = _FlakyRedis(
fakeredis.FakeStrictRedis(),
fail_on="set",
exc=RedisOutOfMemoryError(
"command not allowed when used memory > 'maxmemory'."
),
)
persister = make_redis_persister(client=flaky)
with pytest.warns(VCRCassetteCacheWarning, match="OutOfMemoryError"):
persister.save_cassette(
"tests/llm_translation/test_x/test_save_outage",
_sample_cassette_dict(),
yamlserializer,
)
health = cassette_cache_health()
assert health["save_failures"] == 1
assert "OutOfMemoryError" in health["save_failure_last_error"]
assert health["load_failures"] == 0
def test_load_failure_increments_health_counter_and_emits_warning(reset_health):
flaky = _FlakyRedis(
fakeredis.FakeStrictRedis(),
fail_on="get",
exc=RedisConnectionError("simulated outage"),
)
persister = make_redis_persister(client=flaky)
with pytest.warns(VCRCassetteCacheWarning, match="ConnectionError"):
with pytest.raises(CassetteNotFoundError):
persister.load_cassette(
"tests/llm_translation/test_x/test_load_outage", yamlserializer
)
health = cassette_cache_health()
assert health["load_failures"] == 1
assert "ConnectionError" in health["load_failure_last_error"]
assert health["save_failures"] == 0
def test_health_counters_accumulate_across_failures(reset_health):
flaky = _FlakyRedis(
fakeredis.FakeStrictRedis(),
fail_on="set",
exc=RedisConnectionError("simulated outage"),
)
persister = make_redis_persister(client=flaky)
for i in range(3):
with pytest.warns(VCRCassetteCacheWarning):
persister.save_cassette(
f"tests/llm_translation/test_x/test_outage_{i}",
_sample_cassette_dict(),
yamlserializer,
)
assert cassette_cache_health()["save_failures"] == 3
def test_successful_save_does_not_emit_warning_or_increment_counter(reset_health):
_, persister = _persister_with_fake_redis()
import warnings as _warnings
with _warnings.catch_warnings():
_warnings.simplefilter("error", VCRCassetteCacheWarning)
persister.save_cassette(
"tests/llm_translation/test_x/test_happy",
_sample_cassette_dict(),
yamlserializer,
)
assert cassette_cache_health()["save_failures"] == 0
class _FakeRedisWithInfo:
def __init__(self, used: int, maxmem: int):
self._used = used
self._maxmem = maxmem
def info(self, section=None):
return {"used_memory": self._used, "maxmemory": self._maxmem}
def test_capacity_snapshot_returns_used_max_and_pct():
client = _FakeRedisWithInfo(used=900, maxmem=1000)
snap = cassette_cache_capacity_snapshot(client=client)
assert snap == {
"used_memory_bytes": 900,
"maxmemory_bytes": 1000,
"used_pct": 90.0,
}
def test_capacity_snapshot_returns_none_when_uncapped():
client = _FakeRedisWithInfo(used=900, maxmem=0)
assert cassette_cache_capacity_snapshot(client=client) is None
def test_capacity_snapshot_returns_none_when_used_unknown():
client = _FakeRedisWithInfo(used=0, maxmem=1000)
assert cassette_cache_capacity_snapshot(client=client) is None
def test_capacity_snapshot_swallows_exceptions():
class _Boom:
def info(self, section=None):
raise RuntimeError("redis offline")
assert cassette_cache_capacity_snapshot(client=_Boom()) is None