httpware 0.7.0__tar.gz → 0.8.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.
- {httpware-0.7.0 → httpware-0.8.1}/PKG-INFO +34 -9
- {httpware-0.7.0 → httpware-0.8.1}/README.md +33 -8
- {httpware-0.7.0 → httpware-0.8.1}/pyproject.toml +1 -1
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/__init__.py +25 -4
- httpware-0.8.1/src/httpware/_internal/exception_mapping.py +28 -0
- httpware-0.8.1/src/httpware/_internal/status.py +47 -0
- httpware-0.8.1/src/httpware/client.py +1457 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/decoders/__init__.py +6 -1
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/errors.py +43 -1
- httpware-0.8.1/src/httpware/middleware/__init__.py +143 -0
- httpware-0.8.1/src/httpware/middleware/chain.py +50 -0
- httpware-0.8.1/src/httpware/middleware/resilience/__init__.py +8 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/middleware/resilience/budget.py +19 -12
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/middleware/resilience/bulkhead.py +64 -7
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/middleware/resilience/retry.py +138 -22
- httpware-0.7.0/src/httpware/client.py +0 -770
- httpware-0.7.0/src/httpware/middleware/__init__.py +0 -77
- httpware-0.7.0/src/httpware/middleware/chain.py +0 -31
- httpware-0.7.0/src/httpware/middleware/resilience/__init__.py +0 -8
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.7.0 → httpware-0.8.1}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Resilience-first async HTTP client framework for Python
|
|
5
5
|
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
|
|
38
38
|
**Async HTTP client framework for Python.**
|
|
39
39
|
|
|
40
|
-
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `
|
|
40
|
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
41
41
|
|
|
42
42
|
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
43
43
|
|
|
@@ -54,7 +54,32 @@ pip install httpware[all] # everything declared above (pydantic, msgsp
|
|
|
54
54
|
|
|
55
55
|
## Quickstart
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
**Async usage:**
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
|
|
62
|
+
from httpware import AsyncClient
|
|
63
|
+
|
|
64
|
+
async def main() -> None:
|
|
65
|
+
async with AsyncClient(base_url="https://example.test") as client:
|
|
66
|
+
response = await client.get("/users/42")
|
|
67
|
+
print(response.json())
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Sync usage:**
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from httpware import Client
|
|
76
|
+
|
|
77
|
+
with Client(base_url="https://example.test") as client:
|
|
78
|
+
response = client.get("/users/42")
|
|
79
|
+
print(response.json())
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
58
83
|
|
|
59
84
|
```python
|
|
60
85
|
from httpware import AsyncClient
|
|
@@ -74,18 +99,18 @@ async def main() -> None:
|
|
|
74
99
|
|
|
75
100
|
### With resilience middleware
|
|
76
101
|
|
|
77
|
-
Compose resilience middleware at construction; `
|
|
102
|
+
Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
|
|
78
103
|
|
|
79
104
|
```python
|
|
80
|
-
from httpware import AsyncClient,
|
|
105
|
+
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
|
|
81
106
|
|
|
82
107
|
|
|
83
108
|
async def main() -> None:
|
|
84
109
|
async with AsyncClient(
|
|
85
110
|
base_url="https://api.example.com",
|
|
86
111
|
middleware=[
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
AsyncBulkhead(max_concurrent=10), # cap total in-flight
|
|
113
|
+
AsyncRetry(), # default: 3 attempts, full-jitter backoff
|
|
89
114
|
],
|
|
90
115
|
) as client:
|
|
91
116
|
user = await client.get("/users/1", response_model=User)
|
|
@@ -110,7 +135,7 @@ async def main() -> None:
|
|
|
110
135
|
|
|
111
136
|
`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
|
|
112
137
|
|
|
113
|
-
It does NOT pass through the middleware chain: `
|
|
138
|
+
It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, and any custom middleware are bypassed. (AsyncRetry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
|
|
114
139
|
|
|
115
140
|
## Errors
|
|
116
141
|
|
|
@@ -118,7 +143,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
|
|
|
118
143
|
|
|
119
144
|
## Observability
|
|
120
145
|
|
|
121
|
-
`
|
|
146
|
+
`AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
122
147
|
|
|
123
148
|
Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
|
|
124
149
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
**Async HTTP client framework for Python.**
|
|
9
9
|
|
|
10
|
-
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `
|
|
10
|
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
11
11
|
|
|
12
12
|
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
13
13
|
|
|
@@ -24,7 +24,32 @@ pip install httpware[all] # everything declared above (pydantic, msgsp
|
|
|
24
24
|
|
|
25
25
|
## Quickstart
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
**Async usage:**
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
|
|
32
|
+
from httpware import AsyncClient
|
|
33
|
+
|
|
34
|
+
async def main() -> None:
|
|
35
|
+
async with AsyncClient(base_url="https://example.test") as client:
|
|
36
|
+
response = await client.get("/users/42")
|
|
37
|
+
print(response.json())
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Sync usage:**
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from httpware import Client
|
|
46
|
+
|
|
47
|
+
with Client(base_url="https://example.test") as client:
|
|
48
|
+
response = client.get("/users/42")
|
|
49
|
+
print(response.json())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
28
53
|
|
|
29
54
|
```python
|
|
30
55
|
from httpware import AsyncClient
|
|
@@ -44,18 +69,18 @@ async def main() -> None:
|
|
|
44
69
|
|
|
45
70
|
### With resilience middleware
|
|
46
71
|
|
|
47
|
-
Compose resilience middleware at construction; `
|
|
72
|
+
Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
|
|
48
73
|
|
|
49
74
|
```python
|
|
50
|
-
from httpware import AsyncClient,
|
|
75
|
+
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
|
|
51
76
|
|
|
52
77
|
|
|
53
78
|
async def main() -> None:
|
|
54
79
|
async with AsyncClient(
|
|
55
80
|
base_url="https://api.example.com",
|
|
56
81
|
middleware=[
|
|
57
|
-
|
|
58
|
-
|
|
82
|
+
AsyncBulkhead(max_concurrent=10), # cap total in-flight
|
|
83
|
+
AsyncRetry(), # default: 3 attempts, full-jitter backoff
|
|
59
84
|
],
|
|
60
85
|
) as client:
|
|
61
86
|
user = await client.get("/users/1", response_model=User)
|
|
@@ -80,7 +105,7 @@ async def main() -> None:
|
|
|
80
105
|
|
|
81
106
|
`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
|
|
82
107
|
|
|
83
|
-
It does NOT pass through the middleware chain: `
|
|
108
|
+
It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, and any custom middleware are bypassed. (AsyncRetry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
|
|
84
109
|
|
|
85
110
|
## Errors
|
|
86
111
|
|
|
@@ -88,7 +113,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
|
|
|
88
113
|
|
|
89
114
|
## Observability
|
|
90
115
|
|
|
91
|
-
`
|
|
116
|
+
`AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
92
117
|
|
|
93
118
|
Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
|
|
94
119
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""httpware — thin async HTTP client wrapper over httpx2."""
|
|
1
|
+
"""httpware — thin async + sync HTTP client wrapper over httpx2."""
|
|
2
2
|
|
|
3
|
-
from httpware.client import AsyncClient
|
|
3
|
+
from httpware.client import AsyncClient, Client
|
|
4
4
|
from httpware.decoders import ResponseDecoder
|
|
5
5
|
from httpware.errors import (
|
|
6
6
|
STATUS_TO_EXCEPTION,
|
|
@@ -9,6 +9,7 @@ from httpware.errors import (
|
|
|
9
9
|
ClientError,
|
|
10
10
|
ClientStatusError,
|
|
11
11
|
ConflictError,
|
|
12
|
+
DecodeError,
|
|
12
13
|
ForbiddenError,
|
|
13
14
|
InternalServerError,
|
|
14
15
|
NetworkError,
|
|
@@ -23,19 +24,36 @@ from httpware.errors import (
|
|
|
23
24
|
UnauthorizedError,
|
|
24
25
|
UnprocessableEntityError,
|
|
25
26
|
)
|
|
26
|
-
from httpware.middleware import
|
|
27
|
-
|
|
27
|
+
from httpware.middleware import (
|
|
28
|
+
AsyncMiddleware,
|
|
29
|
+
AsyncNext,
|
|
30
|
+
Middleware,
|
|
31
|
+
Next,
|
|
32
|
+
after_response,
|
|
33
|
+
async_after_response,
|
|
34
|
+
async_before_request,
|
|
35
|
+
async_on_error,
|
|
36
|
+
before_request,
|
|
37
|
+
on_error,
|
|
38
|
+
)
|
|
39
|
+
from httpware.middleware.resilience import AsyncBulkhead, AsyncRetry, Bulkhead, Retry, RetryBudget
|
|
28
40
|
|
|
29
41
|
|
|
30
42
|
__all__ = [
|
|
31
43
|
"STATUS_TO_EXCEPTION",
|
|
44
|
+
"AsyncBulkhead",
|
|
32
45
|
"AsyncClient",
|
|
46
|
+
"AsyncMiddleware",
|
|
47
|
+
"AsyncNext",
|
|
48
|
+
"AsyncRetry",
|
|
33
49
|
"BadRequestError",
|
|
34
50
|
"Bulkhead",
|
|
35
51
|
"BulkheadFullError",
|
|
52
|
+
"Client",
|
|
36
53
|
"ClientError",
|
|
37
54
|
"ClientStatusError",
|
|
38
55
|
"ConflictError",
|
|
56
|
+
"DecodeError",
|
|
39
57
|
"ForbiddenError",
|
|
40
58
|
"InternalServerError",
|
|
41
59
|
"Middleware",
|
|
@@ -55,6 +73,9 @@ __all__ = [
|
|
|
55
73
|
"UnauthorizedError",
|
|
56
74
|
"UnprocessableEntityError",
|
|
57
75
|
"after_response",
|
|
76
|
+
"async_after_response",
|
|
77
|
+
"async_before_request",
|
|
78
|
+
"async_on_error",
|
|
58
79
|
"before_request",
|
|
59
80
|
"on_error",
|
|
60
81
|
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""httpx2 -> httpware exception mapping.
|
|
2
|
+
|
|
3
|
+
Pure function used by both Client._terminal and AsyncClient._terminal,
|
|
4
|
+
and by both stream() methods. Clause ordering: TimeoutException ->
|
|
5
|
+
InvalidURL/CookieConflict -> NetworkError -> HTTPError (subclass before
|
|
6
|
+
parent so the right type wins).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import httpx2
|
|
10
|
+
|
|
11
|
+
from httpware.errors import NetworkError, TimeoutError, TransportError # noqa: A004
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | TransportError:
|
|
15
|
+
"""Map an httpx2 exception to its httpware equivalent.
|
|
16
|
+
|
|
17
|
+
Order is significant: more-specific httpx2 types must match before more
|
|
18
|
+
general ones. We return the mapped exception; the caller does `raise ... from exc`.
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(exc, httpx2.TimeoutException):
|
|
21
|
+
return TimeoutError(str(exc))
|
|
22
|
+
if isinstance(exc, (httpx2.InvalidURL, httpx2.CookieConflict)):
|
|
23
|
+
return TransportError(str(exc))
|
|
24
|
+
if isinstance(exc, httpx2.NetworkError):
|
|
25
|
+
return NetworkError(str(exc))
|
|
26
|
+
if isinstance(exc, httpx2.HTTPError):
|
|
27
|
+
return TransportError(str(exc))
|
|
28
|
+
return TransportError(str(exc)) # pragma: no cover — defensive default; httpx2.HTTPError is the root
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Status-code dispatch + streaming-body detection.
|
|
2
|
+
|
|
3
|
+
Shared by Client and AsyncClient. The STREAMING_BODY_MARKER is the public
|
|
4
|
+
extensions key both Retry and AsyncRetry read; renaming it is breaking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
|
|
9
|
+
import httpx2
|
|
10
|
+
|
|
11
|
+
from httpware.errors import STATUS_TO_EXCEPTION, ClientStatusError, ServerStatusError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
STREAMING_BODY_MARKER = "httpware.streaming_body"
|
|
15
|
+
"""Set on ``httpx2.Request.extensions`` when content/data/files is a non-replayable
|
|
16
|
+
iterable (async-iterable for AsyncClient, sync iterator/generator for Client).
|
|
17
|
+
Retry / AsyncRetry read this marker to refuse retrying a streamed-body request
|
|
18
|
+
(the consumed iterator cannot replay across attempts)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _raise_on_status_error(response: httpx2.Response) -> None:
|
|
22
|
+
"""Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx."""
|
|
23
|
+
status = response.status_code
|
|
24
|
+
if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
25
|
+
exc_class = STATUS_TO_EXCEPTION.get(
|
|
26
|
+
status,
|
|
27
|
+
ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
|
|
28
|
+
)
|
|
29
|
+
raise exc_class(response)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_streaming_body_async(value: object) -> bool:
|
|
33
|
+
"""Return True if value is an async-iterable that cannot be safely replayed for retry."""
|
|
34
|
+
if value is None:
|
|
35
|
+
return False
|
|
36
|
+
if isinstance(value, (bytes, bytearray, memoryview, str, dict)):
|
|
37
|
+
return False
|
|
38
|
+
return hasattr(value, "__aiter__")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_streaming_body_sync(value: object) -> bool:
|
|
42
|
+
"""Return True if value is a sync iterable body that cannot be safely replayed for retry."""
|
|
43
|
+
if value is None:
|
|
44
|
+
return False
|
|
45
|
+
if isinstance(value, (bytes, bytearray, memoryview, str, dict, list, tuple)):
|
|
46
|
+
return False
|
|
47
|
+
return hasattr(value, "__iter__")
|