memydev-auth-sdk 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,239 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/_oidc.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Capabilities 2, 3, 4, 9 — authorization URL, token exchange/refresh, userinfo, logout.
5
+ @module memyauth._oidc
6
+ @description [async -> _sync/_oidc.py twin via scripts/gen_sync.py]. Ported from
7
+ `memybase/src/auth/oidc.ts:14-20,155-159,178-225`. Every URL this service builds comes from the
8
+ DISCOVERY DOCUMENT, never from a guessed path convention (§C.2, D7 — memyui's guessed
9
+ `{issuer}/auth`). Token exchange and refresh are POSTs and are therefore NEVER retried by
10
+ `Transport` (§0 C13 — an authorization code is single-use); their errors are bespoke
11
+ (`TokenExchangeError` / `RefreshRejectedError`) rather than the generic `decode_error` mapping,
12
+ because the token endpoint's failure body is an OAuth `{error, error_description}` shape, not
13
+ the IAM surface's `{code,message,detail}` envelope.
14
+ @dependencies base64, urllib.parse (stdlib); ._transport, ._pkce, ._discovery
15
+ @relatedFiles _discovery.py (this service's sole source of every OIDC URL), _pkce.py (PKCE +
16
+ random helpers), _config.py (ResolvedConfig), _errors.py, tests/test_oidc.py
17
+ @created 2026-09-06
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ from typing import Optional
23
+ from urllib.parse import urlencode
24
+
25
+ import httpx
26
+
27
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
28
+ # _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
29
+ from memyauth._config import ResolvedConfig
30
+ from memyauth._errors import ConfigError, ProtocolError, RefreshRejectedError, TokenExchangeError, UserInfoError
31
+ from memyauth._pkce import generate_code_challenge, generate_code_verifier, random_nonce, random_state
32
+ from memyauth._types import AuthorizationRequest, OidcTokenResponse, RandomSource, UserInfo
33
+
34
+ from ._discovery import DiscoveryService
35
+ from ._transport import Transport, read_json_or_none, truncated_text
36
+
37
+ __all__ = ["OidcService"]
38
+
39
+ _AUTH_STANDARD_KEYS = frozenset(
40
+ {"response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "state", "nonce", "prompt"}
41
+ )
42
+ _LOGOUT_STANDARD_KEYS = frozenset({"post_logout_redirect_uri", "id_token_hint", "client_id", "state"})
43
+
44
+
45
+ class OidcService:
46
+ def __init__(
47
+ self,
48
+ *,
49
+ config: ResolvedConfig,
50
+ discovery: DiscoveryService,
51
+ transport: Optional[httpx.BaseTransport] = None,
52
+ random: Optional[RandomSource] = None,
53
+ ) -> None:
54
+ self._config = config
55
+ self._discovery = discovery
56
+ self._random: RandomSource = random or config["random"]
57
+ self._transport = Transport(
58
+ timeout_ms=config.get("discovery_timeout_ms", 10000),
59
+ max_retries=config["retry"]["max_retries"],
60
+ base_delay_ms=config["retry"]["base_delay_ms"],
61
+ retryable_statuses=config["retry"]["retryable_statuses"],
62
+ transport=transport,
63
+ )
64
+
65
+ def build_authorization_url(
66
+ self,
67
+ *,
68
+ redirect_uri: Optional[str] = None,
69
+ scopes: Optional[list[str]] = None,
70
+ state: Optional[str] = None,
71
+ nonce: Optional[str] = None,
72
+ code_verifier: Optional[str] = None,
73
+ prompt: Optional[str] = None,
74
+ return_to: Optional[str] = None,
75
+ extra_params: Optional[dict[str, str]] = None,
76
+ ) -> AuthorizationRequest:
77
+ # Validated BEFORE any network call (sdk-design.md §C.2) — mirrors js/src/oidc.ts:143,
78
+ # where `assertNoReservedCollision` runs ahead of `discovery.discover()`. A misconfigured
79
+ # caller must fail deterministically without spending a discovery round trip or generating
80
+ # provider traffic.
81
+ _assert_no_extra_param_collision(extra_params, _AUTH_STANDARD_KEYS)
82
+ doc = self._discovery.discover()
83
+
84
+ effective_redirect_uri = redirect_uri or self._config["redirect_uri"]
85
+ if not effective_redirect_uri:
86
+ raise ConfigError("redirectUri or publicBaseUrl must be configured")
87
+
88
+ cv = code_verifier or generate_code_verifier(self._random)
89
+ challenge = generate_code_challenge(cv)
90
+ effective_state = state or random_state(self._random)
91
+ effective_nonce = nonce or random_nonce(self._random)
92
+
93
+ base_scopes = scopes if scopes is not None else self._config["scopes"]
94
+ scopes_supported = doc.get("scopes_supported")
95
+ if isinstance(scopes_supported, list) and scopes_supported:
96
+ negotiated = [s for s in base_scopes if s == "openid" or s in scopes_supported]
97
+ else:
98
+ negotiated = list(base_scopes)
99
+ scope = " ".join(negotiated)
100
+
101
+ params: dict[str, str] = {
102
+ "response_type": "code",
103
+ "client_id": self._config["client_id"],
104
+ "redirect_uri": effective_redirect_uri,
105
+ "code_challenge": challenge,
106
+ "code_challenge_method": "S256",
107
+ "scope": scope,
108
+ "state": effective_state,
109
+ "nonce": effective_nonce,
110
+ "prompt": prompt if prompt is not None else "consent",
111
+ }
112
+ if extra_params:
113
+ params.update(extra_params)
114
+
115
+ url = f"{doc['authorization_endpoint']}?{urlencode(params)}"
116
+ return {
117
+ "url": url,
118
+ "code_verifier": cv,
119
+ "code_challenge": challenge,
120
+ "state": effective_state,
121
+ "nonce": effective_nonce,
122
+ "redirect_uri": effective_redirect_uri,
123
+ "scope": scope,
124
+ }
125
+
126
+ def _credential_form_fields(self) -> dict[str, str]:
127
+ if self._config["token_endpoint_auth_method"] == "client_secret_post":
128
+ return {"client_id": self._config["client_id"], "client_secret": self._config["client_secret"]}
129
+ return {}
130
+
131
+ def _credential_headers(self) -> dict[str, str]:
132
+ if self._config["token_endpoint_auth_method"] == "client_secret_basic":
133
+ raw = f"{self._config['client_id']}:{self._config['client_secret']}".encode("utf-8")
134
+ return {"Authorization": f"Basic {base64.b64encode(raw).decode('ascii')}"}
135
+ return {}
136
+
137
+ def _post_token(self, body: dict[str, str]) -> httpx.Response:
138
+ doc = self._discovery.discover()
139
+ form = {**body, **self._credential_form_fields()}
140
+ headers = self._credential_headers()
141
+ return self._transport.send("POST", doc["token_endpoint"], form=form, headers=headers)
142
+
143
+ @staticmethod
144
+ def _parse_token_response(resp: httpx.Response) -> OidcTokenResponse:
145
+ body = read_json_or_none(resp)
146
+ if not isinstance(body, dict) or "token_type" not in body or "expires_in" not in body:
147
+ raise ProtocolError("OIDC token response is missing token_type or expires_in")
148
+ return body # type: ignore[return-value]
149
+
150
+ def exchange_code(
151
+ self, *, code: str, code_verifier: str, redirect_uri: Optional[str] = None
152
+ ) -> OidcTokenResponse:
153
+ body = {
154
+ "grant_type": "authorization_code",
155
+ "code": code,
156
+ "redirect_uri": redirect_uri or self._config["redirect_uri"],
157
+ "code_verifier": code_verifier,
158
+ }
159
+ resp = self._post_token(body)
160
+ if not (200 <= resp.status_code < 300):
161
+ raise TokenExchangeError(
162
+ f"OIDC token exchange failed: {resp.status_code}", detail=truncated_text(resp)
163
+ )
164
+ return self._parse_token_response(resp)
165
+
166
+ def refresh_tokens(self, refresh_token: str) -> OidcTokenResponse:
167
+ body = {"grant_type": "refresh_token", "refresh_token": refresh_token}
168
+ resp = self._post_token(body)
169
+ if not (200 <= resp.status_code < 300):
170
+ raise RefreshRejectedError(
171
+ f"OIDC token refresh rejected: {resp.status_code}", detail=truncated_text(resp)
172
+ )
173
+ return self._parse_token_response(resp)
174
+
175
+ def fetch_user_info(self, access_token: str) -> UserInfo:
176
+ doc = self._discovery.discover()
177
+ resp = self._transport.send(
178
+ "GET",
179
+ doc["userinfo_endpoint"],
180
+ headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
181
+ )
182
+ if not (200 <= resp.status_code < 300):
183
+ raise UserInfoError(f"OIDC userinfo failed: {resp.status_code}", detail=truncated_text(resp))
184
+ body = read_json_or_none(resp)
185
+ if not isinstance(body, dict) or not isinstance(body.get("sub"), str) or not body["sub"]:
186
+ raise ProtocolError("OIDC userinfo response is missing sub")
187
+ return body # type: ignore[return-value]
188
+
189
+ def build_logout_url(
190
+ self,
191
+ *,
192
+ post_logout_redirect_uri: Optional[str] = None,
193
+ id_token_hint: Optional[str] = None,
194
+ client_id: Optional[str] = None,
195
+ state: Optional[str] = None,
196
+ extra_params: Optional[dict[str, str]] = None,
197
+ ) -> str:
198
+ doc = self._discovery.discover()
199
+ endpoint = doc.get("end_session_endpoint") or f"{doc['issuer'].rstrip('/')}/session/end"
200
+
201
+ # NOTE: unlike build_authorization_url, client_id is NEVER silently defaulted from config
202
+ # here — only an EXPLICITLY passed value is emitted. Memybase's own `buildLogoutUrl()`
203
+ # (the estate's shipped behavior, oidc.ts:222-225) takes no arguments and returns the bare
204
+ # endpoint with no query string at all; the frozen invariant "no arguments and no
205
+ # configured postLogoutRedirectUri -> bare endpoint, no ?" (conformance case
206
+ # `logout-url-bare-endpoint`) is only satisfiable if every param, client_id included, is
207
+ # caller-opt-in.
208
+ params: dict[str, str] = {}
209
+ effective_plr = post_logout_redirect_uri or self._config.get("post_logout_redirect_uri")
210
+ if effective_plr:
211
+ params["post_logout_redirect_uri"] = effective_plr
212
+ if id_token_hint:
213
+ params["id_token_hint"] = id_token_hint
214
+ if client_id:
215
+ params["client_id"] = client_id
216
+ if state:
217
+ params["state"] = state
218
+ _merge_extra_params(params, extra_params, _LOGOUT_STANDARD_KEYS)
219
+
220
+ if not params:
221
+ return endpoint
222
+ return f"{endpoint}?{urlencode(params)}"
223
+
224
+ def close(self) -> None:
225
+ self._transport.close()
226
+
227
+
228
+ def _assert_no_extra_param_collision(extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
229
+ if not extra:
230
+ return
231
+ for key in extra:
232
+ if key in standard_keys:
233
+ raise ConfigError(f"extraParams cannot override a standard parameter: {key}")
234
+
235
+
236
+ def _merge_extra_params(params: dict[str, str], extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
237
+ _assert_no_extra_param_collision(extra, standard_keys)
238
+ if extra:
239
+ params.update(extra)
@@ -0,0 +1,223 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/_transport.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Shared async HTTP transport — timeout, GET/HEAD-only retry, JSON/form encoding.
5
+ @module memyauth._transport
6
+ @description [async -> _sync/_transport.py twin via scripts/gen_sync.py]. Used by discovery, oidc,
7
+ iam and idtoken (§C.11). NOT bound to a single base_url — discovery/oidc/idtoken/iam each call
8
+ different absolute origins (issuer, internal issuer, iam_base_url), so every call site passes a
9
+ full URL. The retry policy is narrowed from the memybase pattern by §0 C13: an OAuth
10
+ authorization code is single-use, so `POST` (and any other non-GET/HEAD method) is NEVER
11
+ retried — not on status, not on timeout, not on a network error. A `Retry-After` response
12
+ header REPLACES (never stacks on) the computed exponential backoff for that iteration, clamped
13
+ to 60s. With `base_delay_ms=0` the backoff is exactly 0 regardless of jitter, which is how the
14
+ conformance harness pins determinism.
15
+
16
+ `send()` is deliberately low-level: it applies the retry policy and returns the raw
17
+ `httpx.Response` for ANY status (only a genuine transport failure raises NetworkError /
18
+ TimeoutError here). Each capability decides how to turn a non-2xx response into its OWN typed
19
+ error, because the estate's endpoints do NOT share one error envelope: `/iam/me` follows
20
+ openapi.yaml's `{code,message,detail}` shape (decoded via `decode_error`, §H), while OIDC-native
21
+ endpoints (discovery, token, userinfo) do not — they get bespoke wrapping
22
+ (DiscoveryError/TokenExchangeError/RefreshRejectedError/UserInfoError) carrying a truncated raw
23
+ body as `detail`, exactly as memybase's `oidc.ts` does. `request_json()` is the IAM-only
24
+ convenience that DOES raise via `decode_error` for a non-2xx response.
25
+ @dependencies httpx
26
+ @relatedFiles _errors.py (decode_error/NetworkError/TimeoutError/ProtocolError), _config.py
27
+ (RetryConfig), _discovery.py, _oidc.py, _iam.py, _idtoken.py, tests/test_transport.py
28
+ @created 2026-09-06
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import time
33
+ import random as _random_module
34
+ from typing import Any, Optional
35
+
36
+ import httpx
37
+
38
+ # ABSOLUTE import of the shared/pure module: unasync generates memyauth/_sync/_transport.py,
39
+ # whose relative `from ._errors` would resolve to the nonexistent memyauth._sync._errors — the
40
+ # absolute path makes both the async module and its sync twin bind the SAME single _errors module
41
+ # (the exact hazard memybase's own _transport.py documents at the same import line).
42
+ from memyauth._errors import NetworkError, ProtocolError, TimeoutError, decode_error
43
+
44
+ __all__ = ["Transport"]
45
+
46
+ _SAFE_METHODS = frozenset({"GET", "HEAD"})
47
+ _BASE_READ_RETRY = frozenset({429, 503})
48
+ _MAX_BACKOFF_MS = 30000
49
+ _MAX_RETRY_AFTER_MS = 60000
50
+
51
+
52
+ class Transport:
53
+ """Internal HTTP transport: header merge, timeout, GET/HEAD-only retry. One instance per
54
+ capability service (each may have its own timeout), all optionally sharing the same injected
55
+ `httpx.BaseTransport` test seam."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ timeout_ms: int = 10000,
61
+ max_retries: int = 2,
62
+ base_delay_ms: int = 500,
63
+ retryable_statuses: Optional[list[int]] = None,
64
+ headers: Optional[dict[str, str]] = None,
65
+ user_agent: Optional[str] = None,
66
+ transport: Optional[httpx.BaseTransport] = None,
67
+ ) -> None:
68
+ self._timeout_ms = timeout_ms
69
+ self._max_retries = max_retries
70
+ self._base_delay_ms = base_delay_ms
71
+ self._extra_retryable_statuses = frozenset(retryable_statuses or [])
72
+ self._extra_headers = dict(headers or {})
73
+ self._user_agent = user_agent
74
+ self._transport = transport
75
+ self._client: Optional[httpx.Client] = None
76
+
77
+ def _get_client(self) -> httpx.Client:
78
+ if self._client is None or self._client.is_closed:
79
+ headers = {"Accept": "application/json", **self._extra_headers}
80
+ if self._user_agent:
81
+ headers["User-Agent"] = self._user_agent
82
+ kwargs: dict[str, Any] = {"headers": headers}
83
+ if self._transport is not None:
84
+ kwargs["transport"] = self._transport
85
+ self._client = httpx.Client(**kwargs)
86
+ return self._client
87
+
88
+ def send(
89
+ self,
90
+ method: str,
91
+ url: str,
92
+ *,
93
+ headers: Optional[dict[str, str]] = None,
94
+ json_body: Any = None,
95
+ form: Optional[dict[str, str]] = None,
96
+ params: Optional[dict[str, str]] = None,
97
+ timeout_ms: Optional[int] = None,
98
+ ) -> httpx.Response:
99
+ """Apply the retry policy and return the raw response for ANY terminal status. Raises
100
+ only `NetworkError` / `TimeoutError` for a genuine transport-level failure."""
101
+ method_upper = method.upper()
102
+ is_safe = method_upper in _SAFE_METHODS
103
+ max_retries = self._max_retries if is_safe else 0
104
+ retryable_statuses = (_BASE_READ_RETRY | self._extra_retryable_statuses) if is_safe else frozenset()
105
+ effective_timeout = (timeout_ms if timeout_ms is not None else self._timeout_ms) / 1000.0
106
+
107
+ client = self._get_client()
108
+ attempt = 0
109
+
110
+ while True:
111
+ try:
112
+ resp = client.request(
113
+ method_upper,
114
+ url,
115
+ headers=headers,
116
+ json=json_body,
117
+ data=form,
118
+ params=params,
119
+ timeout=effective_timeout,
120
+ )
121
+ except httpx.TimeoutException as exc:
122
+ if is_safe and attempt < max_retries:
123
+ time.sleep(_backoff_seconds(attempt, self._base_delay_ms))
124
+ attempt += 1
125
+ continue
126
+ raise TimeoutError(str(exc)) from exc
127
+ except httpx.HTTPError as exc:
128
+ raise NetworkError(str(exc)) from exc
129
+
130
+ if resp.status_code in retryable_statuses and attempt < max_retries:
131
+ retry_after_ms = _parse_retry_after_ms(resp.headers.get("retry-after"))
132
+ delay_seconds = (
133
+ min(retry_after_ms, _MAX_RETRY_AFTER_MS) / 1000.0
134
+ if retry_after_ms is not None
135
+ else _backoff_seconds(attempt, self._base_delay_ms)
136
+ )
137
+ time.sleep(delay_seconds)
138
+ attempt += 1
139
+ continue
140
+
141
+ return resp
142
+
143
+ def request_json(
144
+ self,
145
+ method: str,
146
+ url: str,
147
+ *,
148
+ headers: Optional[dict[str, str]] = None,
149
+ json_body: Any = None,
150
+ form: Optional[dict[str, str]] = None,
151
+ params: Optional[dict[str, str]] = None,
152
+ timeout_ms: Optional[int] = None,
153
+ ) -> Any:
154
+ """IAM-only convenience: `send()` + auto-raise via `decode_error` on non-2xx (the openapi
155
+ `{code,message,detail}` envelope). 204 / empty 2xx body -> `None`."""
156
+ resp = self.send(
157
+ method,
158
+ url,
159
+ headers=headers,
160
+ json_body=json_body,
161
+ form=form,
162
+ params=params,
163
+ timeout_ms=timeout_ms,
164
+ )
165
+ if resp.status_code == 204:
166
+ return None
167
+ if 200 <= resp.status_code < 300:
168
+ return read_json_or_none(resp)
169
+ raise decode_error(
170
+ resp.status_code,
171
+ body=read_json_or_none(resp),
172
+ request_id=resp.headers.get("x-request-id"),
173
+ )
174
+
175
+ def close(self) -> None:
176
+ if self._client is not None:
177
+ self._client.close()
178
+ self._client = None
179
+
180
+
181
+ def read_json_or_none(resp: httpx.Response) -> Any:
182
+ """2xx body -> parsed JSON, or `None` when empty. Raises `ProtocolError` on non-JSON content
183
+ (a 2xx body that fails to parse is a protocol violation, not an absent body)."""
184
+ if not resp.content:
185
+ return None
186
+ try:
187
+ return resp.json()
188
+ except ValueError as exc:
189
+ raise ProtocolError("expected a JSON response body") from exc
190
+
191
+
192
+ def read_json_or_none_lenient(resp: httpx.Response) -> Any:
193
+ """Like `read_json_or_none`, but never raises — used when building an error `detail` where a
194
+ non-JSON body is expected and should not itself trigger a different failure."""
195
+ try:
196
+ return resp.json() if resp.content else None
197
+ except ValueError:
198
+ return None
199
+
200
+
201
+ def truncated_text(resp: httpx.Response, limit: int = 200) -> str:
202
+ """First `limit` chars of the raw body, for an error `detail` — memybase truncates identically
203
+ before logging (§C.3)."""
204
+ try:
205
+ text = resp.text
206
+ except Exception:
207
+ return ""
208
+ return text[:limit]
209
+
210
+
211
+ def _backoff_seconds(attempt: int, base_delay_ms: int) -> float:
212
+ computed = min(_MAX_BACKOFF_MS, base_delay_ms * (2**attempt))
213
+ jitter = _random_module.uniform(0, base_delay_ms) if base_delay_ms else 0
214
+ return (computed + jitter) / 1000.0
215
+
216
+
217
+ def _parse_retry_after_ms(value: Optional[str]) -> Optional[int]:
218
+ if not value:
219
+ return None
220
+ try:
221
+ return max(0, int(float(value) * 1000))
222
+ except ValueError:
223
+ return None
memyauth/_transport.py ADDED
@@ -0,0 +1,221 @@
1
+ """
2
+ @fileoverview Shared async HTTP transport — timeout, GET/HEAD-only retry, JSON/form encoding.
3
+ @module memyauth._transport
4
+ @description [async -> _sync/_transport.py twin via scripts/gen_sync.py]. Used by discovery, oidc,
5
+ iam and idtoken (§C.11). NOT bound to a single base_url — discovery/oidc/idtoken/iam each call
6
+ different absolute origins (issuer, internal issuer, iam_base_url), so every call site passes a
7
+ full URL. The retry policy is narrowed from the memybase pattern by §0 C13: an OAuth
8
+ authorization code is single-use, so `POST` (and any other non-GET/HEAD method) is NEVER
9
+ retried — not on status, not on timeout, not on a network error. A `Retry-After` response
10
+ header REPLACES (never stacks on) the computed exponential backoff for that iteration, clamped
11
+ to 60s. With `base_delay_ms=0` the backoff is exactly 0 regardless of jitter, which is how the
12
+ conformance harness pins determinism.
13
+
14
+ `send()` is deliberately low-level: it applies the retry policy and returns the raw
15
+ `httpx.Response` for ANY status (only a genuine transport failure raises NetworkError /
16
+ TimeoutError here). Each capability decides how to turn a non-2xx response into its OWN typed
17
+ error, because the estate's endpoints do NOT share one error envelope: `/iam/me` follows
18
+ openapi.yaml's `{code,message,detail}` shape (decoded via `decode_error`, §H), while OIDC-native
19
+ endpoints (discovery, token, userinfo) do not — they get bespoke wrapping
20
+ (DiscoveryError/TokenExchangeError/RefreshRejectedError/UserInfoError) carrying a truncated raw
21
+ body as `detail`, exactly as memybase's `oidc.ts` does. `request_json()` is the IAM-only
22
+ convenience that DOES raise via `decode_error` for a non-2xx response.
23
+ @dependencies httpx
24
+ @relatedFiles _errors.py (decode_error/NetworkError/TimeoutError/ProtocolError), _config.py
25
+ (RetryConfig), _discovery.py, _oidc.py, _iam.py, _idtoken.py, tests/test_transport.py
26
+ @created 2026-09-06
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import random as _random_module
32
+ from typing import Any, Optional
33
+
34
+ import httpx
35
+
36
+ # ABSOLUTE import of the shared/pure module: unasync generates memyauth/_sync/_transport.py,
37
+ # whose relative `from ._errors` would resolve to the nonexistent memyauth._sync._errors — the
38
+ # absolute path makes both the async module and its sync twin bind the SAME single _errors module
39
+ # (the exact hazard memybase's own _transport.py documents at the same import line).
40
+ from memyauth._errors import NetworkError, ProtocolError, TimeoutError, decode_error
41
+
42
+ __all__ = ["Transport"]
43
+
44
+ _SAFE_METHODS = frozenset({"GET", "HEAD"})
45
+ _BASE_READ_RETRY = frozenset({429, 503})
46
+ _MAX_BACKOFF_MS = 30000
47
+ _MAX_RETRY_AFTER_MS = 60000
48
+
49
+
50
+ class Transport:
51
+ """Internal HTTP transport: header merge, timeout, GET/HEAD-only retry. One instance per
52
+ capability service (each may have its own timeout), all optionally sharing the same injected
53
+ `httpx.BaseTransport` test seam."""
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ timeout_ms: int = 10000,
59
+ max_retries: int = 2,
60
+ base_delay_ms: int = 500,
61
+ retryable_statuses: Optional[list[int]] = None,
62
+ headers: Optional[dict[str, str]] = None,
63
+ user_agent: Optional[str] = None,
64
+ transport: Optional[httpx.BaseTransport] = None,
65
+ ) -> None:
66
+ self._timeout_ms = timeout_ms
67
+ self._max_retries = max_retries
68
+ self._base_delay_ms = base_delay_ms
69
+ self._extra_retryable_statuses = frozenset(retryable_statuses or [])
70
+ self._extra_headers = dict(headers or {})
71
+ self._user_agent = user_agent
72
+ self._transport = transport
73
+ self._client: Optional[httpx.AsyncClient] = None
74
+
75
+ def _get_client(self) -> httpx.AsyncClient:
76
+ if self._client is None or self._client.is_closed:
77
+ headers = {"Accept": "application/json", **self._extra_headers}
78
+ if self._user_agent:
79
+ headers["User-Agent"] = self._user_agent
80
+ kwargs: dict[str, Any] = {"headers": headers}
81
+ if self._transport is not None:
82
+ kwargs["transport"] = self._transport
83
+ self._client = httpx.AsyncClient(**kwargs)
84
+ return self._client
85
+
86
+ async def send(
87
+ self,
88
+ method: str,
89
+ url: str,
90
+ *,
91
+ headers: Optional[dict[str, str]] = None,
92
+ json_body: Any = None,
93
+ form: Optional[dict[str, str]] = None,
94
+ params: Optional[dict[str, str]] = None,
95
+ timeout_ms: Optional[int] = None,
96
+ ) -> httpx.Response:
97
+ """Apply the retry policy and return the raw response for ANY terminal status. Raises
98
+ only `NetworkError` / `TimeoutError` for a genuine transport-level failure."""
99
+ method_upper = method.upper()
100
+ is_safe = method_upper in _SAFE_METHODS
101
+ max_retries = self._max_retries if is_safe else 0
102
+ retryable_statuses = (_BASE_READ_RETRY | self._extra_retryable_statuses) if is_safe else frozenset()
103
+ effective_timeout = (timeout_ms if timeout_ms is not None else self._timeout_ms) / 1000.0
104
+
105
+ client = self._get_client()
106
+ attempt = 0
107
+
108
+ while True:
109
+ try:
110
+ resp = await client.request(
111
+ method_upper,
112
+ url,
113
+ headers=headers,
114
+ json=json_body,
115
+ data=form,
116
+ params=params,
117
+ timeout=effective_timeout,
118
+ )
119
+ except httpx.TimeoutException as exc:
120
+ if is_safe and attempt < max_retries:
121
+ await asyncio.sleep(_backoff_seconds(attempt, self._base_delay_ms))
122
+ attempt += 1
123
+ continue
124
+ raise TimeoutError(str(exc)) from exc
125
+ except httpx.HTTPError as exc:
126
+ raise NetworkError(str(exc)) from exc
127
+
128
+ if resp.status_code in retryable_statuses and attempt < max_retries:
129
+ retry_after_ms = _parse_retry_after_ms(resp.headers.get("retry-after"))
130
+ delay_seconds = (
131
+ min(retry_after_ms, _MAX_RETRY_AFTER_MS) / 1000.0
132
+ if retry_after_ms is not None
133
+ else _backoff_seconds(attempt, self._base_delay_ms)
134
+ )
135
+ await asyncio.sleep(delay_seconds)
136
+ attempt += 1
137
+ continue
138
+
139
+ return resp
140
+
141
+ async def request_json(
142
+ self,
143
+ method: str,
144
+ url: str,
145
+ *,
146
+ headers: Optional[dict[str, str]] = None,
147
+ json_body: Any = None,
148
+ form: Optional[dict[str, str]] = None,
149
+ params: Optional[dict[str, str]] = None,
150
+ timeout_ms: Optional[int] = None,
151
+ ) -> Any:
152
+ """IAM-only convenience: `send()` + auto-raise via `decode_error` on non-2xx (the openapi
153
+ `{code,message,detail}` envelope). 204 / empty 2xx body -> `None`."""
154
+ resp = await self.send(
155
+ method,
156
+ url,
157
+ headers=headers,
158
+ json_body=json_body,
159
+ form=form,
160
+ params=params,
161
+ timeout_ms=timeout_ms,
162
+ )
163
+ if resp.status_code == 204:
164
+ return None
165
+ if 200 <= resp.status_code < 300:
166
+ return read_json_or_none(resp)
167
+ raise decode_error(
168
+ resp.status_code,
169
+ body=read_json_or_none(resp),
170
+ request_id=resp.headers.get("x-request-id"),
171
+ )
172
+
173
+ async def close(self) -> None:
174
+ if self._client is not None:
175
+ await self._client.aclose()
176
+ self._client = None
177
+
178
+
179
+ def read_json_or_none(resp: httpx.Response) -> Any:
180
+ """2xx body -> parsed JSON, or `None` when empty. Raises `ProtocolError` on non-JSON content
181
+ (a 2xx body that fails to parse is a protocol violation, not an absent body)."""
182
+ if not resp.content:
183
+ return None
184
+ try:
185
+ return resp.json()
186
+ except ValueError as exc:
187
+ raise ProtocolError("expected a JSON response body") from exc
188
+
189
+
190
+ def read_json_or_none_lenient(resp: httpx.Response) -> Any:
191
+ """Like `read_json_or_none`, but never raises — used when building an error `detail` where a
192
+ non-JSON body is expected and should not itself trigger a different failure."""
193
+ try:
194
+ return resp.json() if resp.content else None
195
+ except ValueError:
196
+ return None
197
+
198
+
199
+ def truncated_text(resp: httpx.Response, limit: int = 200) -> str:
200
+ """First `limit` chars of the raw body, for an error `detail` — memybase truncates identically
201
+ before logging (§C.3)."""
202
+ try:
203
+ text = resp.text
204
+ except Exception:
205
+ return ""
206
+ return text[:limit]
207
+
208
+
209
+ def _backoff_seconds(attempt: int, base_delay_ms: int) -> float:
210
+ computed = min(_MAX_BACKOFF_MS, base_delay_ms * (2**attempt))
211
+ jitter = _random_module.uniform(0, base_delay_ms) if base_delay_ms else 0
212
+ return (computed + jitter) / 1000.0
213
+
214
+
215
+ def _parse_retry_after_ms(value: Optional[str]) -> Optional[int]:
216
+ if not value:
217
+ return None
218
+ try:
219
+ return max(0, int(float(value) * 1000))
220
+ except ValueError:
221
+ return None