Skip to content
Merged
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: 3 additions & 3 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@
"filename": "src/cachekit/cache_handler.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 271
"line_number": 410
}
],
"src/cachekit/config/decorator.py": [
Expand All @@ -222,7 +222,7 @@
"filename": "src/cachekit/config/decorator.py",
"hashed_secret": "1a9a9d37d8305b0cd8353468065cf844259e1b1f",
"is_verified": false,
"line_number": 562
"line_number": 593
}
],
"tests/critical/test_aad_v03_security.py": [
Expand Down Expand Up @@ -425,5 +425,5 @@
}
]
},
"generated_at": "2026-06-19T11:44:44Z"
"generated_at": "2026-07-21T23:31:41Z"
}
29 changes: 28 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,33 @@ def fetch_data(user_id: int):

`@cache.io()` automatically creates a `CachekitIOBackend` from environment variables. It applies production-grade defaults (see the [intent preset table](#intent-presets) below).

### Stale-While-Revalidate (`stale_ttl`)

`@cache.io` supports past-TTL stale-while-revalidate ([protocol spec](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#stale-while-revalidate)): for a `stale_ttl`-second window after the fresh TTL lapses, the backend keeps serving the old value (flagged stale) and the SDK re-runs your function **in the background** — no request ever blocks on the recompute at a TTL boundary.

```python notest
from cachekit import cache

# Default: @cache.io enables SWR with stale_ttl = ttl.
@cache.io(ttl=300)
def build_index():
return expensive_scan()

# Size the window explicitly, or pass stale_ttl=0 to opt out.
@cache.io(ttl=300, stale_ttl=900)
def report():
return expensive_report()
```

Rules and behavior:

- Requires a positive `ttl`; `ttl + stale_ttl` is capped at 2,592,000 s (30 days). Violations raise `ConfigurationError` at decoration time.
- **CachekitIO only** — other backends have no read-side freshness signal and raise `ConfigurationError` if `stale_ttl` is set.
- Concurrent stale hits trigger at most one revalidation: per-process dedup plus (async functions) a non-blocking distributed lease on the backend's lock. Contested = serve stale, don't wait.
- A failed background recompute is silent: the entry keeps serving stale until its hard eviction bound, after which the next call takes the ordinary synchronous miss path.
- The background recompute runs **outside the request context** — don't rely on request-scoped state (contextvars, open sessions) inside functions that enable SWR.
- Stale values are never written to the L1 in-memory cache, and stale reads still count as cache **hits** for metered-misses billing.

### File Backend Environment Variables

```bash
Expand Down Expand Up @@ -323,7 +350,7 @@ def secure_function():
| `dev()` | ✓ | ❌ | ❌ | 100 MB | Verbose logs, no Prometheus |
| `production()` | ✓ | ✓ | ✓ | 100 MB | Full observability |
| `secure()` | ✓ | ✓ | ✓ | 100 MB | AES-256-GCM encryption required |
| `io()` | ✓ | ✓ | ✓ | 100 MB | Managed SaaS backend (closed alpha — [request access](https://cachekit.io)) |
| `io()` | ✓ | ✓ | ✓ | 100 MB | Managed SaaS backend (closed alpha — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) |

---

Expand Down
86 changes: 75 additions & 11 deletions src/cachekit/backends/cachekitio/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@
# preferring the header. See protocol spec/saas-api.md (DELETE .../lock).
LOCK_ID_HEADER = "X-CacheKit-Lock-Id"

# Protocol-canonical TTL header (spec/saas-api.md). The legacy X-TTL is sent
# alongside it until the dual-reading server (saas#245) is deployed everywhere;
# sending both is value-identical and safe against either server generation.
# TODO(LAB-381 follow-up): drop X-TTL once saas#245 is live in prod.
TTL_HEADER = "X-CacheKit-TTL"
LEGACY_TTL_HEADER = "X-TTL"

# Stale-while-revalidate (LAB-381, spec/saas-api.md#stale-while-revalidate).
# STALE_TTL_HEADER rides PUTs to open a stale-grace window past the fresh TTL;
# FRESHNESS_HEADER labels every GET/HEAD 200 as fresh|stale. Pre-SWR servers
# ignore the former and never emit the latter.
STALE_TTL_HEADER = "X-CacheKit-Stale-TTL"
FRESHNESS_HEADER = "X-CacheKit-Freshness"


def _inject_metrics_headers(stats: _FunctionStats | None) -> dict[str, str]:
"""Extract cache metrics and format as HTTP headers.
Expand Down Expand Up @@ -335,22 +349,62 @@ def get(self, key: str) -> bytes | None:
return None
raise

def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
@staticmethod
def _is_stale(response: httpx.Response) -> bool:
"""Map the X-CacheKit-Freshness header to staleness (spec/saas-api.md).

Absent header = fresh (pre-SWR server); unrecognized value = stale
(revalidation is the conservative action). Tokens are lowercase and
case-sensitive per spec.
"""
value = response.headers.get(FRESHNESS_HEADER)
return value is not None and value != "fresh"

def get_with_freshness(self, key: str) -> tuple[bytes, bool] | None:
"""Retrieve value plus its SWR freshness (sync).

Returns:
``(value, is_stale)`` on a hit — ``is_stale`` is True only for an
entry in its stale-grace window (LAB-381) — or None on a miss.

Raises:
BackendError: If operation fails (network, auth, etc.)
"""
try:
response = self._request_sync("GET", key)
return response.content, self._is_stale(response)
except BackendError as exc:
if exc.original_exception and isinstance(exc.original_exception, httpx.HTTPStatusError):
if exc.original_exception.response.status_code == 404:
return None
raise

def set(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None:
"""Store value in cache (sync).

Args:
key: Cache key
value: Bytes to cache
ttl: Time-to-live in seconds (optional)
stale_ttl: Stale-grace window in seconds past the fresh TTL
(LAB-381 SWR). Only honoured alongside an explicit ``ttl``;
pre-SWR servers ignore it.

Raises:
BackendError: If operation fails
"""
headers = {}
if ttl is not None:
headers["X-TTL"] = str(ttl)
self._request_sync("PUT", key, content=value, headers=self._set_headers(ttl, stale_ttl))

self._request_sync("PUT", key, content=value, headers=headers)
@staticmethod
def _set_headers(ttl: int | None, stale_ttl: int | None) -> dict[str, str]:
"""PUT timing headers: canonical + legacy TTL (dual-send until saas#245 deploys), stale window."""
headers: dict[str, str] = {}
if ttl is not None:
headers[TTL_HEADER] = str(ttl)
headers[LEGACY_TTL_HEADER] = str(ttl)
if stale_ttl is not None and stale_ttl > 0 and ttl is not None:
headers[STALE_TTL_HEADER] = str(stale_ttl)
return headers

def delete(self, key: str) -> bool:
"""Delete key from cache (sync).
Expand Down Expand Up @@ -457,22 +511,32 @@ async def get_async(self, key: str) -> bytes | None:
return None
raise

async def set_async(self, key: str, value: bytes, ttl: int | None = None) -> None:
async def get_with_freshness_async(self, key: str) -> tuple[bytes, bool] | None:
"""Retrieve value plus its SWR freshness (async). See :meth:`get_with_freshness`."""
try:
response = await self._request_async("GET", key)
return response.content, self._is_stale(response)
except BackendError as exc:
if exc.original_exception and isinstance(exc.original_exception, httpx.HTTPStatusError):
if exc.original_exception.response.status_code == 404:
return None
raise

async def set_async(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None:
"""Store value in cache (async).

Args:
key: Cache key
value: Bytes to cache
ttl: Time-to-live in seconds (optional)
stale_ttl: Stale-grace window in seconds past the fresh TTL
(LAB-381 SWR). Only honoured alongside an explicit ``ttl``;
pre-SWR servers ignore it.

Raises:
BackendError: If operation fails
"""
headers = {}
if ttl is not None:
headers["X-TTL"] = str(ttl)

await self._request_async("PUT", key, content=value, headers=headers)
await self._request_async("PUT", key, content=value, headers=self._set_headers(ttl, stale_ttl))

async def delete_async(self, key: str) -> bool:
"""Delete key from cache (async).
Expand Down
Loading
Loading