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
14 changes: 14 additions & 0 deletions src/cachekit/backends/cachekitio/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def classify_http_error(
Classification rules:
- HTTP 401/403: AUTHENTICATION (alert ops, don't retry)
- HTTP 429: TRANSIENT (rate limit, exponential backoff)
- HTTP 413: PERMANENT (value too large — retrying never helps)
- HTTP 5xx: TRANSIENT (server error, retry)
- HTTP 4xx: PERMANENT (client error, don't retry)
- TimeoutException: TIMEOUT (configurable retry)
Expand Down Expand Up @@ -75,6 +76,19 @@ def classify_http_error(
key=key,
)

# PERMANENT: value too large. A 413 would already classify PERMANENT via the generic
# 4xx branch below — this dedicated branch exists only to give an ACTIONABLE message
# ("value too large") instead of "Client error: HTTP 413". Retrying never helps (the
# value must shrink), so the decorator degrades: runs uncached, once.
if status == 413:
return BackendError(
"Value too large for cachekit.io backend (HTTP 413): value exceeds the server's maximum cache value size",
error_type=BackendErrorType.PERMANENT,
original_exception=exc,
operation=operation,
key=key,
)

# PERMANENT: Client errors (don't retry)
if 400 <= status < 500:
return BackendError(
Expand Down
79 changes: 79 additions & 0 deletions tests/integration/saas/test_sdk_data_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,85 @@ def get_large_value():
assert len(result2) == len(large_string)


# ============================================================================
# Large-value tests (P1) — multi-MB values must round-trip intact through the SDK,
# and values above the API's maximum size must be rejected with a permanent 413.
# These use INCOMPRESSIBLE random bytes so the payload is genuinely multi-MB on the
# wire (a repetitive string would compress to ~KB and never exercise the large path).
# ============================================================================


def _size_limit_key(namespace: str, name: str) -> str:
"""Build a valid cache key: ns:<ns>:func:<module.qualname>:args:<hash>:<meta>."""
import hashlib

args_hash = hashlib.blake2b(name.encode(), digest_size=32).hexdigest()
return f"ns:{namespace}:func:tests.e2e.size_limits.{name}:args:{args_hash}:1s"


def test_large_value_roundtrip(cache_io_decorator, clean_cache):
"""A multi-MB value round-trips byte-identically through the SDK.

Priority: P1
"""
import os

blob = os.urandom(13_500_000) # ~13.5 MB, incompressible → genuinely multi-MB on the wire

@cache_io_decorator
def get_blob():
return blob

assert get_blob() == blob # miss → compute → store
assert get_blob() == blob # hit → exact bytes preserved


def test_large_value_roundtrip_and_overwrite_via_http(http_client, sdk_config, unique_namespace):
"""Direct HTTP (bypasses SDK L1/serialization): a multi-MB value round-trips
byte-identically, and overwriting it with a small value returns exactly the small
value (no stale bytes).

Priority: P1
"""
import os

# Raw key in the path (colons unencoded) — matches how the SDK builds the URL
# (backend.py: f"/v1/cache/{key}"); the API splits the path on literal ':'.
# (Percent-encoding the colons fails key-format validation.)
key = _size_limit_key(unique_namespace, "roundtrip")
url = f"{sdk_config['api_url']}/v1/cache/{key}"
headers = {"Content-Type": "application/octet-stream", "X-TTL": "300"}

big = os.urandom(13_500_000)
put = http_client.put(url, data=big, headers=headers)
assert put.status_code == 200, put.text
got = http_client.get(url)
assert got.status_code == 200
assert got.content == big # round-trip is byte-exact

# Overwrite large → small: GET must return exactly the small value (no stale bytes).
small = os.urandom(1024)
put2 = http_client.put(url, data=small, headers=headers)
assert put2.status_code == 200, put2.text
got2 = http_client.get(url)
assert got2.status_code == 200
assert got2.content == small


def test_oversized_value_rejected_with_413(http_client, sdk_config, unique_namespace):
"""A value above the API's maximum size is rejected with a clean, permanent 413
(not a 500 the SDK would mis-classify as transient and retry).

Priority: P1
"""
# Raw key in the path (colons unencoded) — see test_large_value_roundtrip_and_overwrite_via_http.
key = _size_limit_key(unique_namespace, "oversized")
url = f"{sdk_config['api_url']}/v1/cache/{key}"
body = b"\x00" * (26 * 1024 * 1024) # exceeds the API maximum value size
resp = http_client.put(url, data=body, headers={"Content-Type": "application/octet-stream"})
assert resp.status_code == 413


# ============================================================================
# Pydantic and Dataclass Tests (P1)
# ============================================================================
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/backends/test_cachekitio_error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ def test_client_errors_are_permanent(self, status: int) -> None:
result = classify_http_error(exc, response=_response(status))
assert result.error_type == BackendErrorType.PERMANENT

def test_value_too_large_413_is_permanent(self) -> None:
# Regression: a too-large value previously surfaced as a 500 → TRANSIENT and was
# retried 3× before a silent graceful-degrade. 413 must be PERMANENT (retrying
# never helps — the value must shrink) with a clear "too large" message.
exc = Exception("payload too large")
result = classify_http_error(exc, response=_response(413), operation="put")
assert result.error_type == BackendErrorType.PERMANENT
assert "too large" in str(result).lower()


class TestNetworkExceptionClassification:
"""Tests for network-level exception → error type mapping."""
Expand Down
Loading