81 lines
3.8 KiB
Python
81 lines
3.8 KiB
Python
"""Fail-closed decoding for one Gitea API delivery attempt.
|
|
|
|
Unexpected/malformed acknowledgements can follow a committed mutation: callers
|
|
must not blindly retry. They may perform caller-initiated read-only reconciliation.
|
|
"""
|
|
import json
|
|
import ssl
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
MAX_RESPONSE_BYTES = 1024 * 1024
|
|
|
|
|
|
class DeliveryResponseError(Exception):
|
|
"""Sanitized response failure; message never includes server content."""
|
|
|
|
|
|
class InvalidExpectationError(DeliveryResponseError, ValueError):
|
|
"""The caller supplied an invalid endpoint-specific status expectation."""
|
|
|
|
|
|
class RejectedResponseError(DeliveryResponseError):
|
|
"""Known auth/redirect or other non-success rejection; do not retry blindly."""
|
|
|
|
|
|
class AcknowledgementUncertainError(DeliveryResponseError):
|
|
"""Mutation may have committed; do not retry; reconcile read-only if needed."""
|
|
|
|
|
|
def _validate_expected_status(expected_status):
|
|
if (isinstance(expected_status, bool) or not isinstance(expected_status, int)
|
|
or expected_status < 200 or expected_status > 299):
|
|
raise InvalidExpectationError("expected_status must be an explicit success status integer")
|
|
|
|
|
|
def _reject_constant(_value):
|
|
raise ValueError("non-standard JSON constant")
|
|
|
|
|
|
def decode_response(status, body, *, expected_status):
|
|
"""Require the exact expected status and a bounded JSON object body."""
|
|
_validate_expected_status(expected_status)
|
|
if isinstance(status, bool) or not isinstance(status, int):
|
|
raise AcknowledgementUncertainError("invalid response status; do not retry; reconcile read-only")
|
|
if 300 <= status <= 399 or status in (401, 403):
|
|
raise RejectedResponseError("request rejected or redirected; do not retry blindly")
|
|
if status != expected_status:
|
|
raise AcknowledgementUncertainError("unexpected response status; do not retry; reconcile read-only")
|
|
if not isinstance(body, (bytes, bytearray)) or len(body) > MAX_RESPONSE_BYTES:
|
|
raise AcknowledgementUncertainError("invalid or oversized response; do not retry; reconcile read-only")
|
|
try:
|
|
value = json.loads(bytes(body).decode("utf-8"), parse_constant=_reject_constant)
|
|
except (UnicodeError, ValueError, RecursionError):
|
|
raise AcknowledgementUncertainError("invalid JSON response; do not retry; reconcile read-only") from None
|
|
if not isinstance(value, dict):
|
|
raise AcknowledgementUncertainError("response JSON must be an object; do not retry; reconcile read-only")
|
|
return value
|
|
|
|
|
|
def deliver_once(send, *, expected_status):
|
|
"""Validate expectation, invoke injected sender exactly once, decode its (status, bytes)."""
|
|
_validate_expected_status(expected_status)
|
|
try:
|
|
result = send()
|
|
except (RejectedResponseError, ssl.SSLCertVerificationError):
|
|
raise RejectedResponseError("transport security rejected; do not retry blindly") from None
|
|
except HTTPError as error:
|
|
if 300 <= error.code <= 399 or error.code in (401, 403):
|
|
raise RejectedResponseError("request rejected or redirected; do not retry blindly") from None
|
|
raise AcknowledgementUncertainError("HTTP acknowledgement uncertain; do not retry; reconcile read-only") from None
|
|
except URLError as error:
|
|
if isinstance(error.reason, ssl.SSLCertVerificationError):
|
|
raise RejectedResponseError("TLS verification rejected; do not retry blindly") from None
|
|
raise AcknowledgementUncertainError("transport failure; do not retry; reconcile read-only") from None
|
|
except Exception:
|
|
raise AcknowledgementUncertainError("transport failure; do not retry; reconcile read-only") from None
|
|
try:
|
|
status, body = result
|
|
except Exception:
|
|
raise AcknowledgementUncertainError("invalid transport response; do not retry; reconcile read-only") from None
|
|
return decode_response(status, body, expected_status=expected_status)
|