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