invonetwork 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invonetwork/__init__.py +95 -0
- invonetwork/errors.py +165 -0
- invonetwork/hooks.py +63 -0
- invonetwork/http.py +323 -0
- invonetwork/py.typed +0 -0
- invonetwork/server.py +771 -0
- invonetwork/types.py +219 -0
- invonetwork/webhooks.py +193 -0
- invonetwork-0.1.0.dist-info/METADATA +492 -0
- invonetwork-0.1.0.dist-info/RECORD +12 -0
- invonetwork-0.1.0.dist-info/WHEEL +4 -0
- invonetwork-0.1.0.dist-info/licenses/LICENSE +18 -0
invonetwork/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""invonetwork -- first-party Python server SDK for the INVO API.
|
|
2
|
+
|
|
3
|
+
Server-side counterpart to the JS web SDK. Holds the game secret and runs every
|
|
4
|
+
server-side money flow (mint player token, initiate send/transfer, currency purchase,
|
|
5
|
+
item purchase) plus server-only reads and webhook verification.
|
|
6
|
+
|
|
7
|
+
Zero runtime dependencies (stdlib only). Python 3.9+.
|
|
8
|
+
|
|
9
|
+
Quickstart::
|
|
10
|
+
|
|
11
|
+
from invonetwork import InvoServer, InvoError, verify_webhook
|
|
12
|
+
|
|
13
|
+
server = InvoServer(
|
|
14
|
+
game_secret=os.environ["INVO_GAME_SECRET"], # server-side only
|
|
15
|
+
base_url="https://sandbox.invo.network/sandbox",
|
|
16
|
+
)
|
|
17
|
+
token = server.mint_player_token(player_email="p@example.com")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .errors import InvoError, error_from_response
|
|
23
|
+
from .hooks import ErrorInfo, Hooks, RequestInfo, ResponseInfo
|
|
24
|
+
from .http import (
|
|
25
|
+
Http,
|
|
26
|
+
HttpRequest,
|
|
27
|
+
HttpResponse,
|
|
28
|
+
Transport,
|
|
29
|
+
TransportError,
|
|
30
|
+
assert_secure_base_url,
|
|
31
|
+
)
|
|
32
|
+
from .server import InvoServer
|
|
33
|
+
from .types import (
|
|
34
|
+
ConfirmPaymentResult,
|
|
35
|
+
CreateCheckoutResult,
|
|
36
|
+
CurrencyBalance,
|
|
37
|
+
InboundPendingItem,
|
|
38
|
+
InboundPendingResult,
|
|
39
|
+
InitiateResult,
|
|
40
|
+
ItemHistoryResult,
|
|
41
|
+
LinkedIdentitiesResult,
|
|
42
|
+
LinkedIdentityEmail,
|
|
43
|
+
Money,
|
|
44
|
+
OrderDetailsResult,
|
|
45
|
+
PlayerBalanceResult,
|
|
46
|
+
PlayerToken,
|
|
47
|
+
PurchaseItemResult,
|
|
48
|
+
PurchaseResult,
|
|
49
|
+
Rail,
|
|
50
|
+
VerificationMethod,
|
|
51
|
+
)
|
|
52
|
+
from .webhooks import WebhookEvent, verify_webhook
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
# Core
|
|
59
|
+
"InvoServer",
|
|
60
|
+
"InvoError",
|
|
61
|
+
"error_from_response",
|
|
62
|
+
"verify_webhook",
|
|
63
|
+
"WebhookEvent",
|
|
64
|
+
# Hooks
|
|
65
|
+
"Hooks",
|
|
66
|
+
"RequestInfo",
|
|
67
|
+
"ResponseInfo",
|
|
68
|
+
"ErrorInfo",
|
|
69
|
+
# Transport (advanced / testing)
|
|
70
|
+
"Http",
|
|
71
|
+
"HttpRequest",
|
|
72
|
+
"HttpResponse",
|
|
73
|
+
"Transport",
|
|
74
|
+
"TransportError",
|
|
75
|
+
"assert_secure_base_url",
|
|
76
|
+
# Result types
|
|
77
|
+
"PlayerToken",
|
|
78
|
+
"InitiateResult",
|
|
79
|
+
"CreateCheckoutResult",
|
|
80
|
+
"PurchaseResult",
|
|
81
|
+
"ConfirmPaymentResult",
|
|
82
|
+
"OrderDetailsResult",
|
|
83
|
+
"PurchaseItemResult",
|
|
84
|
+
"ItemHistoryResult",
|
|
85
|
+
"CurrencyBalance",
|
|
86
|
+
"PlayerBalanceResult",
|
|
87
|
+
"InboundPendingItem",
|
|
88
|
+
"InboundPendingResult",
|
|
89
|
+
"LinkedIdentityEmail",
|
|
90
|
+
"LinkedIdentitiesResult",
|
|
91
|
+
# Aliases
|
|
92
|
+
"Rail",
|
|
93
|
+
"VerificationMethod",
|
|
94
|
+
"Money",
|
|
95
|
+
]
|
invonetwork/errors.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Typed error for every INVO backend failure.
|
|
2
|
+
|
|
3
|
+
The backend error envelope is usually ``{"error": <message>, "code": <STABLE_CODE>}``,
|
|
4
|
+
but a few txn-state/claim errors carry only ``error`` (no ``code``) and some use
|
|
5
|
+
``{"status": "error", "message": ...}``. So ``code`` may be ``None`` -- branch on
|
|
6
|
+
``code`` when present, else fall back to ``message``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
14
|
+
|
|
15
|
+
__all__ = ["InvoError", "error_from_response"]
|
|
16
|
+
|
|
17
|
+
_INSUFFICIENT_RE = re.compile(r"insufficient[_ ]balance", re.IGNORECASE)
|
|
18
|
+
_DUPLICATE_RE = re.compile(r"duplicate", re.IGNORECASE)
|
|
19
|
+
_RECEIVER_NOT_ENROLLED_RE = re.compile(r"receiver_not_enrolled_use_claim_code", re.IGNORECASE)
|
|
20
|
+
_PROMOTABLE_ERROR_RE = re.compile(
|
|
21
|
+
r"^(flow_paused|spending_limit_exceeded|receiver_not_enrolled_use_claim_code)\Z"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvoError(Exception):
|
|
26
|
+
"""The single error type raised by the SDK.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
message: Human-readable description.
|
|
30
|
+
code: Stable machine code from the backend (``code``/``error_code``), when present.
|
|
31
|
+
status: HTTP status (``0`` for client-side validation and network errors).
|
|
32
|
+
body: Raw parsed response body, for debugging / fields not surfaced here.
|
|
33
|
+
request_id: Backend request id (from response headers), when present -- quote it
|
|
34
|
+
in support tickets.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
code: Optional[str]
|
|
38
|
+
status: int
|
|
39
|
+
body: Any
|
|
40
|
+
request_id: Optional[str]
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
*,
|
|
46
|
+
code: Optional[str] = None,
|
|
47
|
+
status: int = 0,
|
|
48
|
+
body: Any = None,
|
|
49
|
+
request_id: Optional[str] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.message = message
|
|
53
|
+
self.code = code
|
|
54
|
+
self.status = status
|
|
55
|
+
self.body = body if body is not None else None
|
|
56
|
+
self.request_id = request_id
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
return self.message
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"InvoError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
|
|
63
|
+
|
|
64
|
+
# ---- Classifiers (mirror the JS getters) ----
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_receiver_not_enrolled(self) -> bool:
|
|
68
|
+
"""True for the "recipient isn't passkey-enrolled, fall back to claim code" signal.
|
|
69
|
+
|
|
70
|
+
Returned in the ``error`` field with NO ``code`` key by some backends.
|
|
71
|
+
"""
|
|
72
|
+
return (
|
|
73
|
+
self.code == "receiver_not_enrolled_use_claim_code"
|
|
74
|
+
or bool(_RECEIVER_NOT_ENROLLED_RE.search(self.message))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_token_expired(self) -> bool:
|
|
79
|
+
"""True if the session/SDK token expired and the caller should re-mint + retry."""
|
|
80
|
+
return self.code == "SDK_TOKEN_EXPIRED"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_enrollment_authorization_required(self) -> bool:
|
|
84
|
+
"""True if enrollment needs the OTP-grant flow (``enrollmentBegin``/``enrollmentVerify``)."""
|
|
85
|
+
return self.code == "ENROLLMENT_REQUIRES_AUTHORIZATION"
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_enrollment_proof_required(self) -> bool:
|
|
89
|
+
"""True if enrolling is blocked because another method exists -- use device link."""
|
|
90
|
+
return self.code == "ENROLLMENT_REQUIRES_PROOF"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_insufficient_balance(self) -> bool:
|
|
94
|
+
"""True if an item purchase failed because the player's balance was too low (400).
|
|
95
|
+
|
|
96
|
+
The backend carries ``required_amount`` + ``current_balance`` on ``body`` for the UI.
|
|
97
|
+
Gated to status 400 so the ``429 insufficient_balance_blocked`` abuse throttle (a
|
|
98
|
+
rate-limit, not a top-up condition) is NOT misclassified as this.
|
|
99
|
+
"""
|
|
100
|
+
if self.status != 400:
|
|
101
|
+
return False
|
|
102
|
+
b = self._body_object()
|
|
103
|
+
return (
|
|
104
|
+
self.code == "INSUFFICIENT_BALANCE"
|
|
105
|
+
or "required_amount" in b
|
|
106
|
+
or bool(_INSUFFICIENT_RE.search(self.message))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_duplicate_request(self) -> bool:
|
|
111
|
+
"""True if an idempotency-keyed request was a duplicate (item purchase -> 409)."""
|
|
112
|
+
return self.code == "DUPLICATE_REQUEST" or (
|
|
113
|
+
self.status == 409 and bool(_DUPLICATE_RE.search(self.message))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def retry_after(self) -> Optional[Union[int, float]]:
|
|
118
|
+
"""Seconds to wait before retrying, when the backend throttled the call (429).
|
|
119
|
+
|
|
120
|
+
Reads ``retry_after`` from the body (numeric or string). ``None`` when absent/invalid.
|
|
121
|
+
"""
|
|
122
|
+
v = self._body_object().get("retry_after")
|
|
123
|
+
n: Optional[Union[int, float]]
|
|
124
|
+
if isinstance(v, bool):
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(v, str):
|
|
127
|
+
try:
|
|
128
|
+
n = float(v)
|
|
129
|
+
except ValueError:
|
|
130
|
+
return None
|
|
131
|
+
elif isinstance(v, (int, float)):
|
|
132
|
+
n = v
|
|
133
|
+
else:
|
|
134
|
+
return None
|
|
135
|
+
if math.isfinite(n) and n >= 0:
|
|
136
|
+
return n
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def _body_object(self) -> Dict[str, Any]:
|
|
140
|
+
"""``body`` as a plain dict, or ``{}`` for non-object bodies (``in`` throws on primitives)."""
|
|
141
|
+
return self.body if isinstance(self.body, dict) else {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def error_from_response(
|
|
145
|
+
status: int, body: Any, request_id: Optional[str] = None
|
|
146
|
+
) -> InvoError:
|
|
147
|
+
"""Build an :class:`InvoError` from a parsed (or text) response body."""
|
|
148
|
+
message = f"INVO request failed (HTTP {status})"
|
|
149
|
+
code: Optional[str] = None
|
|
150
|
+
if isinstance(body, dict):
|
|
151
|
+
if isinstance(body.get("code"), str):
|
|
152
|
+
code = body["code"]
|
|
153
|
+
elif isinstance(body.get("error_code"), str):
|
|
154
|
+
code = body["error_code"]
|
|
155
|
+
if isinstance(body.get("error"), str):
|
|
156
|
+
message = body["error"]
|
|
157
|
+
elif isinstance(body.get("message"), str):
|
|
158
|
+
message = body["message"]
|
|
159
|
+
# A few txn-state errors ship only an `error` token with no code/error_code --
|
|
160
|
+
# promote the known ones so consumers can branch on `err.code`.
|
|
161
|
+
if not code and isinstance(body.get("error"), str) and _PROMOTABLE_ERROR_RE.match(body["error"]):
|
|
162
|
+
code = body["error"]
|
|
163
|
+
elif isinstance(body, str) and body.strip():
|
|
164
|
+
message = body
|
|
165
|
+
return InvoError(message, code=code, status=status, body=body, request_id=request_id)
|
invonetwork/hooks.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Optional observability callbacks (``on_request`` / ``on_response`` / ``on_error``).
|
|
2
|
+
|
|
3
|
+
All hooks are best-effort: a throwing hook is swallowed and never breaks a request.
|
|
4
|
+
|
|
5
|
+
.. note::
|
|
6
|
+
Hook payloads include the request ``url``, which for some calls embeds a player
|
|
7
|
+
email (e.g. balance-by-email). The game secret is sent as a header and is **never**
|
|
8
|
+
passed to hooks -- but redact the ``url`` if you log hook payloads.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .errors import InvoError
|
|
18
|
+
|
|
19
|
+
__all__ = ["RequestInfo", "ResponseInfo", "ErrorInfo", "Hooks"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RequestInfo:
|
|
24
|
+
"""Passed to ``on_request`` before each attempt."""
|
|
25
|
+
|
|
26
|
+
method: str
|
|
27
|
+
url: str
|
|
28
|
+
attempt: int
|
|
29
|
+
"""0-based attempt number (0 = first try, 1 = first retry, ...)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ResponseInfo:
|
|
34
|
+
"""Passed to ``on_response`` after a response is received."""
|
|
35
|
+
|
|
36
|
+
method: str
|
|
37
|
+
url: str
|
|
38
|
+
attempt: int
|
|
39
|
+
status: int
|
|
40
|
+
duration_ms: float
|
|
41
|
+
request_id: Optional[str] = None
|
|
42
|
+
"""Backend request id from the response headers, when present."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ErrorInfo:
|
|
47
|
+
"""Passed to ``on_error`` on a network error or a non-2xx response."""
|
|
48
|
+
|
|
49
|
+
method: str
|
|
50
|
+
url: str
|
|
51
|
+
attempt: int
|
|
52
|
+
error: "InvoError"
|
|
53
|
+
will_retry: bool
|
|
54
|
+
"""Whether the SDK will retry after this error."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Hooks:
|
|
59
|
+
"""Observability callbacks. Pass any subset; all are best-effort."""
|
|
60
|
+
|
|
61
|
+
on_request: Optional[Callable[[RequestInfo], None]] = None
|
|
62
|
+
on_response: Optional[Callable[[ResponseInfo], None]] = None
|
|
63
|
+
on_error: Optional[Callable[[ErrorInfo], None]] = None
|
invonetwork/http.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Transport, retry/backoff, and observability for the INVO SDK.
|
|
2
|
+
|
|
3
|
+
Zero runtime dependencies: the default transport is built on :mod:`urllib.request`.
|
|
4
|
+
The transport is a plain callable ``(HttpRequest) -> HttpResponse`` so tests (and
|
|
5
|
+
advanced callers) can inject a fake instead of touching the network. A transport
|
|
6
|
+
signals a network failure/timeout by raising :class:`TransportError`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
from .errors import InvoError, error_from_response
|
|
22
|
+
from .hooks import ErrorInfo, Hooks, RequestInfo, ResponseInfo
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"HttpRequest",
|
|
26
|
+
"HttpResponse",
|
|
27
|
+
"Transport",
|
|
28
|
+
"TransportError",
|
|
29
|
+
"Http",
|
|
30
|
+
"assert_secure_base_url",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
DEFAULT_TIMEOUT = 30.0
|
|
34
|
+
"""Default per-request timeout, in seconds."""
|
|
35
|
+
|
|
36
|
+
MAX_RETRY_AFTER = 20.0
|
|
37
|
+
"""Upper bound on an honored ``retry_after`` (seconds); larger values surface the 429."""
|
|
38
|
+
|
|
39
|
+
_RETRIABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class HttpRequest:
|
|
44
|
+
"""A single outbound HTTP request handed to the transport."""
|
|
45
|
+
|
|
46
|
+
method: str
|
|
47
|
+
url: str
|
|
48
|
+
headers: Dict[str, str]
|
|
49
|
+
body: Optional[bytes]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class HttpResponse:
|
|
54
|
+
"""A raw HTTP response returned by the transport."""
|
|
55
|
+
|
|
56
|
+
status: int
|
|
57
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
58
|
+
body: bytes = b""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: A transport is any callable turning a request into a response. It must raise
|
|
62
|
+
#: :class:`TransportError` for network failures/timeouts (so retries can kick in).
|
|
63
|
+
Transport = Callable[[HttpRequest], HttpResponse]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TransportError(Exception):
|
|
67
|
+
"""Raised by a transport for a network failure or timeout (never an HTTP status)."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str, *, timeout: bool = False) -> None:
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.message = message
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def assert_secure_base_url(base_url: str) -> None:
|
|
76
|
+
"""Reject a non-https ``base_url``.
|
|
77
|
+
|
|
78
|
+
The game secret travels in a request header, so ``http://`` would expose it on the
|
|
79
|
+
wire. ``http://localhost`` (and loopback) is allowed for local development only.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: if the URL is unparseable or not https (non-loopback).
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
u = urlparse(base_url)
|
|
86
|
+
except Exception as exc: # pragma: no cover - urlparse rarely raises
|
|
87
|
+
raise ValueError(f"Invalid baseUrl: {base_url}") from exc
|
|
88
|
+
if not u.scheme or not u.netloc:
|
|
89
|
+
raise ValueError(f"Invalid baseUrl: {base_url}")
|
|
90
|
+
host = (u.hostname or "").lower()
|
|
91
|
+
is_local = host in ("localhost", "127.0.0.1", "::1")
|
|
92
|
+
if u.scheme == "https" or (u.scheme == "http" and is_local):
|
|
93
|
+
return
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f'baseUrl must use https:// (got "{u.scheme}://"). '
|
|
96
|
+
"Plaintext would expose the game secret on the wire."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _make_urllib_transport(timeout: float) -> Transport:
|
|
101
|
+
"""Build the default transport backed by :mod:`urllib.request`."""
|
|
102
|
+
|
|
103
|
+
def transport(request: HttpRequest) -> HttpResponse:
|
|
104
|
+
req = urllib.request.Request(request.url, data=request.body, method=request.method)
|
|
105
|
+
for name, value in request.headers.items():
|
|
106
|
+
req.add_header(name, value)
|
|
107
|
+
try:
|
|
108
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https enforced)
|
|
109
|
+
headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
110
|
+
return HttpResponse(status=resp.status, headers=headers, body=resp.read())
|
|
111
|
+
except urllib.error.HTTPError as exc:
|
|
112
|
+
# HTTPError IS a response object -- surface its status/body rather than retrying.
|
|
113
|
+
raw = exc.headers.items() if exc.headers else []
|
|
114
|
+
headers = {k.lower(): v for k, v in raw}
|
|
115
|
+
return HttpResponse(status=exc.code, headers=headers, body=exc.read())
|
|
116
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
117
|
+
reason = getattr(exc, "reason", exc)
|
|
118
|
+
is_timeout = isinstance(reason, TimeoutError) or "timed out" in str(reason).lower()
|
|
119
|
+
raise TransportError(str(reason), timeout=is_timeout) from exc
|
|
120
|
+
|
|
121
|
+
return transport
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
125
|
+
"""Case-insensitive header lookup that tolerates unlowered keys."""
|
|
126
|
+
target = name.lower()
|
|
127
|
+
for key, value in headers.items():
|
|
128
|
+
if key.lower() == target:
|
|
129
|
+
return value
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pick_request_id(headers: Mapping[str, str]) -> Optional[str]:
|
|
134
|
+
"""Pull a backend request id from the response headers (for support/tracing)."""
|
|
135
|
+
return _get_header(headers, "x-invo-request-id") or _get_header(headers, "x-request-id")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _retry_after_seconds(parsed: Any, headers: Mapping[str, str]) -> Optional[float]:
|
|
139
|
+
"""Honor a 429 ``retry_after`` (seconds) from the body or the ``Retry-After`` header."""
|
|
140
|
+
if isinstance(parsed, dict):
|
|
141
|
+
v = parsed.get("retry_after")
|
|
142
|
+
n: Optional[float] = None
|
|
143
|
+
if isinstance(v, bool):
|
|
144
|
+
n = None
|
|
145
|
+
elif isinstance(v, (int, float)):
|
|
146
|
+
n = float(v)
|
|
147
|
+
elif isinstance(v, str):
|
|
148
|
+
try:
|
|
149
|
+
n = float(v)
|
|
150
|
+
except ValueError:
|
|
151
|
+
n = None
|
|
152
|
+
if n is not None and n >= 0:
|
|
153
|
+
return n
|
|
154
|
+
header = _get_header(headers, "retry-after")
|
|
155
|
+
if header:
|
|
156
|
+
try:
|
|
157
|
+
n = float(header)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return None
|
|
160
|
+
if n >= 0:
|
|
161
|
+
return n
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Http:
|
|
166
|
+
"""HTTP core: builds requests, retries transient failures, and fires hooks.
|
|
167
|
+
|
|
168
|
+
Retries apply ONLY to idempotent requests (network error/timeout, 429, 5xx). A
|
|
169
|
+
timeout can fire while the server is mid-processing, so a non-idempotent POST is
|
|
170
|
+
never auto-retried (it could double-apply an action).
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
base_url: str,
|
|
177
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
178
|
+
max_retries: int = 2,
|
|
179
|
+
retry_base_delay: float = 0.25,
|
|
180
|
+
user_agent: Optional[str] = None,
|
|
181
|
+
hooks: Optional[Hooks] = None,
|
|
182
|
+
transport: Optional[Transport] = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
self.base_url = base_url.rstrip("/")
|
|
185
|
+
self.timeout = timeout
|
|
186
|
+
self.max_retries = max(0, max_retries)
|
|
187
|
+
self.retry_base_delay = retry_base_delay
|
|
188
|
+
self.user_agent = user_agent
|
|
189
|
+
self.hooks = hooks
|
|
190
|
+
self._transport: Transport = transport or _make_urllib_transport(timeout)
|
|
191
|
+
|
|
192
|
+
def get(self, path: str, auth_headers: Dict[str, str]) -> Any:
|
|
193
|
+
"""GET is always idempotent -> safe to retry."""
|
|
194
|
+
return self._request("GET", path, None, auth_headers, idempotent=True)
|
|
195
|
+
|
|
196
|
+
def post(
|
|
197
|
+
self,
|
|
198
|
+
path: str,
|
|
199
|
+
body: Optional[Dict[str, Any]],
|
|
200
|
+
auth_headers: Dict[str, str],
|
|
201
|
+
*,
|
|
202
|
+
idempotent: bool = False,
|
|
203
|
+
) -> Any:
|
|
204
|
+
"""POST. ``idempotent=True`` marks it safe to auto-retry (carries an idempotency key)."""
|
|
205
|
+
return self._request("POST", path, body, auth_headers, idempotent=idempotent)
|
|
206
|
+
|
|
207
|
+
def _request(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
path: str,
|
|
211
|
+
body: Optional[Dict[str, Any]],
|
|
212
|
+
auth_headers: Dict[str, str],
|
|
213
|
+
*,
|
|
214
|
+
idempotent: bool,
|
|
215
|
+
) -> Any:
|
|
216
|
+
url = f"{self.base_url}{path}"
|
|
217
|
+
headers: Dict[str, str] = {"Accept": "application/json", **auth_headers}
|
|
218
|
+
if self.user_agent:
|
|
219
|
+
headers["User-Agent"] = self.user_agent
|
|
220
|
+
payload: Optional[bytes] = None
|
|
221
|
+
if body is not None:
|
|
222
|
+
headers["Content-Type"] = "application/json"
|
|
223
|
+
payload = json.dumps(body).encode("utf-8")
|
|
224
|
+
|
|
225
|
+
attempt = 0
|
|
226
|
+
while True:
|
|
227
|
+
start = time.monotonic()
|
|
228
|
+
self._fire_request(RequestInfo(method=method, url=url, attempt=attempt))
|
|
229
|
+
request = HttpRequest(method=method, url=url, headers=dict(headers), body=payload)
|
|
230
|
+
|
|
231
|
+
network_error: Optional[InvoError] = None
|
|
232
|
+
response: Optional[HttpResponse] = None
|
|
233
|
+
try:
|
|
234
|
+
response = self._transport(request)
|
|
235
|
+
except TransportError as exc:
|
|
236
|
+
message = (
|
|
237
|
+
f"Request to {path} timed out after {self.timeout}s"
|
|
238
|
+
if exc.timeout
|
|
239
|
+
else f"Network error calling {path}: {exc.message}"
|
|
240
|
+
)
|
|
241
|
+
network_error = InvoError(message, status=0)
|
|
242
|
+
except Exception as exc: # any other transport failure == network error
|
|
243
|
+
network_error = InvoError(f"Network error calling {path}: {exc}", status=0)
|
|
244
|
+
|
|
245
|
+
if network_error is not None:
|
|
246
|
+
will_retry = idempotent and attempt < self.max_retries
|
|
247
|
+
self._fire_error(
|
|
248
|
+
ErrorInfo(method=method, url=url, attempt=attempt, error=network_error, will_retry=will_retry)
|
|
249
|
+
)
|
|
250
|
+
if will_retry:
|
|
251
|
+
time.sleep(self._backoff(attempt))
|
|
252
|
+
attempt += 1
|
|
253
|
+
continue
|
|
254
|
+
raise network_error
|
|
255
|
+
|
|
256
|
+
assert response is not None
|
|
257
|
+
request_id = _pick_request_id(response.headers)
|
|
258
|
+
raw_body = response.body
|
|
259
|
+
text = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else str(raw_body)
|
|
260
|
+
parsed: Any = None
|
|
261
|
+
if text:
|
|
262
|
+
try:
|
|
263
|
+
parsed = json.loads(text)
|
|
264
|
+
except ValueError:
|
|
265
|
+
parsed = text
|
|
266
|
+
|
|
267
|
+
self._fire_response(
|
|
268
|
+
ResponseInfo(
|
|
269
|
+
method=method,
|
|
270
|
+
url=url,
|
|
271
|
+
attempt=attempt,
|
|
272
|
+
status=response.status,
|
|
273
|
+
duration_ms=(time.monotonic() - start) * 1000.0,
|
|
274
|
+
request_id=request_id,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if not (200 <= response.status < 300):
|
|
279
|
+
err = error_from_response(response.status, parsed, request_id)
|
|
280
|
+
wait: Optional[float] = None
|
|
281
|
+
if idempotent and attempt < self.max_retries and response.status in _RETRIABLE_STATUS:
|
|
282
|
+
if response.status == 429:
|
|
283
|
+
ra = _retry_after_seconds(parsed, response.headers)
|
|
284
|
+
if ra is None:
|
|
285
|
+
wait = self._backoff(attempt)
|
|
286
|
+
elif ra <= MAX_RETRY_AFTER:
|
|
287
|
+
wait = ra
|
|
288
|
+
else:
|
|
289
|
+
wait = None # surface the 429 rather than stalling
|
|
290
|
+
else:
|
|
291
|
+
wait = self._backoff(attempt)
|
|
292
|
+
self._fire_error(
|
|
293
|
+
ErrorInfo(method=method, url=url, attempt=attempt, error=err, will_retry=wait is not None)
|
|
294
|
+
)
|
|
295
|
+
if wait is not None:
|
|
296
|
+
time.sleep(wait)
|
|
297
|
+
attempt += 1
|
|
298
|
+
continue
|
|
299
|
+
raise err
|
|
300
|
+
|
|
301
|
+
return parsed if parsed is not None else {}
|
|
302
|
+
|
|
303
|
+
def _backoff(self, attempt: int) -> float:
|
|
304
|
+
"""Exponential backoff with full jitter: ``base * 2**attempt``, randomized."""
|
|
305
|
+
ceil = self.retry_base_delay * (2.0 ** attempt)
|
|
306
|
+
return random.random() * ceil + self.retry_base_delay
|
|
307
|
+
|
|
308
|
+
# ---- Hook firing (best-effort; a throwing hook never breaks a request) ----
|
|
309
|
+
|
|
310
|
+
def _fire_request(self, info: RequestInfo) -> None:
|
|
311
|
+
if self.hooks and self.hooks.on_request:
|
|
312
|
+
with contextlib.suppress(Exception):
|
|
313
|
+
self.hooks.on_request(info)
|
|
314
|
+
|
|
315
|
+
def _fire_response(self, info: ResponseInfo) -> None:
|
|
316
|
+
if self.hooks and self.hooks.on_response:
|
|
317
|
+
with contextlib.suppress(Exception):
|
|
318
|
+
self.hooks.on_response(info)
|
|
319
|
+
|
|
320
|
+
def _fire_error(self, info: ErrorInfo) -> None:
|
|
321
|
+
if self.hooks and self.hooks.on_error:
|
|
322
|
+
with contextlib.suppress(Exception):
|
|
323
|
+
self.hooks.on_error(info)
|
invonetwork/py.typed
ADDED
|
File without changes
|