feat(edifabric): surface retry_after_seconds on quota-blocked responses
EdifabricError now carries an optional retry_after_seconds kwarg parsed from the upstream Retry-After header (API Management quota policy always sends this on 429/403 quota responses). The spot-check driver threads the value into each transport_error entry of spot-check.json so the operator sees the exact moment quota replenishes rather than parsing it out of the response body. Adds a MockTransport test confirming retry_after_seconds is set when the upstream sends Retry-After. Existing callers are unaffected (retry_after_seconds defaults to None and is keyword-only).
This commit is contained in:
@@ -63,13 +63,22 @@ class EdifabricError(RuntimeError):
|
|||||||
``message`` keys for 4xx, or a string for 5xx / network
|
``message`` keys for 4xx, or a string for 5xx / network
|
||||||
errors. Preserved verbatim so the caller can surface it
|
errors. Preserved verbatim so the caller can surface it
|
||||||
to the operator.
|
to the operator.
|
||||||
|
retry_after_seconds: When the upstream sent a ``Retry-After``
|
||||||
|
header (the API Management quota policy does), the value
|
||||||
|
in seconds. ``None`` if absent. Quota-blocked callers can
|
||||||
|
surface this so the operator knows when to retry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, status_code: int, body: Any) -> None:
|
def __init__(
|
||||||
|
self, status_code: int, body: Any, *, retry_after_seconds: int | None = None
|
||||||
|
) -> None:
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.body = body
|
self.body = body
|
||||||
|
self.retry_after_seconds = retry_after_seconds
|
||||||
body_repr = repr(body) if not isinstance(body, str) else body
|
body_repr = repr(body) if not isinstance(body, str) else body
|
||||||
msg = f"Edifabric API returned {status_code}: {body_repr}"
|
msg = f"Edifabric API returned {status_code}: {body_repr}"
|
||||||
|
if retry_after_seconds is not None:
|
||||||
|
msg += f" (retry after {retry_after_seconds}s)"
|
||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,7 +155,11 @@ def read_interchange(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
if not (200 <= resp.status_code < 300):
|
if not (200 <= resp.status_code < 300):
|
||||||
raise EdifabricError(resp.status_code, _safe_body(resp))
|
raise EdifabricError(
|
||||||
|
resp.status_code,
|
||||||
|
_safe_body(resp),
|
||||||
|
retry_after_seconds=_retry_after_seconds(resp),
|
||||||
|
)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if not isinstance(data, list) or not data:
|
if not isinstance(data, list) or not data:
|
||||||
raise EdifabricError(
|
raise EdifabricError(
|
||||||
@@ -187,7 +200,11 @@ def validate_interchange(x12_json: dict, *, api_key: str | None = None) -> dict:
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
if not (200 <= resp.status_code < 300):
|
if not (200 <= resp.status_code < 300):
|
||||||
raise EdifabricError(resp.status_code, _safe_body(resp))
|
raise EdifabricError(
|
||||||
|
resp.status_code,
|
||||||
|
_safe_body(resp),
|
||||||
|
retry_after_seconds=_retry_after_seconds(resp),
|
||||||
|
)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
@@ -214,3 +231,21 @@ def _safe_body(resp: httpx.Response) -> Any:
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
text = resp.text
|
text = resp.text
|
||||||
return text if text else f"<empty {resp.status_code} response>"
|
return text if text else f"<empty {resp.status_code} response>"
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_seconds(resp: httpx.Response) -> int | None:
|
||||||
|
"""Parse the ``Retry-After`` header into integer seconds.
|
||||||
|
|
||||||
|
Returns ``None`` if the header is absent (and the caller should
|
||||||
|
fall back to its own backoff). The upstream API Management sends
|
||||||
|
this header on quota-blocked responses; treating it as authoritative
|
||||||
|
lets the caller sleep exactly until quota replenishes rather than
|
||||||
|
guessing.
|
||||||
|
"""
|
||||||
|
raw = resp.headers.get("Retry-After") or resp.headers.get("retry-after")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
@@ -80,6 +80,12 @@ def validate_spot_check_files(paths: list[Path]) -> list[dict]:
|
|||||||
try:
|
try:
|
||||||
result = validate_edi(body)
|
result = validate_edi(body)
|
||||||
except EdifabricError as exc:
|
except EdifabricError as exc:
|
||||||
|
tx: dict = {
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"body": exc.body,
|
||||||
|
}
|
||||||
|
if exc.retry_after_seconds is not None:
|
||||||
|
tx["retry_after_seconds"] = exc.retry_after_seconds
|
||||||
out.append({
|
out.append({
|
||||||
"file": str(p),
|
"file": str(p),
|
||||||
"passed": False,
|
"passed": False,
|
||||||
@@ -87,10 +93,7 @@ def validate_spot_check_files(paths: list[Path]) -> list[dict]:
|
|||||||
f"EdifabricError {exc.status_code}: {exc.body!r}"
|
f"EdifabricError {exc.status_code}: {exc.body!r}"
|
||||||
),
|
),
|
||||||
"result": None,
|
"result": None,
|
||||||
"transport_error": {
|
"transport_error": tx,
|
||||||
"status_code": exc.status_code,
|
|
||||||
"body": exc.body,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
# Re-raise so the caller can't silently absorb the failure
|
# Re-raise so the caller can't silently absorb the failure
|
||||||
# (the original scratch driver caught + fallback'd which
|
# (the original scratch driver caught + fallback'd which
|
||||||
|
|||||||
@@ -136,6 +136,28 @@ def test_validate_interchange_raises_on_5xx():
|
|||||||
assert "upstream overloaded" in str(exc_info.value.body)
|
assert "upstream overloaded" in str(exc_info.value.body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_interchange_raises_with_retry_after_when_quota_blocked():
|
||||||
|
"""On HTTP 403 with a ``Retry-After`` header (API Management quota
|
||||||
|
policy), the raised EdifabricError exposes ``retry_after_seconds``
|
||||||
|
so callers can sleep exactly until quota replenishes."""
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
403,
|
||||||
|
headers={"Retry-After": "50492"},
|
||||||
|
json={"statusCode": 403,
|
||||||
|
"message": "Out of call volume quota."},
|
||||||
|
)
|
||||||
|
|
||||||
|
_install_factory(handler)
|
||||||
|
with pytest.raises(edifabric.EdifabricError) as exc_info:
|
||||||
|
edifabric.read_interchange(b"ISA*...", api_key=_TEST_KEY)
|
||||||
|
err = exc_info.value
|
||||||
|
assert err.status_code == 403
|
||||||
|
assert err.retry_after_seconds == 50492
|
||||||
|
assert "Out of call volume quota" in str(err.body)
|
||||||
|
|
||||||
|
|
||||||
# --- validate_edi (composed) ------------------------------------------
|
# --- validate_edi (composed) ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user