standard-http 0.2.1__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,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: standard-http
3
+ Version: 0.2.1
4
+ Summary: Async HTTP client and authentication helpers for Standard
5
+ Project-URL: Documentation, https://appunni-m.github.io/python-standard/http/
6
+ Project-URL: Repository, https://github.com/appunni-m/python-standard
7
+ Author: Standard contributors
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: async,authentication,http,httpx,standard
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Requires-Dist: standard==0.2.1
24
+ Description-Content-Type: text/markdown
25
+
26
+ # standard-http
27
+
28
+ `standard-http` adds an asynchronous HTTP client and authentication helpers to
29
+ the `standard` Python library. Install the package to use the `std.http`
30
+ import namespace:
31
+
32
+ From this repository, install the core and HTTP projects together:
33
+
34
+ ```shell
35
+ python -m pip install . packages/standard-http
36
+ ```
37
+
38
+ After the add-on is published, install the distributions with
39
+ `python -m pip install standard standard-http`.
40
+
41
+ ```python
42
+ from std.http import AuthConfig, BearerAuth, HttpClient, RetryConfig
43
+
44
+ client = HttpClient(
45
+ base_url="https://api.example.com/v1/",
46
+ auth_config=AuthConfig(auth=BearerAuth("your-token")),
47
+ retries=RetryConfig(max_attempts=3),
48
+ )
49
+
50
+ async with client:
51
+ response = await client.get("users", params={"active": "true"})
52
+ response.raise_for_status()
53
+ print(response.json())
54
+ ```
55
+
56
+ The package also provides `BasicAuth`, `ApiKeyAuth`, `AuthBase`, `AuthConfig`,
57
+ and Pydantic response helpers such as `get_model`. See the [HTTP guide](../../docs/http.md)
58
+ for retry, timeout, token refresh, error, transport injection, and lifecycle
59
+ behavior.
60
+
61
+ The wheel contains only `std.http`; it does not bundle FastAPI, AWS, or other
62
+ integration modules.
@@ -0,0 +1,9 @@
1
+ std/http/__init__.py,sha256=Mp6N26w2emdqIhNQx4-KFq2kzFVf1-ypX0qz0XB4KIs,415
2
+ std/http/auth.py,sha256=NrEkD-0dnx9eq_tqYTGc0RoeuQUs6xTu_VNRtTyidTo,3644
3
+ std/http/client.py,sha256=neDnFEG1C21ChGBPASkiR66cYvyrdghGK0t6CHgjcKM,15974
4
+ std/http/config.py,sha256=SLuqttlEp66qqoeC3vqfl-wotCh3HntCkrA-NlUHVJg,3826
5
+ std/http/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ standard_http-0.2.1.dist-info/METADATA,sha256=wM1FAfOLM-B9rBuNcwYTQoj6rErhfmxoD0luMN6ZKtA,2176
7
+ standard_http-0.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ standard_http-0.2.1.dist-info/licenses/LICENSE,sha256=RnyEKEDLRmbhyf36lr51oOrBm8qr4u2JYGHhg2Rcs7g,1073
9
+ standard_http-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Standard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
std/http/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Async HTTP client, retry configuration, and authentication helpers."""
2
+
3
+ from std.http.auth import ApiKeyAuth, AuthBase, BasicAuth, BearerAuth
4
+ from std.http.client import HttpClient, HttpClientError
5
+ from std.http.config import AuthConfig, RetryConfig
6
+
7
+ __all__ = [
8
+ "ApiKeyAuth",
9
+ "AuthBase",
10
+ "AuthConfig",
11
+ "BasicAuth",
12
+ "BearerAuth",
13
+ "HttpClient",
14
+ "HttpClientError",
15
+ "RetryConfig",
16
+ ]
std/http/auth.py ADDED
@@ -0,0 +1,96 @@
1
+ """Authentication handlers compatible with the Standard HTTP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+
7
+ import httpx
8
+
9
+
10
+ class AuthBase:
11
+ """Base class for simple request-header authentication handlers."""
12
+
13
+ def auth_flow(self, request: httpx.Request) -> httpx.Request:
14
+ """Return the request unchanged; subclasses can add authentication headers."""
15
+ return request
16
+
17
+
18
+ class BearerAuth(AuthBase):
19
+ """Add a bearer token to the ``Authorization`` request header."""
20
+
21
+ __slots__ = ("token",)
22
+
23
+ def __init__(self, token: str) -> None:
24
+ """Create bearer auth without including the secret in object representations."""
25
+ if not isinstance(token, str) or not token.strip():
26
+ raise ValueError("bearer token must be a non-empty string")
27
+ self.token = token
28
+
29
+ def auth_flow(self, request: httpx.Request) -> httpx.Request:
30
+ """Set the bearer authorization header on a request."""
31
+ request.headers["Authorization"] = f"Bearer {self.token}"
32
+ return request
33
+
34
+ def __repr__(self) -> str:
35
+ """Return a representation that does not disclose the bearer token."""
36
+ return "BearerAuth(token=[redacted])"
37
+
38
+
39
+ class BasicAuth(AuthBase):
40
+ """Add HTTP Basic authentication using UTF-8 encoded credentials."""
41
+
42
+ __slots__ = ("username", "password")
43
+
44
+ def __init__(self, username: str, password: str) -> None:
45
+ """Create Basic auth without exposing credentials in object representations."""
46
+ if not isinstance(username, str) or not isinstance(password, str):
47
+ raise TypeError("username and password must be strings")
48
+ self.username = username
49
+ self.password = password
50
+
51
+ def auth_flow(self, request: httpx.Request) -> httpx.Request:
52
+ """Set the Basic authorization header on a request."""
53
+ credentials = f"{self.username}:{self.password}".encode()
54
+ encoded = base64.b64encode(credentials).decode("ascii")
55
+ request.headers["Authorization"] = f"Basic {encoded}"
56
+ return request
57
+
58
+ def __repr__(self) -> str:
59
+ """Return a representation that does not disclose either credential."""
60
+ return "BasicAuth(username=[redacted], password=[redacted])"
61
+
62
+
63
+ class ApiKeyAuth(AuthBase):
64
+ """Add an API key to a configurable header, optionally with a prefix."""
65
+
66
+ __slots__ = ("api_key", "header_name", "prefix")
67
+
68
+ def __init__(
69
+ self,
70
+ api_key: str,
71
+ header_name: str = "X-API-Key",
72
+ prefix: str | None = None,
73
+ ) -> None:
74
+ """Create API-key auth with a custom header and optional value prefix."""
75
+ if not isinstance(api_key, str) or not api_key.strip():
76
+ raise ValueError("API key must be a non-empty string")
77
+ if not isinstance(header_name, str) or not header_name.strip():
78
+ raise ValueError("header name must be a non-empty string")
79
+ if prefix is not None and not isinstance(prefix, str):
80
+ raise TypeError("prefix must be a string or None")
81
+ self.api_key = api_key
82
+ self.header_name = header_name
83
+ self.prefix = prefix
84
+
85
+ def auth_flow(self, request: httpx.Request) -> httpx.Request:
86
+ """Set the API-key header on a request."""
87
+ value = f"{self.prefix} {self.api_key}" if self.prefix else self.api_key
88
+ request.headers[self.header_name] = value
89
+ return request
90
+
91
+ def __repr__(self) -> str:
92
+ """Return a representation that does not disclose the API key."""
93
+ return (
94
+ "ApiKeyAuth(api_key=[redacted], "
95
+ f"header_name={self.header_name!r}, prefix={self.prefix!r})"
96
+ )
std/http/client.py ADDED
@@ -0,0 +1,407 @@
1
+ """Async HTTP client with authentication, retries, and Pydantic response helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import math
8
+ import random
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any, TypeVar
11
+
12
+ import httpx
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+ from std.http.auth import ApiKeyAuth, AuthBase, BearerAuth
16
+ from std.http.config import AuthConfig, RetryConfig
17
+
18
+ ModelT = TypeVar("ModelT", bound=BaseModel)
19
+ _UNSET = object()
20
+ _logger = logging.getLogger(__name__)
21
+
22
+
23
+ class HttpClientError(Exception):
24
+ """A sanitized HTTP client failure with optional status and response context."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ status_code: int | None = None,
30
+ response: httpx.Response | None = None,
31
+ ) -> None:
32
+ """Create an error without rendering request URLs, headers, or exception text."""
33
+ super().__init__(message)
34
+ self.status_code = status_code
35
+ self.response = response
36
+
37
+
38
+ class _LegacyAuth(httpx.Auth):
39
+ """Adapt the historical one-request auth-flow contract to HTTPX auth."""
40
+
41
+ def __init__(self, auth: AuthBase) -> None:
42
+ self._auth = auth
43
+
44
+ def auth_flow(self, request: httpx.Request) -> Any:
45
+ yield self._auth.auth_flow(request)
46
+
47
+
48
+ class HttpClient:
49
+ """Reusable asynchronous HTTP client with explicit retry and cleanup behavior.
50
+
51
+ ``retries`` accepts the historical integer form, interpreted as total attempts.
52
+ ``RetryConfig`` offers method, status, exception, backoff, and jitter controls.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ base_url: str = "",
58
+ timeout: float | httpx.Timeout | None = 10.0,
59
+ retries: int | RetryConfig | None = None,
60
+ auth_config: AuthConfig | None = None,
61
+ default_headers: Mapping[str, str] | None = None,
62
+ verify_ssl: bool = True,
63
+ follow_redirects: bool = True,
64
+ transport: httpx.AsyncBaseTransport | None = None,
65
+ ) -> None:
66
+ """Configure an HTTP client; the underlying connection pool is created lazily."""
67
+ if isinstance(retries, bool):
68
+ raise TypeError("retries cannot be a boolean")
69
+ if isinstance(retries, int):
70
+ if retries < 0:
71
+ raise ValueError("retries cannot be negative")
72
+ self.retry_config = RetryConfig(max_attempts=max(1, retries))
73
+ elif isinstance(retries, RetryConfig):
74
+ self.retry_config = retries
75
+ elif retries is None:
76
+ self.retry_config = RetryConfig()
77
+ else:
78
+ raise TypeError("retries must be an integer, RetryConfig, or None")
79
+
80
+ if timeout is not None and not isinstance(timeout, httpx.Timeout):
81
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
82
+ raise TypeError("timeout must be a number, HTTPX Timeout, or None")
83
+ if not math.isfinite(timeout) or timeout < 0:
84
+ raise ValueError("timeout must be finite and non-negative")
85
+ timeout = httpx.Timeout(float(timeout))
86
+ if not isinstance(base_url, str):
87
+ raise TypeError("base_url must be a string")
88
+ if not isinstance(verify_ssl, bool) or not isinstance(follow_redirects, bool):
89
+ raise TypeError("verify_ssl and follow_redirects must be booleans")
90
+
91
+ self.base_url = base_url
92
+ self.timeout = timeout
93
+ self.auth_config = auth_config or AuthConfig()
94
+ self.default_headers = dict(default_headers or {})
95
+ self.verify_ssl = verify_ssl
96
+ self.follow_redirects = follow_redirects
97
+ self._transport = transport
98
+ self._client: httpx.AsyncClient | None = None
99
+ self._client_lock = asyncio.Lock()
100
+
101
+ async def __aenter__(self) -> HttpClient:
102
+ """Open the underlying HTTPX client and return this wrapper."""
103
+ await self._ensure_client()
104
+ return self
105
+
106
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
107
+ """Close the underlying connection pool when leaving an async context."""
108
+ await self.close()
109
+
110
+ @property
111
+ async def client(self) -> httpx.AsyncClient:
112
+ """Return the lazily created HTTPX client for advanced use."""
113
+ return await self._ensure_client()
114
+
115
+ async def _ensure_client(self) -> httpx.AsyncClient:
116
+ """Create exactly one HTTPX client when concurrent requests start together."""
117
+ if self._client is not None:
118
+ return self._client
119
+ async with self._client_lock:
120
+ if self._client is None:
121
+ try:
122
+ self._client = httpx.AsyncClient(
123
+ base_url=self.base_url,
124
+ timeout=self.timeout,
125
+ verify=self.verify_ssl,
126
+ follow_redirects=self.follow_redirects,
127
+ headers=self.default_headers,
128
+ transport=self._transport,
129
+ )
130
+ except Exception:
131
+ raise HttpClientError("HTTP client initialization failed") from None
132
+ return self._client
133
+
134
+ async def close(self) -> None:
135
+ """Close the connection pool; calling this method repeatedly is safe."""
136
+ async with self._client_lock:
137
+ client = self._client
138
+ self._client = None
139
+ if client is not None:
140
+ try:
141
+ await client.aclose()
142
+ except Exception:
143
+ raise HttpClientError("HTTP client cleanup failed") from None
144
+
145
+ async def request(
146
+ self,
147
+ method: str,
148
+ url: str,
149
+ headers: Mapping[str, str] | None = None,
150
+ **kwargs: Any,
151
+ ) -> httpx.Response:
152
+ """Send a request and return its response, including non-success statuses."""
153
+ if not isinstance(method, str) or not method.strip():
154
+ raise ValueError("method must be a non-empty string")
155
+ if not isinstance(url, str) or not url:
156
+ raise ValueError("url must be a non-empty string")
157
+
158
+ normalized_method = method.strip().upper()
159
+ request_kwargs = dict(kwargs)
160
+ per_request_auth = request_kwargs.pop("auth", _UNSET)
161
+ auth = self.auth_config.auth if per_request_auth is _UNSET else per_request_auth
162
+ client = await self._ensure_client()
163
+ attempts = self.retry_config.max_attempts
164
+ replayable = _request_body_is_replayable(request_kwargs)
165
+ refreshed = False
166
+
167
+ for attempt in range(attempts):
168
+ try:
169
+ request = client.build_request(
170
+ normalized_method,
171
+ url,
172
+ headers=headers,
173
+ **request_kwargs,
174
+ )
175
+ response = await client.send(request, auth=_as_httpx_auth(auth))
176
+ except Exception as error:
177
+ if (
178
+ attempt + 1 < attempts
179
+ and replayable
180
+ and normalized_method in self.retry_config.retry_methods
181
+ and _is_retryable_exception(error, self.retry_config)
182
+ ):
183
+ _logger.warning(
184
+ "HTTP %s request failed with %s; retrying attempt %d of %d",
185
+ normalized_method,
186
+ type(error).__name__,
187
+ attempt + 2,
188
+ attempts,
189
+ )
190
+ await self._sleep_before_retry(attempt)
191
+ continue
192
+ raise HttpClientError(
193
+ f"HTTP {normalized_method} request failed after {attempt + 1} attempt(s)"
194
+ ) from None
195
+
196
+ if (
197
+ response.status_code == 401
198
+ and not refreshed
199
+ and self.auth_config.refresh_callback is not None
200
+ and attempt + 1 < attempts
201
+ and replayable
202
+ and normalized_method in self.retry_config.retry_methods
203
+ ):
204
+ try:
205
+ replacement = self.auth_config.refresh_callback()
206
+ auth = _replace_auth_secret(auth, replacement)
207
+ except Exception:
208
+ await response.aclose()
209
+ raise HttpClientError(
210
+ "HTTP authentication refresh failed",
211
+ status_code=401,
212
+ response=response,
213
+ ) from None
214
+ refreshed = True
215
+ await response.aclose()
216
+ _logger.info(
217
+ "HTTP %s authentication was refreshed after status 401", normalized_method
218
+ )
219
+ continue
220
+
221
+ if (
222
+ attempt + 1 < attempts
223
+ and replayable
224
+ and normalized_method in self.retry_config.retry_methods
225
+ and response.status_code in self.retry_config.retry_statuses
226
+ ):
227
+ _logger.warning(
228
+ "HTTP %s returned status %d; retrying attempt %d of %d",
229
+ normalized_method,
230
+ response.status_code,
231
+ attempt + 2,
232
+ attempts,
233
+ )
234
+ await response.aclose()
235
+ await self._sleep_before_retry(attempt)
236
+ continue
237
+ return response
238
+
239
+ raise HttpClientError(
240
+ f"HTTP {normalized_method} request failed after {attempts} attempt(s)"
241
+ )
242
+
243
+ async def get(
244
+ self, url: str, headers: Mapping[str, str] | None = None, **kwargs: Any
245
+ ) -> httpx.Response:
246
+ """Send a GET request."""
247
+ return await self.request("GET", url, headers, **kwargs)
248
+
249
+ async def post(
250
+ self, url: str, headers: Mapping[str, str] | None = None, **kwargs: Any
251
+ ) -> httpx.Response:
252
+ """Send a POST request."""
253
+ return await self.request("POST", url, headers, **kwargs)
254
+
255
+ async def put(
256
+ self, url: str, headers: Mapping[str, str] | None = None, **kwargs: Any
257
+ ) -> httpx.Response:
258
+ """Send a PUT request."""
259
+ return await self.request("PUT", url, headers, **kwargs)
260
+
261
+ async def patch(
262
+ self, url: str, headers: Mapping[str, str] | None = None, **kwargs: Any
263
+ ) -> httpx.Response:
264
+ """Send a PATCH request."""
265
+ return await self.request("PATCH", url, headers, **kwargs)
266
+
267
+ async def delete(
268
+ self, url: str, headers: Mapping[str, str] | None = None, **kwargs: Any
269
+ ) -> httpx.Response:
270
+ """Send a DELETE request."""
271
+ return await self.request("DELETE", url, headers, **kwargs)
272
+
273
+ async def get_model(
274
+ self,
275
+ url: str,
276
+ response_model: type[ModelT],
277
+ headers: Mapping[str, str] | None = None,
278
+ **kwargs: Any,
279
+ ) -> ModelT:
280
+ """Send GET and validate its JSON response as a Pydantic model."""
281
+ return await self._send_model("GET", url, response_model, headers, **kwargs)
282
+
283
+ async def post_model(
284
+ self,
285
+ url: str,
286
+ response_model: type[ModelT],
287
+ headers: Mapping[str, str] | None = None,
288
+ **kwargs: Any,
289
+ ) -> ModelT:
290
+ """Send POST and validate its JSON response as a Pydantic model."""
291
+ return await self._send_model("POST", url, response_model, headers, **kwargs)
292
+
293
+ async def put_model(
294
+ self,
295
+ url: str,
296
+ response_model: type[ModelT],
297
+ headers: Mapping[str, str] | None = None,
298
+ **kwargs: Any,
299
+ ) -> ModelT:
300
+ """Send PUT and validate its JSON response as a Pydantic model."""
301
+ return await self._send_model("PUT", url, response_model, headers, **kwargs)
302
+
303
+ async def patch_model(
304
+ self,
305
+ url: str,
306
+ response_model: type[ModelT],
307
+ headers: Mapping[str, str] | None = None,
308
+ **kwargs: Any,
309
+ ) -> ModelT:
310
+ """Send PATCH and validate its JSON response as a Pydantic model."""
311
+ return await self._send_model("PATCH", url, response_model, headers, **kwargs)
312
+
313
+ async def delete_model(
314
+ self,
315
+ url: str,
316
+ response_model: type[ModelT],
317
+ headers: Mapping[str, str] | None = None,
318
+ **kwargs: Any,
319
+ ) -> ModelT:
320
+ """Send DELETE and validate its JSON response as a Pydantic model."""
321
+ return await self._send_model("DELETE", url, response_model, headers, **kwargs)
322
+
323
+ async def _send_model(
324
+ self,
325
+ method: str,
326
+ url: str,
327
+ response_model: type[ModelT],
328
+ headers: Mapping[str, str] | None,
329
+ **kwargs: Any,
330
+ ) -> ModelT:
331
+ """Validate a successful JSON response without leaking response details in errors."""
332
+ response = await self.request(method, url, headers, **kwargs)
333
+ if not response.is_success:
334
+ raise HttpClientError(
335
+ f"HTTP {method} returned status {response.status_code}",
336
+ status_code=response.status_code,
337
+ response=response,
338
+ )
339
+ try:
340
+ payload = response.json()
341
+ return response_model.model_validate(payload)
342
+ except (ValueError, ValidationError):
343
+ raise HttpClientError(
344
+ f"HTTP {method} response did not match {response_model.__name__}",
345
+ status_code=response.status_code,
346
+ response=response,
347
+ ) from None
348
+
349
+ async def _sleep_before_retry(self, attempt: int) -> None:
350
+ """Sleep for exponential backoff capped at one minute."""
351
+ factor = self.retry_config.backoff_factor
352
+ if factor == 0:
353
+ return
354
+ delay = min(factor * (2 ** min(attempt, 60)), 60.0)
355
+ if self.retry_config.jitter:
356
+ delay = min(delay * random.uniform(0.5, 1.5), 60.0)
357
+ await asyncio.sleep(delay)
358
+
359
+
360
+ def _as_httpx_auth(auth: Any) -> httpx.Auth | None:
361
+ """Adapt the legacy auth handler to HTTPX while accepting native HTTPX auth."""
362
+ if auth is None:
363
+ return None
364
+ if isinstance(auth, httpx.Auth):
365
+ return auth
366
+ auth_flow = getattr(auth, "auth_flow", None)
367
+ if callable(auth_flow):
368
+ return _LegacyAuth(auth)
369
+ raise TypeError("auth must implement httpx.Auth or AuthBase.auth_flow")
370
+
371
+
372
+ def _replace_auth_secret(auth: Any, secret: str) -> Any:
373
+ """Return a supported auth handler containing a refreshed token or API key."""
374
+ if not isinstance(secret, str) or not secret.strip():
375
+ raise ValueError("authentication refresh returned an empty secret")
376
+ if isinstance(auth, BearerAuth):
377
+ return BearerAuth(secret)
378
+ if isinstance(auth, ApiKeyAuth):
379
+ return ApiKeyAuth(secret, header_name=auth.header_name, prefix=auth.prefix)
380
+ raise TypeError("authentication refresh requires BearerAuth or ApiKeyAuth")
381
+
382
+
383
+ def _is_retryable_exception(error: Exception, config: RetryConfig) -> bool:
384
+ """Retry transport errors by default and additional configured exception types."""
385
+ if isinstance(error, httpx.TransportError):
386
+ return True
387
+ return bool(config.retry_exceptions) and isinstance(error, tuple(config.retry_exceptions))
388
+
389
+
390
+ def _request_body_is_replayable(kwargs: Mapping[str, Any]) -> bool:
391
+ """Avoid replaying generators and open streams that are exhausted by the first send."""
392
+ return all(_value_is_replayable(value) for value in kwargs.values())
393
+
394
+
395
+ def _value_is_replayable(value: Any) -> bool:
396
+ """Return whether a request argument can be safely used to build a second request."""
397
+ if value is None or isinstance(value, (str, bytes, bytearray, memoryview, int, float, bool)):
398
+ return True
399
+ if isinstance(value, BaseModel):
400
+ return _value_is_replayable(value.model_dump())
401
+ if hasattr(value, "read"):
402
+ return False
403
+ if isinstance(value, Mapping):
404
+ return all(_value_is_replayable(item) for item in value.values())
405
+ if isinstance(value, Sequence):
406
+ return all(_value_is_replayable(item) for item in value)
407
+ return False
std/http/config.py ADDED
@@ -0,0 +1,99 @@
1
+ """Typed configuration for HTTP request retries and authentication."""
2
+
3
+ from collections.abc import Callable
4
+ from math import isfinite
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
+
9
+
10
+ class RetryConfig(BaseModel):
11
+ """Control which HTTP responses and errors are retried."""
12
+
13
+ model_config = ConfigDict(validate_assignment=True)
14
+
15
+ max_attempts: int = Field(
16
+ default=3, ge=1, description="Maximum total attempts, including the first"
17
+ )
18
+ retry_statuses: list[int] = Field(
19
+ default_factory=lambda: [500, 502, 503, 504],
20
+ description="HTTP status codes eligible for a retry",
21
+ )
22
+ retry_methods: list[str] = Field(
23
+ default_factory=lambda: ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"],
24
+ description="HTTP methods eligible for a retry",
25
+ )
26
+ backoff_factor: float = Field(default=0.5, ge=0, description="Base delay in seconds")
27
+ jitter: bool = Field(default=True, description="Apply bounded random jitter to retry delays")
28
+ retry_exceptions: list[type[BaseException]] = Field(
29
+ default_factory=list,
30
+ description="Additional exception classes eligible for retries",
31
+ )
32
+
33
+ @field_validator("retry_statuses")
34
+ @classmethod
35
+ def validate_retry_statuses(cls, values: list[int]) -> list[int]:
36
+ """Require valid HTTP status codes and remove duplicates in input order."""
37
+ unique_values: list[int] = []
38
+ for status in values:
39
+ if isinstance(status, bool) or not 100 <= status <= 599:
40
+ raise ValueError("retry statuses must be integers from 100 through 599")
41
+ if status not in unique_values:
42
+ unique_values.append(status)
43
+ return unique_values
44
+
45
+ @field_validator("retry_methods")
46
+ @classmethod
47
+ def normalize_retry_methods(cls, values: list[str]) -> list[str]:
48
+ """Normalize method names and reject empty values."""
49
+ methods: list[str] = []
50
+ for method in values:
51
+ if not method or not method.strip():
52
+ raise ValueError("retry methods cannot be empty")
53
+ normalized = method.strip().upper()
54
+ if normalized not in methods:
55
+ methods.append(normalized)
56
+ return methods
57
+
58
+ @field_validator("retry_exceptions")
59
+ @classmethod
60
+ def validate_retry_exceptions(
61
+ cls, values: list[type[BaseException]]
62
+ ) -> list[type[BaseException]]:
63
+ """Require exception classes rather than instances or arbitrary objects."""
64
+ for exception_type in values:
65
+ if not isinstance(exception_type, type) or not issubclass(
66
+ exception_type, BaseException
67
+ ):
68
+ raise ValueError("retry_exceptions must contain exception classes")
69
+ return values
70
+
71
+ @field_validator("backoff_factor")
72
+ @classmethod
73
+ def validate_backoff_factor(cls, value: float) -> float:
74
+ """Require a finite delay factor so a bad config cannot stall requests."""
75
+ if not isfinite(value):
76
+ raise ValueError("backoff_factor must be finite")
77
+ return value
78
+
79
+
80
+ class AuthConfig(BaseModel):
81
+ """Collect an authentication handler and optional token-refresh settings."""
82
+
83
+ model_config = ConfigDict(arbitrary_types_allowed=True)
84
+
85
+ auth: Any | None = Field(
86
+ default=None,
87
+ repr=False,
88
+ description="Authentication object compatible with HTTPX or AuthBase",
89
+ )
90
+ refresh_token: str | None = Field(
91
+ default=None,
92
+ repr=False,
93
+ description="Refresh token retained for a caller-provided refresh callback",
94
+ )
95
+ refresh_callback: Callable[[], str] | None = Field(
96
+ default=None,
97
+ repr=False,
98
+ description="Synchronous callback that returns a replacement bearer or API key",
99
+ )
std/http/py.typed ADDED
@@ -0,0 +1 @@
1
+