mudraid-sdk 1.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.
- mudraid/__init__.py +66 -0
- mudraid/_agent.py +342 -0
- mudraid/_consequence.py +153 -0
- mudraid/_env.py +216 -0
- mudraid/_http.py +417 -0
- mudraid/_machine_agent.py +162 -0
- mudraid/_machine_auth.py +433 -0
- mudraid/_platform_resolver.py +177 -0
- mudraid/_scopes.py +112 -0
- mudraid/_token_manager.py +168 -0
- mudraid/exceptions.py +209 -0
- mudraid/py.typed +0 -0
- mudraid_sdk-1.1.0.dist-info/METADATA +640 -0
- mudraid_sdk-1.1.0.dist-info/RECORD +16 -0
- mudraid_sdk-1.1.0.dist-info/WHEEL +4 -0
- mudraid_sdk-1.1.0.dist-info/licenses/LICENSE +201 -0
mudraid/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""mudraid — Python SDK for MudraID.
|
|
2
|
+
|
|
3
|
+
Two auth profiles are exposed:
|
|
4
|
+
|
|
5
|
+
* :class:`Agent` — the *legacy* profile: a drop-in replacement for ``requests``
|
|
6
|
+
that authenticates with an api_key_id/secret pair and routes per registered
|
|
7
|
+
platform. Retained additively; see :meth:`Agent.legacy` and the class
|
|
8
|
+
docstring's retirement policy.
|
|
9
|
+
* :class:`MachineAgent` — the *V2 machine-authority* profile:
|
|
10
|
+
``private_key_jwt`` client-assertion auth with explicit audience + scopes,
|
|
11
|
+
resource/scope-bound access tokens, and consequence-safe retry. An omitted
|
|
12
|
+
scope set is the empty (minimal) set — never a wildcard — and a consequential
|
|
13
|
+
call whose outcome is unknown is never blindly replayed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from mudraid._agent import Agent
|
|
17
|
+
from mudraid._consequence import IDEMPOTENCY_KEY_HEADER, is_idempotent
|
|
18
|
+
from mudraid._machine_agent import MachineAgent
|
|
19
|
+
from mudraid._machine_auth import (
|
|
20
|
+
AssertionSigner,
|
|
21
|
+
MachineIdentity,
|
|
22
|
+
MachineTokenManager,
|
|
23
|
+
PyJWTSigner,
|
|
24
|
+
build_client_assertion_claims,
|
|
25
|
+
)
|
|
26
|
+
from mudraid._scopes import RequestedScopes
|
|
27
|
+
from mudraid.exceptions import (
|
|
28
|
+
MudraIDAuthError,
|
|
29
|
+
MudraIDBillingFrozenError,
|
|
30
|
+
MudraIDConfigError,
|
|
31
|
+
MudraIDError,
|
|
32
|
+
MudraIDExecutionUnknownError,
|
|
33
|
+
MudraIDNetworkError,
|
|
34
|
+
MudraIDPlatformNotRegisteredError,
|
|
35
|
+
MudraIDRateLimitedError,
|
|
36
|
+
MudraIDRevokedError,
|
|
37
|
+
MudraIDScopeError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Legacy profile
|
|
42
|
+
"Agent",
|
|
43
|
+
# V2 machine-authority profile
|
|
44
|
+
"MachineAgent",
|
|
45
|
+
"MachineIdentity",
|
|
46
|
+
"MachineTokenManager",
|
|
47
|
+
"AssertionSigner",
|
|
48
|
+
"PyJWTSigner",
|
|
49
|
+
"RequestedScopes",
|
|
50
|
+
"build_client_assertion_claims",
|
|
51
|
+
"IDEMPOTENCY_KEY_HEADER",
|
|
52
|
+
"is_idempotent",
|
|
53
|
+
# Errors
|
|
54
|
+
"MudraIDError",
|
|
55
|
+
"MudraIDConfigError",
|
|
56
|
+
"MudraIDAuthError",
|
|
57
|
+
"MudraIDRevokedError",
|
|
58
|
+
"MudraIDNetworkError",
|
|
59
|
+
"MudraIDPlatformNotRegisteredError",
|
|
60
|
+
"MudraIDRateLimitedError",
|
|
61
|
+
"MudraIDScopeError",
|
|
62
|
+
"MudraIDBillingFrozenError",
|
|
63
|
+
"MudraIDExecutionUnknownError",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
__version__ = "1.1.0"
|
mudraid/_agent.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Agent — public entry point of the MudraID SDK.
|
|
2
|
+
|
|
3
|
+
Each call:
|
|
4
|
+
|
|
5
|
+
1. Resolves the URL's host to a ``platform_id`` via
|
|
6
|
+
:class:`mudraid._platform_resolver.PlatformResolver`.
|
|
7
|
+
2. Asks :class:`mudraid._token_manager.TokenManager` for a current
|
|
8
|
+
JWT for that platform (cache hit, or mint on miss).
|
|
9
|
+
3. Injects ``Authorization: Bearer <jwt>`` into the outgoing
|
|
10
|
+
``requests`` call, preserving every other ``requests`` kwarg.
|
|
11
|
+
4. Runs the request under consequence-safe retry semantics
|
|
12
|
+
(:mod:`mudraid._consequence`) and returns the
|
|
13
|
+
:class:`requests.Response`.
|
|
14
|
+
|
|
15
|
+
The class mirrors :class:`requests.Session` so an integrator's diff
|
|
16
|
+
is "``import requests``" → "``from mudraid import Agent``".
|
|
17
|
+
|
|
18
|
+
Consequence safety
|
|
19
|
+
------------------
|
|
20
|
+
|
|
21
|
+
This client will not replay a request whose replay could duplicate a
|
|
22
|
+
side effect. That applies to both ways a replay can arise:
|
|
23
|
+
|
|
24
|
+
* a **transport failure** where the request was sent and no response
|
|
25
|
+
was read, and
|
|
26
|
+
* a resource-server **401**, where the token is refreshed and the
|
|
27
|
+
call retried.
|
|
28
|
+
|
|
29
|
+
For ``GET``/``HEAD``/``OPTIONS``/``PUT``/``DELETE`` a replay cannot
|
|
30
|
+
duplicate an effect, so it happens as before. For ``POST``/``PATCH``
|
|
31
|
+
it happens only when the caller supplies an ``idempotency_key`` the
|
|
32
|
+
server deduplicates on. Without one, the 401 is returned to the caller
|
|
33
|
+
unreplayed and an ambiguous transport failure raises
|
|
34
|
+
:class:`mudraid.MudraIDExecutionUnknownError`.
|
|
35
|
+
|
|
36
|
+
This is the same rule :class:`mudraid.MachineAgent` applies. It used to
|
|
37
|
+
differ here, and the difference mattered: a platform that mutated state
|
|
38
|
+
and *then* answered 401 had the mutation performed twice, and the caller
|
|
39
|
+
saw a clean success from the second attempt with nothing to indicate the
|
|
40
|
+
first had landed.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
import requests
|
|
49
|
+
|
|
50
|
+
from mudraid._consequence import execute, is_idempotent
|
|
51
|
+
from mudraid._env import SdkConfig, load_config
|
|
52
|
+
from mudraid._http import MudraIDHttpClient
|
|
53
|
+
from mudraid._platform_resolver import PlatformResolver
|
|
54
|
+
from mudraid._token_manager import TokenManager
|
|
55
|
+
|
|
56
|
+
_logger = logging.getLogger("mudraid.agent")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Agent:
|
|
60
|
+
"""Authenticated HTTP client that speaks for one MudraID agent.
|
|
61
|
+
|
|
62
|
+
.. note:: **Legacy machine-authority profile.**
|
|
63
|
+
|
|
64
|
+
This class is the *legacy* auth profile: it authenticates with an
|
|
65
|
+
api_key_id/secret pair against ``POST /api/v1/auth/token`` and — by
|
|
66
|
+
design — lets an empty ``scopes`` request expand to the agent's *full*
|
|
67
|
+
permitted set on the platform. The V2 machine-authority profile
|
|
68
|
+
(:class:`mudraid.MachineAgent` /
|
|
69
|
+
:class:`mudraid.MachineIdentity`), where a ``private_key_jwt`` assertion
|
|
70
|
+
carries explicit audience + scopes and an omitted scope set means the
|
|
71
|
+
*empty* (minimal) set, never "everything."
|
|
72
|
+
|
|
73
|
+
The legacy profile stays fully supported and reachable — additively, so
|
|
74
|
+
existing integrations do not break — and can be selected explicitly via
|
|
75
|
+
:meth:`Agent.legacy` to make the choice visible at call sites.
|
|
76
|
+
|
|
77
|
+
**Retirement policy:** the legacy profile is retained through the V2
|
|
78
|
+
transition and is scheduled for removal in a future major SDK release
|
|
79
|
+
once V2 machine authority is generally available; it will be deprecated
|
|
80
|
+
(with a runtime warning and a migration window) before any removal. New
|
|
81
|
+
integrations should adopt :class:`mudraid.MachineAgent`.
|
|
82
|
+
|
|
83
|
+
Construction resolves credentials with the following precedence:
|
|
84
|
+
|
|
85
|
+
1. Explicit keyword arguments (``api_key_id=``, ``secret=``,
|
|
86
|
+
``base_url=``)
|
|
87
|
+
2. The OS environment (``MUDRAID_API_KEY_ID``, ``MUDRAID_SECRET``,
|
|
88
|
+
``MUDRAID_BASE_URL``)
|
|
89
|
+
3. A ``.env`` file in the project tree (auto-discovered)
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
MudraIDConfigError: when ``api_key_id`` or ``secret`` cannot
|
|
93
|
+
be resolved from any of the above. ``base_url`` always has a
|
|
94
|
+
sensible production default and never raises on its own.
|
|
95
|
+
|
|
96
|
+
The first outgoing request triggers a one-time bootstrap call to
|
|
97
|
+
MudraID to learn which platforms this agent is registered with.
|
|
98
|
+
Subsequent calls are cache-warm.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key_id: str | None = None,
|
|
104
|
+
secret: str | None = None,
|
|
105
|
+
base_url: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._config: SdkConfig = load_config(
|
|
108
|
+
api_key_id=api_key_id,
|
|
109
|
+
secret=secret,
|
|
110
|
+
base_url=base_url,
|
|
111
|
+
)
|
|
112
|
+
# api_key_id is public; base_url is public. Logging both is
|
|
113
|
+
# safe and useful for "which agent did this?" debugging.
|
|
114
|
+
_logger.info(
|
|
115
|
+
"Agent created: api_key_id=%s base_url=%s",
|
|
116
|
+
self._config.api_key_id,
|
|
117
|
+
self._config.base_url,
|
|
118
|
+
)
|
|
119
|
+
self._mudraid_http = MudraIDHttpClient(self._config)
|
|
120
|
+
self._tokens = TokenManager(self._mudraid_http)
|
|
121
|
+
self._platforms = PlatformResolver(self._mudraid_http)
|
|
122
|
+
# A separate Session for outgoing platform calls. We do NOT
|
|
123
|
+
# share the MudraID session because the two have different
|
|
124
|
+
# security properties: MudraID requests carry the agent's
|
|
125
|
+
# plaintext secret in the body; platform requests carry the
|
|
126
|
+
# short-lived JWT in a header. Keeping the sessions distinct
|
|
127
|
+
# makes it impossible to accidentally send one's headers on
|
|
128
|
+
# the other's connection.
|
|
129
|
+
self._platform_session = requests.Session()
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def legacy(
|
|
133
|
+
cls,
|
|
134
|
+
api_key_id: str | None = None,
|
|
135
|
+
secret: str | None = None,
|
|
136
|
+
base_url: str | None = None,
|
|
137
|
+
) -> "Agent":
|
|
138
|
+
"""Explicitly construct the legacy api_key_id/secret auth profile.
|
|
139
|
+
|
|
140
|
+
Behaviourally identical to calling ``Agent(...)`` directly — the default
|
|
141
|
+
constructor *is* the legacy profile — but naming it at the call site
|
|
142
|
+
documents the choice now that the V2 profile
|
|
143
|
+
(:class:`mudraid.MachineAgent`) exists. Provided so integrations can pin
|
|
144
|
+
themselves to the legacy behaviour intentionally rather than by default,
|
|
145
|
+
and so a future deprecation can target this explicit entry point. See the
|
|
146
|
+
class docstring for the retirement policy.
|
|
147
|
+
"""
|
|
148
|
+
_logger.info("Agent.legacy() — constructing the legacy auth profile explicitly")
|
|
149
|
+
return cls(api_key_id=api_key_id, secret=secret, base_url=base_url)
|
|
150
|
+
|
|
151
|
+
# ---- public, safe-to-read accessors ---------------------------------
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def api_key_id(self) -> str:
|
|
155
|
+
"""The public agent identifier (``muid_kid_...``).
|
|
156
|
+
|
|
157
|
+
Safe to log and display — this is the public half of the
|
|
158
|
+
credential pair. The secret is intentionally not exposed via
|
|
159
|
+
any property.
|
|
160
|
+
"""
|
|
161
|
+
return self._config.api_key_id
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def base_url(self) -> str:
|
|
165
|
+
"""Resolved MudraID API base URL.
|
|
166
|
+
|
|
167
|
+
Useful for diagnostics and dev tooling that need to confirm
|
|
168
|
+
the SDK is pointed at the right backend.
|
|
169
|
+
"""
|
|
170
|
+
return self._config.base_url
|
|
171
|
+
|
|
172
|
+
# ---- maintenance ----------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def refresh_platforms(self) -> None:
|
|
175
|
+
"""Force a fresh bootstrap of the platform map.
|
|
176
|
+
|
|
177
|
+
Call this after granting a new platform to the agent in the
|
|
178
|
+
portal, or after removing one. The next outgoing request
|
|
179
|
+
triggers a re-fetch of ``/auth/agents/me/platforms`` and
|
|
180
|
+
rebuilds the host→platform_id map. Cached JWTs are also
|
|
181
|
+
dropped so a now-revoked platform cannot serve a stale token.
|
|
182
|
+
"""
|
|
183
|
+
_logger.info("Agent.refresh_platforms() — clearing resolver + token caches")
|
|
184
|
+
self._platforms.refresh()
|
|
185
|
+
self._tokens.clear()
|
|
186
|
+
|
|
187
|
+
def close(self) -> None:
|
|
188
|
+
"""Release the underlying HTTP connection pools.
|
|
189
|
+
|
|
190
|
+
Optional — both sessions garbage-collect cleanly. Provided for
|
|
191
|
+
scripts that want deterministic shutdown.
|
|
192
|
+
"""
|
|
193
|
+
self._platform_session.close()
|
|
194
|
+
self._mudraid_http.close()
|
|
195
|
+
|
|
196
|
+
# ---- HTTP surface ---------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# The idempotent verbs take no ``idempotency_key``: replaying them cannot
|
|
199
|
+
# duplicate an effect, so nothing about their retry behaviour depends on one.
|
|
200
|
+
def get(self, url: str, **kwargs: Any) -> requests.Response:
|
|
201
|
+
return self._request("GET", url, **kwargs)
|
|
202
|
+
|
|
203
|
+
def head(self, url: str, **kwargs: Any) -> requests.Response:
|
|
204
|
+
return self._request("HEAD", url, **kwargs)
|
|
205
|
+
|
|
206
|
+
def options(self, url: str, **kwargs: Any) -> requests.Response:
|
|
207
|
+
return self._request("OPTIONS", url, **kwargs)
|
|
208
|
+
|
|
209
|
+
# ``idempotency_key`` is what makes a replay of a consequential call safe:
|
|
210
|
+
# the server collapses the duplicate. The signature mirrors
|
|
211
|
+
# :class:`mudraid.MachineAgent` exactly, so moving between the two profiles
|
|
212
|
+
# is not also a change of retry semantics.
|
|
213
|
+
def post(
|
|
214
|
+
self, url: str, *, idempotency_key: str | None = None, **kwargs: Any
|
|
215
|
+
) -> requests.Response:
|
|
216
|
+
return self._request("POST", url, idempotency_key=idempotency_key, **kwargs)
|
|
217
|
+
|
|
218
|
+
def patch(
|
|
219
|
+
self, url: str, *, idempotency_key: str | None = None, **kwargs: Any
|
|
220
|
+
) -> requests.Response:
|
|
221
|
+
return self._request("PATCH", url, idempotency_key=idempotency_key, **kwargs)
|
|
222
|
+
|
|
223
|
+
# PUT and DELETE are idempotent, so a key is not needed for SDK-side safety.
|
|
224
|
+
# It is accepted anyway — a server may still want to deduplicate, and
|
|
225
|
+
# refusing the argument here would make the two clients disagree.
|
|
226
|
+
def put(
|
|
227
|
+
self, url: str, *, idempotency_key: str | None = None, **kwargs: Any
|
|
228
|
+
) -> requests.Response:
|
|
229
|
+
return self._request("PUT", url, idempotency_key=idempotency_key, **kwargs)
|
|
230
|
+
|
|
231
|
+
def delete(
|
|
232
|
+
self, url: str, *, idempotency_key: str | None = None, **kwargs: Any
|
|
233
|
+
) -> requests.Response:
|
|
234
|
+
return self._request("DELETE", url, idempotency_key=idempotency_key, **kwargs)
|
|
235
|
+
|
|
236
|
+
# ---- internals ------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _request(
|
|
239
|
+
self,
|
|
240
|
+
method: str,
|
|
241
|
+
url: str,
|
|
242
|
+
*,
|
|
243
|
+
idempotency_key: str | None = None,
|
|
244
|
+
**kwargs: Any,
|
|
245
|
+
) -> requests.Response:
|
|
246
|
+
"""The single point through which every HTTP method flows.
|
|
247
|
+
|
|
248
|
+
Behaviour:
|
|
249
|
+
|
|
250
|
+
- Resolves the URL host → platform_id. Failure here raises
|
|
251
|
+
``MudraIDPlatformNotRegisteredError`` BEFORE any outbound
|
|
252
|
+
HTTP call.
|
|
253
|
+
- Asks TokenManager for a JWT for the platform. Failure
|
|
254
|
+
here raises ``MudraIDAuthError`` / ``MudraIDRevokedError``
|
|
255
|
+
/ ``MudraIDNetworkError`` BEFORE the platform is contacted.
|
|
256
|
+
- Sends the request with ``Authorization: Bearer <jwt>``,
|
|
257
|
+
under :func:`mudraid._consequence.execute` so an ambiguous
|
|
258
|
+
transport failure is never blindly replayed.
|
|
259
|
+
- On a platform ``401``, refreshes the JWT and replays the
|
|
260
|
+
request EXACTLY ONCE — **when replaying is consequence-safe**.
|
|
261
|
+
A second 401 is surfaced to the caller; we never loop. Any
|
|
262
|
+
other status (200, 4xx, 5xx) is returned unmodified.
|
|
263
|
+
|
|
264
|
+
WHY THE 401 REPLAY IS CONDITIONAL. It used to be unconditional,
|
|
265
|
+
defended by the argument that a 401 proves the platform rejected
|
|
266
|
+
the request *before* processing it, so the replay is the first
|
|
267
|
+
time the call is really seen. That argument is about a platform
|
|
268
|
+
the SDK does not control and cannot inspect. A server that writes
|
|
269
|
+
and then fails to renew its own auth check, a proxy that turns an
|
|
270
|
+
expired session into a 401 after forwarding, a gateway that
|
|
271
|
+
answers 401 on the response path — each produces a 401 that
|
|
272
|
+
arrives *after* the effect. The SDK cannot distinguish those from
|
|
273
|
+
the benign case, and the cost of being wrong is a duplicated
|
|
274
|
+
payment, message or deletion, reported to the caller as a clean
|
|
275
|
+
success from the second attempt.
|
|
276
|
+
|
|
277
|
+
So the rule is the one :class:`mudraid.MachineAgent` already
|
|
278
|
+
applies: replay when replaying provably cannot duplicate an
|
|
279
|
+
effect — an idempotent method, or a caller-supplied
|
|
280
|
+
``idempotency_key`` the server deduplicates on. Otherwise return
|
|
281
|
+
the 401 and let the caller decide, which is the only party that
|
|
282
|
+
knows whether the action is safe to repeat.
|
|
283
|
+
"""
|
|
284
|
+
platform_id = self._platforms.resolve(url)
|
|
285
|
+
|
|
286
|
+
# Pop the caller's headers exactly once so each attempt sees a clean
|
|
287
|
+
# copy. Without this, a ``kwargs.pop`` inside the retry would silently
|
|
288
|
+
# drop the caller's headers on the second attempt.
|
|
289
|
+
caller_headers = dict(kwargs.pop("headers", None) or {})
|
|
290
|
+
|
|
291
|
+
def send(extra_headers: dict[str, str]) -> requests.Response:
|
|
292
|
+
"""Attach a current JWT and dispatch one HTTP request.
|
|
293
|
+
|
|
294
|
+
Header merge policy: caller-supplied entries first, then the
|
|
295
|
+
consequence layer's ``Idempotency-Key``, then the SDK's
|
|
296
|
+
``Authorization`` last so it always wins. A developer passing their
|
|
297
|
+
own ``Authorization`` is almost certainly using a different auth
|
|
298
|
+
mechanism; overriding is correct because attaching the MudraID
|
|
299
|
+
bearer token is what this client is for.
|
|
300
|
+
|
|
301
|
+
The token is fetched per attempt rather than captured once, so a
|
|
302
|
+
replay after ``TokenManager.refresh`` carries the fresh one.
|
|
303
|
+
"""
|
|
304
|
+
token = self._tokens.get_token(platform_id)
|
|
305
|
+
headers = {
|
|
306
|
+
**caller_headers,
|
|
307
|
+
**extra_headers,
|
|
308
|
+
"Authorization": f"Bearer {token}",
|
|
309
|
+
}
|
|
310
|
+
return self._platform_session.request(
|
|
311
|
+
method=method,
|
|
312
|
+
url=url,
|
|
313
|
+
headers=headers,
|
|
314
|
+
**kwargs,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
response = execute(send, method=method, idempotency_key=idempotency_key)
|
|
318
|
+
if response.status_code != 401:
|
|
319
|
+
return response
|
|
320
|
+
|
|
321
|
+
if not (is_idempotent(method) or idempotency_key):
|
|
322
|
+
# Consequential, unkeyed, and the outcome of the first attempt is
|
|
323
|
+
# not knowable from here. Returning the 401 is the honest answer:
|
|
324
|
+
# the caller learns authentication failed and decides whether the
|
|
325
|
+
# action is safe to repeat. Replaying would decide that for them.
|
|
326
|
+
_logger.warning(
|
|
327
|
+
"platform returned 401 for %s %s; NOT replaying a consequential "
|
|
328
|
+
"call with no idempotency key (a post-mutation 401 would be "
|
|
329
|
+
"duplicated) — returning the 401. Pass idempotency_key= to make "
|
|
330
|
+
"the replay safe.",
|
|
331
|
+
method,
|
|
332
|
+
url,
|
|
333
|
+
)
|
|
334
|
+
return response
|
|
335
|
+
|
|
336
|
+
_logger.warning(
|
|
337
|
+
"platform returned 401 for %s %s; refreshing token and retrying once",
|
|
338
|
+
method,
|
|
339
|
+
url,
|
|
340
|
+
)
|
|
341
|
+
self._tokens.refresh(platform_id)
|
|
342
|
+
return execute(send, method=method, idempotency_key=idempotency_key)
|
mudraid/_consequence.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Consequence-safe request execution — the no-blind-replay invariant.
|
|
2
|
+
|
|
3
|
+
The invariant: *a consequential call whose outcome is
|
|
4
|
+
unknown must not be blindly retried in a way that could duplicate a
|
|
5
|
+
side-effecting action.* This module classifies failures precisely so retries
|
|
6
|
+
happen only where they are provably safe:
|
|
7
|
+
|
|
8
|
+
* **Pre-response failures** (the server never received the request — e.g. a
|
|
9
|
+
connect timeout) are safe to retry for *any* method: the action never
|
|
10
|
+
started.
|
|
11
|
+
* **Idempotent methods** (``GET``/``HEAD``/``OPTIONS``/``PUT``/``DELETE``, per
|
|
12
|
+
RFC 7231) are safe to retry even on an *ambiguous* failure: replaying them
|
|
13
|
+
cannot duplicate an effect by definition.
|
|
14
|
+
* **Ambiguous failures on a consequential method** (``POST``/``PATCH`` sent,
|
|
15
|
+
but no response read — a read timeout, a mid-flight connection drop) are the
|
|
16
|
+
dangerous case. Here the server may have fully processed the action, so the
|
|
17
|
+
SDK does **not** retry. Instead it either
|
|
18
|
+
- attaches a caller-supplied **idempotency key** the server deduplicates,
|
|
19
|
+
making the replay safe, or
|
|
20
|
+
- raises :class:`mudraid.MudraIDExecutionUnknownError` and hands the
|
|
21
|
+
outcome-unknown decision back to the caller.
|
|
22
|
+
|
|
23
|
+
An *HTTP response* — even a 5xx — is a definite outcome and is always returned;
|
|
24
|
+
retrying on status codes is a separate policy the SDK intentionally leaves to
|
|
25
|
+
the caller. Only *transport* exceptions are classified here.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
from mudraid.exceptions import MudraIDExecutionUnknownError
|
|
36
|
+
|
|
37
|
+
_logger = logging.getLogger("mudraid.consequence")
|
|
38
|
+
|
|
39
|
+
# RFC 7231 idempotent methods: replaying them cannot duplicate an effect, so an
|
|
40
|
+
# ambiguous (sent, no response) failure may be safely retried.
|
|
41
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"})
|
|
42
|
+
|
|
43
|
+
# The header the server deduplicates on. A stable key across attempts lets the
|
|
44
|
+
# server collapse a replayed consequential call to a single effect.
|
|
45
|
+
IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_idempotent(method: str) -> bool:
|
|
49
|
+
"""True if ``method`` is idempotent (safe to replay on an ambiguous failure)."""
|
|
50
|
+
return method.upper() in _IDEMPOTENT_METHODS
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_pre_response_failure(exc: requests.RequestException) -> bool:
|
|
54
|
+
"""True when the request provably never reached the server.
|
|
55
|
+
|
|
56
|
+
A connect timeout means the TCP/TLS connection was never established, so the
|
|
57
|
+
server never saw the request — the action never started and a retry is safe
|
|
58
|
+
for any method. Everything else that happens *after* a connection exists
|
|
59
|
+
(read timeouts, mid-flight drops, chunked-encoding truncation) is treated as
|
|
60
|
+
ambiguous: the bytes may already be on the server.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(exc, requests.exceptions.ConnectTimeout):
|
|
63
|
+
return True
|
|
64
|
+
# A ConnectionError raised while *establishing* the connection is
|
|
65
|
+
# pre-response; requests does not always distinguish, so we stay
|
|
66
|
+
# conservative and treat a bare ConnectionError as ambiguous unless it is a
|
|
67
|
+
# ConnectTimeout (handled above). Under-claiming "pre-response" is the safe
|
|
68
|
+
# direction: at worst we refuse a retry that would have been fine.
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def execute(
|
|
73
|
+
send: Callable[[dict[str, str]], requests.Response],
|
|
74
|
+
*,
|
|
75
|
+
method: str,
|
|
76
|
+
idempotency_key: str | None = None,
|
|
77
|
+
max_retries: int = 1,
|
|
78
|
+
) -> requests.Response:
|
|
79
|
+
"""Run a request with consequence-safe retry semantics.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
send: performs one attempt given the extra headers to merge in (so the
|
|
83
|
+
idempotency-key header can be injected on retries). Returns the
|
|
84
|
+
:class:`requests.Response`, or raises a ``requests.RequestException``
|
|
85
|
+
on a transport failure.
|
|
86
|
+
method: the HTTP method, used to classify idempotency.
|
|
87
|
+
idempotency_key: when provided, attached as the ``Idempotency-Key``
|
|
88
|
+
header on every attempt so the server can deduplicate a replay —
|
|
89
|
+
which makes retrying a consequential call safe.
|
|
90
|
+
max_retries: additional attempts after the first, used only where a
|
|
91
|
+
retry is provably safe. Defaults to a single extra attempt.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The :class:`requests.Response` from the first attempt that produced one.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
MudraIDExecutionUnknownError: an ambiguous failure on a consequential
|
|
98
|
+
method with no idempotency key — the outcome is unknown and the SDK
|
|
99
|
+
refuses to duplicate the action.
|
|
100
|
+
requests.RequestException: a transport failure that exhausted the safe
|
|
101
|
+
retry budget (e.g. repeated connect timeouts).
|
|
102
|
+
"""
|
|
103
|
+
extra_headers: dict[str, str] = {}
|
|
104
|
+
if idempotency_key:
|
|
105
|
+
extra_headers[IDEMPOTENCY_KEY_HEADER] = idempotency_key
|
|
106
|
+
|
|
107
|
+
method_upper = method.upper()
|
|
108
|
+
# A key or an idempotent method makes a replay safe; otherwise a replay could
|
|
109
|
+
# duplicate the action and is forbidden on an ambiguous failure.
|
|
110
|
+
retry_is_safe = bool(idempotency_key) or is_idempotent(method_upper)
|
|
111
|
+
|
|
112
|
+
attempt = 0
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
# A completed HTTP response — any status — is a definite outcome.
|
|
116
|
+
return send(extra_headers)
|
|
117
|
+
except requests.RequestException as exc:
|
|
118
|
+
pre_response = _is_pre_response_failure(exc)
|
|
119
|
+
can_retry = (pre_response or retry_is_safe) and attempt < max_retries
|
|
120
|
+
if can_retry:
|
|
121
|
+
attempt += 1
|
|
122
|
+
_logger.warning(
|
|
123
|
+
"transport failure on %s (attempt %d); retry is safe "
|
|
124
|
+
"(pre_response=%s idempotent=%s keyed=%s), retrying",
|
|
125
|
+
method_upper,
|
|
126
|
+
attempt,
|
|
127
|
+
pre_response,
|
|
128
|
+
is_idempotent(method_upper),
|
|
129
|
+
bool(idempotency_key),
|
|
130
|
+
exc_info=False,
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
if not pre_response and not retry_is_safe:
|
|
135
|
+
# The consequential + ambiguous case: sent, outcome unknown, no
|
|
136
|
+
# dedupe key. Do NOT replay — surface a typed result so the
|
|
137
|
+
# caller reconciles or re-issues with an idempotency key. The
|
|
138
|
+
# transport error is chained; the message carries no request body.
|
|
139
|
+
_logger.warning(
|
|
140
|
+
"ambiguous failure on consequential %s with no idempotency key; "
|
|
141
|
+
"NOT retrying — surfacing execution-unknown",
|
|
142
|
+
method_upper,
|
|
143
|
+
)
|
|
144
|
+
raise MudraIDExecutionUnknownError(
|
|
145
|
+
f"the {method_upper} request was sent but its outcome is unknown "
|
|
146
|
+
"(no response, no idempotency key). It was NOT retried to avoid "
|
|
147
|
+
"duplicating a side effect; verify server state or re-issue with an "
|
|
148
|
+
"idempotency key the server deduplicates."
|
|
149
|
+
) from exc
|
|
150
|
+
|
|
151
|
+
# Safe-to-retry class but the retry budget is exhausted — surface the
|
|
152
|
+
# original transport error unchanged.
|
|
153
|
+
raise
|