standard-http 0.2.1__tar.gz
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.
- standard_http-0.2.1/.gitignore +31 -0
- standard_http-0.2.1/LICENSE +21 -0
- standard_http-0.2.1/PKG-INFO +62 -0
- standard_http-0.2.1/README.md +37 -0
- standard_http-0.2.1/pyproject.toml +42 -0
- standard_http-0.2.1/src/std/http/__init__.py +16 -0
- standard_http-0.2.1/src/std/http/auth.py +96 -0
- standard_http-0.2.1/src/std/http/client.py +407 -0
- standard_http-0.2.1/src/std/http/config.py +99 -0
- standard_http-0.2.1/src/std/http/py.typed +1 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Workspace-managed mounts.
|
|
2
|
+
/.agents/
|
|
3
|
+
/.aws/
|
|
4
|
+
/.codex/
|
|
5
|
+
|
|
6
|
+
# Python tooling and generated files.
|
|
7
|
+
/.venv/
|
|
8
|
+
/venv/
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*.egg-info/
|
|
12
|
+
/build/
|
|
13
|
+
/dist/
|
|
14
|
+
/.coverage
|
|
15
|
+
/.coverage.*
|
|
16
|
+
/htmlcov/
|
|
17
|
+
/deprecated/
|
|
18
|
+
/.pytest_cache/
|
|
19
|
+
/.mypy_cache/
|
|
20
|
+
/.ruff_cache/
|
|
21
|
+
/.tox/
|
|
22
|
+
|
|
23
|
+
# Documentation output.
|
|
24
|
+
/.cache/
|
|
25
|
+
/site/
|
|
26
|
+
|
|
27
|
+
# Local secrets and environment files.
|
|
28
|
+
.env
|
|
29
|
+
.env.*
|
|
30
|
+
!.env.example
|
|
31
|
+
.kata.local.toml
|
|
@@ -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.
|
|
@@ -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,37 @@
|
|
|
1
|
+
# standard-http
|
|
2
|
+
|
|
3
|
+
`standard-http` adds an asynchronous HTTP client and authentication helpers to
|
|
4
|
+
the `standard` Python library. Install the package to use the `std.http`
|
|
5
|
+
import namespace:
|
|
6
|
+
|
|
7
|
+
From this repository, install the core and HTTP projects together:
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
python -m pip install . packages/standard-http
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
After the add-on is published, install the distributions with
|
|
14
|
+
`python -m pip install standard standard-http`.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from std.http import AuthConfig, BearerAuth, HttpClient, RetryConfig
|
|
18
|
+
|
|
19
|
+
client = HttpClient(
|
|
20
|
+
base_url="https://api.example.com/v1/",
|
|
21
|
+
auth_config=AuthConfig(auth=BearerAuth("your-token")),
|
|
22
|
+
retries=RetryConfig(max_attempts=3),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async with client:
|
|
26
|
+
response = await client.get("users", params={"active": "true"})
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
print(response.json())
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The package also provides `BasicAuth`, `ApiKeyAuth`, `AuthBase`, `AuthConfig`,
|
|
32
|
+
and Pydantic response helpers such as `get_model`. See the [HTTP guide](../../docs/http.md)
|
|
33
|
+
for retry, timeout, token refresh, error, transport injection, and lifecycle
|
|
34
|
+
behavior.
|
|
35
|
+
|
|
36
|
+
The wheel contains only `std.http`; it does not bundle FastAPI, AWS, or other
|
|
37
|
+
integration modules.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2", "hatch-vcs>=0.4,<1"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "standard-http"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Async HTTP client and authentication helpers for Standard"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Standard contributors" }]
|
|
14
|
+
keywords = ["httpx", "http", "async", "authentication", "standard"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["standard==0.2.1", "httpx>=0.27,<1"]
|
|
28
|
+
urls = { Documentation = "https://appunni-m.github.io/python-standard/http/", Repository = "https://github.com/appunni-m/python-standard" }
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
only-include = ["src/std/http"]
|
|
32
|
+
sources = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
only-include = ["pyproject.toml", "README.md", "LICENSE", "src/std/http"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
tag-pattern = "^v(?P<version>\\d+\\.\\d+\\.\\d+(?:(?:a|b|rc)\\d+)?)$"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version.raw-options]
|
|
42
|
+
search_parent_directories = true
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|