From 3f5b5e0fe177e58883daf91ca13547db94ab9112 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sun, 21 Jun 2026 14:13:44 -0600 Subject: [PATCH] refactor(api): drop dead error codes, lock error contract harness Remove 4 unused APIErrorCode members; strengthen test_errors.py to pin the full (status, code, retryable) contract before the PRD 05 collapse. --- tests/remote/unit/test_errors.py | 53 +++++++++++++++++++++++++++++++- unshackle/core/api/errors.py | 8 ----- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/tests/remote/unit/test_errors.py b/tests/remote/unit/test_errors.py index 147751cd..b5906431 100644 --- a/tests/remote/unit/test_errors.py +++ b/tests/remote/unit/test_errors.py @@ -17,23 +17,32 @@ def _body(resp) -> dict: def test_api_error_default_http_status_per_code() -> None: + # Every live APIErrorCode and the status a client observes for it. This is the + # status half of the error contract PRD 05 must preserve through the collapse. cases = { APIErrorCode.INVALID_INPUT: 400, APIErrorCode.INVALID_SERVICE: 400, - APIErrorCode.AUTH_REQUIRED: 401, + APIErrorCode.INVALID_PROXY: 400, + APIErrorCode.INVALID_PARAMETERS: 400, APIErrorCode.AUTH_FAILED: 401, APIErrorCode.FORBIDDEN: 403, APIErrorCode.GEOFENCE: 403, APIErrorCode.NOT_FOUND: 404, + APIErrorCode.NO_CONTENT: 404, + APIErrorCode.JOB_NOT_FOUND: 404, APIErrorCode.SESSION_NOT_FOUND: 404, APIErrorCode.TRACK_NOT_FOUND: 404, APIErrorCode.RATE_LIMITED: 429, APIErrorCode.INTERNAL_ERROR: 500, + APIErrorCode.DOWNLOAD_ERROR: 500, + APIErrorCode.WORKER_ERROR: 500, APIErrorCode.SERVICE_ERROR: 502, APIErrorCode.DRM_ERROR: 502, APIErrorCode.NETWORK_ERROR: 503, APIErrorCode.SERVICE_UNAVAILABLE: 503, } + # Lock that the cases above are the *complete* live set — a new/removed code trips this. + assert {c.name for c in cases} == {c.name for c in APIErrorCode} for code, expected in cases.items(): assert APIError(code, "x").http_status == expected, code @@ -125,3 +134,45 @@ def test_handle_api_exception_categorizes_generic() -> None: resp = handle_api_exception(ConnectionError("oops")) body = _body(resp) assert body["error_code"] == "NETWORK_ERROR" + + +# --- Error contract harness (PRD 05 gate) --------------------------------- +# Locks the FULL observable response — (http status, error_code, retryable) — that +# a client sees for a generic (non-APIError) exception flowing through the +# handle_api_exception funnel that every route's `except Exception` path calls. +# This is the equivalence reference: after the categorize_exception collapse, +# any tuple that changes here is a client-visible behaviour change, not a refactor. +@pytest.mark.parametrize( + "exc, status, code, retryable", + [ + (Exception("Invalid credentials provided"), 401, "AUTH_FAILED", False), + (Exception("Connection refused"), 503, "NETWORK_ERROR", True), + (TimeoutError("read timeout"), 503, "NETWORK_ERROR", True), + (Exception("Not available in your region"), 403, "GEOFENCE", False), + (Exception("Title not found"), 404, "NOT_FOUND", False), + (Exception("HTTP 429 too many requests"), 429, "RATE_LIMITED", True), + (Exception("DRM license fetch failed"), 502, "DRM_ERROR", False), + (Exception("503 service unavailable"), 503, "SERVICE_UNAVAILABLE", True), + (ValueError("malformed body"), 400, "INVALID_INPUT", False), + (RuntimeError("totally novel failure xyz"), 500, "INTERNAL_ERROR", False), + ], +) +def test_error_contract_generic_exception(exc: Exception, status: int, code: str, retryable: bool) -> None: + resp = handle_api_exception(exc) + assert resp.status == status + body = _body(resp) + assert body["error_code"] == code + # retryable is only serialized when True (build_error_response omits it otherwise). + assert body.get("retryable", False) is retryable + + +def test_error_contract_apierror_passthrough_full_shape() -> None: + # APIError carriers keep status + code + message + details + retryable verbatim. + err = APIError(APIErrorCode.RATE_LIMITED, "slow down", details={"retry_after": 5}, retryable=True) + resp = handle_api_exception(err) + assert resp.status == 429 + body = _body(resp) + assert body["error_code"] == "RATE_LIMITED" + assert body["message"] == "slow down" + assert body["details"] == {"retry_after": 5} + assert body["retryable"] is True diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py index 2d5a106f..57a11172 100644 --- a/unshackle/core/api/errors.py +++ b/unshackle/core/api/errors.py @@ -21,14 +21,10 @@ class APIErrorCode(str, Enum): # Client errors (4xx) INVALID_INPUT = "INVALID_INPUT" # Missing or malformed request data INVALID_SERVICE = "INVALID_SERVICE" # Unknown service name - INVALID_TITLE_ID = "INVALID_TITLE_ID" # Invalid or malformed title ID - INVALID_PROFILE = "INVALID_PROFILE" # Profile doesn't exist INVALID_PROXY = "INVALID_PROXY" # Invalid proxy specification - INVALID_LANGUAGE = "INVALID_LANGUAGE" # Invalid language code INVALID_PARAMETERS = "INVALID_PARAMETERS" # Invalid download parameters AUTH_FAILED = "AUTH_FAILED" # Authentication failure (invalid credentials/cookies) - AUTH_REQUIRED = "AUTH_REQUIRED" # Missing authentication FORBIDDEN = "FORBIDDEN" # Action not allowed GEOFENCE = "GEOFENCE" # Content not available in region @@ -84,13 +80,9 @@ class APIError(Exception): # 400 Bad Request APIErrorCode.INVALID_INPUT: 400, APIErrorCode.INVALID_SERVICE: 400, - APIErrorCode.INVALID_TITLE_ID: 400, - APIErrorCode.INVALID_PROFILE: 400, APIErrorCode.INVALID_PROXY: 400, - APIErrorCode.INVALID_LANGUAGE: 400, APIErrorCode.INVALID_PARAMETERS: 400, # 401 Unauthorized - APIErrorCode.AUTH_REQUIRED: 401, APIErrorCode.AUTH_FAILED: 401, # 403 Forbidden APIErrorCode.FORBIDDEN: 403,