diff --git a/src/cachekit/backends/cachekitio/error_handler.py b/src/cachekit/backends/cachekitio/error_handler.py index 45bcaca..243752e 100644 --- a/src/cachekit/backends/cachekitio/error_handler.py +++ b/src/cachekit/backends/cachekitio/error_handler.py @@ -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) @@ -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( diff --git a/tests/integration/saas/test_sdk_data_handling.py b/tests/integration/saas/test_sdk_data_handling.py index d3a2bb4..627c261 100644 --- a/tests/integration/saas/test_sdk_data_handling.py +++ b/tests/integration/saas/test_sdk_data_handling.py @@ -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::func::args::.""" + 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) # ============================================================================ diff --git a/tests/unit/backends/test_cachekitio_error_handler.py b/tests/unit/backends/test_cachekitio_error_handler.py index 1111126..ea53303 100644 --- a/tests/unit/backends/test_cachekitio_error_handler.py +++ b/tests/unit/backends/test_cachekitio_error_handler.py @@ -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."""