mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 03:55:05 +00:00
POST/DELETE /api/dashboard/drain refuses new sessions and jobs (SERVER_DRAINING, 409) and warns open sessions. A CDN can replace a 5xx body, so server errors are now 422, retryable ones and the tunnel check 424, and a new middleware turns any 5xx into 422. BREAKING CHANGE: INTERNAL_ERROR, DOWNLOAD_ERROR, WORKER_ERROR and the fallback return 422 instead of 500; NETWORK_ERROR and SERVICE_UNAVAILABLE return 424 instead of 503; /api/tunnel/check failures return 424 instead of 502.
221 lines
8.6 KiB
Python
221 lines
8.6 KiB
Python
"""Unit tests for unshackle.core.api.errors."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from unshackle.core.api.errors import (
|
|
APIError,
|
|
APIErrorCode,
|
|
build_error_response,
|
|
categorize_exception,
|
|
handle_api_exception,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _body(resp) -> dict:
|
|
return json.loads(resp.body.decode("utf-8"))
|
|
|
|
|
|
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.INVALID_PROXY: 400,
|
|
APIErrorCode.INVALID_PARAMETERS: 400,
|
|
APIErrorCode.AUTH_FAILED: 401,
|
|
APIErrorCode.FORBIDDEN: 403,
|
|
APIErrorCode.GEOFENCE: 403,
|
|
APIErrorCode.SERVER_CDM_CAPPED: 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.CONFLICT: 409,
|
|
APIErrorCode.RATE_LIMITED: 429,
|
|
APIErrorCode.INTERNAL_ERROR: 422,
|
|
APIErrorCode.DOWNLOAD_ERROR: 422,
|
|
APIErrorCode.WORKER_ERROR: 422,
|
|
APIErrorCode.SERVICE_ERROR: 422,
|
|
APIErrorCode.DRM_ERROR: 422,
|
|
APIErrorCode.NETWORK_ERROR: 424,
|
|
APIErrorCode.SERVICE_UNAVAILABLE: 424,
|
|
APIErrorCode.SERVER_DRAINING: 409,
|
|
}
|
|
# 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
|
|
# the edge proxy can replace a 5xx body, so no code may default to one
|
|
assert all(status < 500 for status in cases.values())
|
|
|
|
|
|
def test_api_error_explicit_http_status_overrides_default() -> None:
|
|
err = APIError(APIErrorCode.INVALID_INPUT, "x", http_status=418)
|
|
assert err.http_status == 418
|
|
|
|
|
|
def test_build_error_response_from_api_error() -> None:
|
|
err = APIError(
|
|
APIErrorCode.SESSION_NOT_FOUND,
|
|
"no such session",
|
|
details={"session_id": "abc"},
|
|
retryable=False,
|
|
)
|
|
resp = build_error_response(err)
|
|
assert resp.status == 404
|
|
body = _body(resp)
|
|
assert body["status"] == "error"
|
|
assert body["error_code"] == "SESSION_NOT_FOUND"
|
|
assert body["message"] == f"no such session (ref {body['ref']})"
|
|
assert body["details"] == {"session_id": "abc"}
|
|
assert "retryable" not in body
|
|
assert "debug_info" not in body
|
|
assert "timestamp" in body
|
|
|
|
|
|
def test_build_error_response_retryable_flag() -> None:
|
|
err = APIError(APIErrorCode.NETWORK_ERROR, "boom", retryable=True)
|
|
body = _body(build_error_response(err))
|
|
assert body["retryable"] is True
|
|
|
|
|
|
def test_build_error_response_from_generic_exception() -> None:
|
|
resp = build_error_response(RuntimeError("oops"))
|
|
assert resp.status == 422
|
|
body = _body(resp)
|
|
assert body["error_code"] == "INTERNAL_ERROR"
|
|
assert body["message"] == f"oops (ref {body['ref']})"
|
|
|
|
|
|
def test_build_error_response_debug_mode_includes_traceback() -> None:
|
|
try:
|
|
raise ValueError("kaboom")
|
|
except ValueError as e:
|
|
resp = build_error_response(e, debug_mode=True, extra_debug_info={"foo": "bar"})
|
|
body = _body(resp)
|
|
assert body["debug_info"]["exception_type"] == "ValueError"
|
|
assert "traceback" in body["debug_info"]
|
|
assert body["debug_info"]["foo"] == "bar"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc, expected_code",
|
|
[
|
|
(Exception("Invalid credentials provided"), APIErrorCode.AUTH_FAILED),
|
|
(Exception("Connection refused"), APIErrorCode.NETWORK_ERROR),
|
|
(TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR),
|
|
(Exception("Not available in your region"), APIErrorCode.GEOFENCE),
|
|
(Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE),
|
|
(
|
|
Exception(
|
|
"Service is not available in your region (GB). Pass --proxy with a proxy outside the blocked regions."
|
|
),
|
|
APIErrorCode.GEOFENCE,
|
|
),
|
|
(Exception("Title not found"), APIErrorCode.NOT_FOUND),
|
|
(Exception("HTTP 429 too many requests"), APIErrorCode.RATE_LIMITED),
|
|
(Exception("DRM license fetch failed"), APIErrorCode.DRM_ERROR),
|
|
(Exception("503 service unavailable"), APIErrorCode.SERVICE_UNAVAILABLE),
|
|
(ValueError("malformed body"), APIErrorCode.INVALID_INPUT),
|
|
(RuntimeError("totally novel failure xyz"), APIErrorCode.INTERNAL_ERROR),
|
|
],
|
|
)
|
|
def test_categorize_exception(exc: Exception, expected_code: APIErrorCode) -> None:
|
|
api_err = categorize_exception(exc, context={"service": "EXAMPLE"})
|
|
assert api_err.error_code == expected_code
|
|
assert api_err.details.get("service") == "EXAMPLE"
|
|
|
|
|
|
def test_categorize_preserves_context() -> None:
|
|
api_err = categorize_exception(ValueError("bad"), context={"op": "search"})
|
|
assert api_err.details["op"] == "search"
|
|
|
|
|
|
def test_handle_api_exception_with_api_error_preserves_code() -> None:
|
|
err = APIError(APIErrorCode.TRACK_NOT_FOUND, "no track")
|
|
resp = handle_api_exception(err)
|
|
body = _body(resp)
|
|
assert body["error_code"] == "TRACK_NOT_FOUND"
|
|
assert resp.status == 404
|
|
|
|
|
|
def test_handle_api_exception_categorizes_generic() -> None:
|
|
resp = handle_api_exception(ConnectionError("oops"))
|
|
body = _body(resp)
|
|
assert body["error_code"] == "NETWORK_ERROR"
|
|
|
|
|
|
# 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"), 424, "NETWORK_ERROR", True),
|
|
(TimeoutError("read timeout"), 424, "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"), 422, "DRM_ERROR", False),
|
|
(Exception("503 service unavailable"), 424, "SERVICE_UNAVAILABLE", True),
|
|
(ValueError("malformed body"), 400, "INVALID_INPUT", False),
|
|
(RuntimeError("totally novel failure xyz"), 422, "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"] == f"slow down (ref {body['ref']})"
|
|
assert body["details"] == {"retry_after": 5}
|
|
assert body["retryable"] is True
|
|
|
|
|
|
async def test_outer_middleware_never_answers_5xx(aiohttp_client) -> None:
|
|
from aiohttp import web
|
|
|
|
from unshackle.core.api.routes import no_5xx_middleware
|
|
|
|
async def broken(request: web.Request) -> web.Response:
|
|
raise KeyError(request.headers["X-Secret-Key"])
|
|
|
|
async def subapp_500(_request: web.Request) -> web.Response:
|
|
return web.json_response({"status": 500, "message": "device error"}, status=500)
|
|
|
|
app = web.Application(middlewares=[no_5xx_middleware])
|
|
app.router.add_get("/broken", broken)
|
|
app.router.add_get("/subapp", subapp_500)
|
|
client = await aiohttp_client(app)
|
|
|
|
resp = await client.get("/broken", headers={"X-Secret-Key": "leaky-key"})
|
|
assert resp.status == 422
|
|
body = await resp.json()
|
|
assert body["error_code"] == "INTERNAL_ERROR" and "leaky-key" not in json.dumps(body)
|
|
|
|
resp = await client.get("/subapp")
|
|
assert resp.status == 422 and (await resp.json())["message"] == "device error"
|
|
assert (await client.get("/missing")).status == 404
|