test: cover defensive branches in the new fallback helpers

codecov/patch flagged 93.16% vs 94.12% target on the previous commit
— the ~1% miss was the defensive branches in `build_fallback_attempt`
and `build_aggregate_failure_response` that the integration tests in
`test_operations.py` don't naturally exercise (odd status codes, the
contract-violation fallback, the oversize-message cap, empty attempts
list).

Add 7 targeted unit tests in `test/kohakuhub/api/fallback/test_utils.py`:

- `_categorize_status` maps each of 401 / 403 / 404 / 410 / 503 to the
  right category.
- An unclassifiable status (418) lands in `CATEGORY_OTHER` so the
  aggregate shape stays consistent.
- Contract-violation call (no response / timeout / network supplied)
  returns a safe default rather than throwing.
- Oversized upstream bodies get truncated under the per-attempt cap.
- `timeout=...` and `network=...` paths record the right categories
  and surface the exception message.
- Empty-attempts aggregate is a well-formed 502 rather than a KeyError
  or a misleading 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
narugo1992
2026-04-24 15:27:14 +08:00
parent 6684620a5c
commit da0b552cad

View File

@@ -152,3 +152,117 @@ def test_strip_xet_response_headers_is_noop_without_xet_signals():
strip_xet_response_headers(headers)
assert headers == original
# -------------------------------------------------------------------------
# Aggregated fallback failure helpers (new for the upstream-error-
# classification fix). The loop-level behavior is already covered by
# test_operations; these unit tests cover the edge-case branches that
# only defensive callers would hit.
# -------------------------------------------------------------------------
from kohakuhub.api.fallback.utils import (
CATEGORY_AUTH,
CATEGORY_FORBIDDEN,
CATEGORY_NETWORK,
CATEGORY_NOT_FOUND,
CATEGORY_OTHER,
CATEGORY_SERVER,
CATEGORY_TIMEOUT,
build_aggregate_failure_response,
build_fallback_attempt,
)
def _plain_response(status: int, body: bytes = b"") -> httpx.Response:
return httpx.Response(
status,
content=body,
request=httpx.Request("HEAD", "https://src.local/f"),
)
def test_build_fallback_attempt_categorizes_known_status_codes():
src = {"name": "S", "url": "https://s"}
assert (
build_fallback_attempt(src, response=_plain_response(401))["category"]
== CATEGORY_AUTH
)
assert (
build_fallback_attempt(src, response=_plain_response(403))["category"]
== CATEGORY_FORBIDDEN
)
assert (
build_fallback_attempt(src, response=_plain_response(404))["category"]
== CATEGORY_NOT_FOUND
)
assert (
build_fallback_attempt(src, response=_plain_response(410))["category"]
== CATEGORY_NOT_FOUND
)
assert (
build_fallback_attempt(src, response=_plain_response(503))["category"]
== CATEGORY_SERVER
)
def test_build_fallback_attempt_falls_through_on_unclassifiable_status():
"""Any status that isn't in the enumerated buckets (e.g. an
I'm-a-teapot or an odd client error a mirror might invent) gets the
``CATEGORY_OTHER`` label so the aggregate still has a consistent
shape and the caller can still display the message."""
src = {"name": "S", "url": "https://s"}
attempt = build_fallback_attempt(src, response=_plain_response(418))
assert attempt["category"] == CATEGORY_OTHER
assert attempt["status"] == 418
def test_build_fallback_attempt_contract_violation_returns_safe_default():
"""If the caller passes none of response/timeout/network we still
return a well-formed attempt dict with CATEGORY_OTHER so the
aggregate loop can't swallow an exception path silently."""
attempt = build_fallback_attempt({"name": "S", "url": "https://s"})
assert attempt["status"] is None
assert attempt["category"] == CATEGORY_OTHER
assert attempt["message"] == ""
assert attempt["name"] == "S"
def test_build_fallback_attempt_truncates_very_long_upstream_messages():
"""A pathological upstream that returns a multi-MB error body
cannot be allowed to blow up response headers or body size. The
per-attempt message is capped (see MAX_ATTEMPT_MESSAGE_LEN)."""
src = {"name": "S", "url": "https://s"}
huge = "x" * 5000
attempt = build_fallback_attempt(
src, response=_plain_response(500, body=huge.encode())
)
assert len(attempt["message"]) <= 600 # cap is 500, allow a bit of slack
def test_build_fallback_attempt_records_timeout_without_http_status():
import httpx
src = {"name": "S", "url": "https://s"}
attempt = build_fallback_attempt(src, timeout=httpx.TimeoutException("slow"))
assert attempt["status"] is None
assert attempt["category"] == CATEGORY_TIMEOUT
assert "slow" in attempt["message"]
def test_build_fallback_attempt_records_generic_network_error():
src = {"name": "S", "url": "https://s"}
attempt = build_fallback_attempt(src, network=ConnectionResetError("reset"))
assert attempt["status"] is None
assert attempt["category"] == CATEGORY_NETWORK
assert "reset" in attempt["message"]
def test_build_aggregate_failure_response_empty_attempts_is_generic_502():
"""No recorded attempts is a contract violation (caller should
return None in that case), but we still produce a well-formed 502
rather than a KeyError or a nonsensical 401."""
resp = build_aggregate_failure_response([])
assert resp.status_code == 502
assert resp.headers.get("x-error-code") is None