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.
@@ -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