Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/redis_fastapi/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,12 @@ async def _store_cache_entry(
set_kwargs: dict[str, Any] = {}
if pending.ttl > 0:
set_kwargs["ex"] = pending.ttl
else:
logger.warning(
"Caching key '%s' without TTL — entry will persist "
"until explicitly evicted or evicted by Redis policy",
pending.key,
)
await redis.set(pending.key, json.dumps(entry), **set_kwargs)
write_type = "write_through" if pending.write_through else "miss_fill"
record_cache_write(write_type=write_type)
Expand Down
19 changes: 13 additions & 6 deletions src/redis_fastapi/cache_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,16 @@ async def set(
key: The cache key to store under.
value: The value to serialize and cache.
ttl: Time-to-live as seconds (``int``) or a
:class:`~datetime.timedelta`. ``None`` or value below ``1``
means the key will not be automatically expired.
:class:`~datetime.timedelta`. ``None`` or a value of ``0``
or below means the key will not be automatically expired.
eviction_group: Override the instance-level eviction group for this call.

Raises:
ValueError: If *ttl* is negative.
"""
grp = eviction_group if eviction_group is not None else self._eviction_group
full_key = self._build_key(key, eviction_group)
ttl_seconds = int(ttl.total_seconds()) if isinstance(ttl, timedelta) else ttl
ttl_milliseconds: int | None = (
int(ttl / timedelta(milliseconds=1)) if isinstance(ttl, timedelta) else None
)
with cache_span(
"cache.backend.set",
attributes={
Expand All @@ -204,9 +204,16 @@ async def set(
with timed_operation("set", eviction_group=grp):
try:
encoded = self._coder.encode(value)
if ttl_seconds is not None and ttl_seconds > 0:
if ttl_milliseconds is not None:
await self._redis.set(full_key, encoded, px=ttl_milliseconds)
elif ttl_seconds is not None and ttl_seconds > 0:
await self._redis.set(full_key, encoded, ex=ttl_seconds)
else:
logger.warning(
"Caching key '%s' without TTL — entry will persist "
"until explicitly evicted or evicted by Redis policy",
full_key,
)
await self._redis.set(full_key, encoded)
record_cache_write(write_type="miss_fill", eviction_group=grp)
except (RedisError, OSError):
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/test_edge_cases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import pytest
from redis_fastapi.cache_backend import CacheBackend


class TestNegativeTTLDocstringMismatch:
@pytest.mark.asyncio
async def test_negative_ttl_does_not_raise_valueerror(self) -> None:
import fakeredis.aioredis

fake = fakeredis.aioredis.FakeRedis(decode_responses=True)
backend = CacheBackend(fake, eviction_group="ns")

try:
await backend.set("k", "v", ttl=-5)
except ValueError as exc:
pytest.fail(f"Unexpected ValueError: {exc}")

assert await backend.get("k") == "v"
full_key = backend._build_key("k")
ttl_val = await fake.ttl(full_key)
assert ttl_val == -1, "Negative TTL should behave as no expiry"


class TestTimedeltaTTLPrecision:
@pytest.mark.asyncio
async def test_timedelta_ttl_uses_milliseconds(self) -> None:
from datetime import timedelta

import fakeredis.aioredis

fake = fakeredis.aioredis.FakeRedis(decode_responses=True)
backend = CacheBackend(fake, eviction_group="ns")

td = timedelta(milliseconds=500)
await backend.set("k", "v", ttl=td)
full_key = backend._build_key("k")
ttl_val = await fake.pttl(full_key)
assert 0 < ttl_val <= 500, (
f"Expected pttl between 1 and 500ms, got {ttl_val}"
)

@pytest.mark.asyncio
async def test_timedelta_ttl_one_second(self) -> None:
from datetime import timedelta

import fakeredis.aioredis

fake = fakeredis.aioredis.FakeRedis(decode_responses=True)
backend = CacheBackend(fake, eviction_group="ns")

td = timedelta(seconds=1)
await backend.set("k", "v", ttl=td)
full_key = backend._build_key("k")
ttl_val = await fake.ttl(full_key)
assert ttl_val == 1, f"Expected ttl=1, got {ttl_val}"


class TestZeroTTLStorage:
@pytest.mark.asyncio
async def test_zero_ttl_persists_indefinitely(self) -> None:
import fakeredis.aioredis

fake = fakeredis.aioredis.FakeRedis(decode_responses=True)
backend = CacheBackend(fake, eviction_group="ns")
await backend.set("k", "v", ttl=0)
await backend.set("k2", "v2")
full_key = backend._build_key("k")
full_key2 = backend._build_key("k2")
assert await fake.ttl(full_key) == -1
assert await fake.ttl(full_key2) == -1

@pytest.mark.asyncio
async def test_default_ttl_zero_persists_forever(self) -> None:
import fakeredis.aioredis

fake = fakeredis.aioredis.FakeRedis(decode_responses=True)
backend = CacheBackend(fake, eviction_group="ns")
await backend.set("k", "v")
full_key = backend._build_key("k")
assert await fake.ttl(full_key) == -1