httpware 0.6.0__tar.gz → 0.8.0__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.
Files changed (26) hide show
  1. {httpware-0.6.0 → httpware-0.8.0}/PKG-INFO +36 -9
  2. {httpware-0.6.0 → httpware-0.8.0}/README.md +35 -8
  3. {httpware-0.6.0 → httpware-0.8.0}/pyproject.toml +1 -1
  4. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/__init__.py +23 -4
  5. httpware-0.8.0/src/httpware/_internal/exception_mapping.py +28 -0
  6. httpware-0.8.0/src/httpware/_internal/status.py +47 -0
  7. httpware-0.8.0/src/httpware/client.py +1451 -0
  8. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/errors.py +1 -1
  9. httpware-0.8.0/src/httpware/middleware/__init__.py +143 -0
  10. httpware-0.8.0/src/httpware/middleware/chain.py +50 -0
  11. httpware-0.8.0/src/httpware/middleware/resilience/__init__.py +8 -0
  12. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/middleware/resilience/budget.py +19 -12
  13. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/middleware/resilience/bulkhead.py +64 -7
  14. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/middleware/resilience/retry.py +138 -22
  15. httpware-0.6.0/src/httpware/client.py +0 -770
  16. httpware-0.6.0/src/httpware/middleware/__init__.py +0 -77
  17. httpware-0.6.0/src/httpware/middleware/chain.py +0 -31
  18. httpware-0.6.0/src/httpware/middleware/resilience/__init__.py +0 -8
  19. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/_internal/__init__.py +0 -0
  20. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/_internal/import_checker.py +0 -0
  21. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/_internal/observability.py +0 -0
  22. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/decoders/__init__.py +0 -0
  23. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/decoders/msgspec.py +0 -0
  24. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/decoders/pydantic.py +0 -0
  25. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
  26. {httpware-0.6.0 → httpware-0.8.0}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.6.0
3
+ Version: 0.8.0
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 — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
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
- > Requires: `pip install httpware[pydantic]`
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]`:
58
83
 
59
84
  ```python
60
85
  from httpware import AsyncClient
@@ -74,23 +99,25 @@ async def main() -> None:
74
99
 
75
100
  ### With resilience middleware
76
101
 
77
- Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts.
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, Bulkhead, Retry
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
- Bulkhead(max_concurrent=10), # cap total in-flight
88
- Retry(), # default: 3 attempts, full-jitter backoff
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)
92
117
  ```
93
118
 
119
+ Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](docs/middleware.md).
120
+
94
121
  ### Streaming responses
95
122
 
96
123
  For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
@@ -108,7 +135,7 @@ async def main() -> None:
108
135
 
109
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.
110
137
 
111
- It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
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.)
112
139
 
113
140
  ## Errors
114
141
 
@@ -116,7 +143,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
116
143
 
117
144
  ## Observability
118
145
 
119
- `Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
146
+ `AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
120
147
 
121
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.
122
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 — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
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
- > Requires: `pip install httpware[pydantic]`
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]`:
28
53
 
29
54
  ```python
30
55
  from httpware import AsyncClient
@@ -44,23 +69,25 @@ async def main() -> None:
44
69
 
45
70
  ### With resilience middleware
46
71
 
47
- Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts.
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, Bulkhead, Retry
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
- Bulkhead(max_concurrent=10), # cap total in-flight
58
- Retry(), # default: 3 attempts, full-jitter backoff
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)
62
87
  ```
63
88
 
89
+ Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](docs/middleware.md).
90
+
64
91
  ### Streaming responses
65
92
 
66
93
  For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
@@ -78,7 +105,7 @@ async def main() -> None:
78
105
 
79
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.
80
107
 
81
- It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
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.)
82
109
 
83
110
  ## Errors
84
111
 
@@ -86,7 +113,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
86
113
 
87
114
  ## Observability
88
115
 
89
- `Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
116
+ `AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
90
117
 
91
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.
92
119
 
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.6.0"
29
+ version = "0.8.0"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -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,
@@ -23,16 +23,32 @@ from httpware.errors import (
23
23
  UnauthorizedError,
24
24
  UnprocessableEntityError,
25
25
  )
26
- from httpware.middleware import Middleware, Next, after_response, before_request, on_error
27
- from httpware.middleware.resilience import Bulkhead, Retry, RetryBudget
26
+ from httpware.middleware import (
27
+ AsyncMiddleware,
28
+ AsyncNext,
29
+ Middleware,
30
+ Next,
31
+ after_response,
32
+ async_after_response,
33
+ async_before_request,
34
+ async_on_error,
35
+ before_request,
36
+ on_error,
37
+ )
38
+ from httpware.middleware.resilience import AsyncBulkhead, AsyncRetry, Bulkhead, Retry, RetryBudget
28
39
 
29
40
 
30
41
  __all__ = [
31
42
  "STATUS_TO_EXCEPTION",
43
+ "AsyncBulkhead",
32
44
  "AsyncClient",
45
+ "AsyncMiddleware",
46
+ "AsyncNext",
47
+ "AsyncRetry",
33
48
  "BadRequestError",
34
49
  "Bulkhead",
35
50
  "BulkheadFullError",
51
+ "Client",
36
52
  "ClientError",
37
53
  "ClientStatusError",
38
54
  "ConflictError",
@@ -55,6 +71,9 @@ __all__ = [
55
71
  "UnauthorizedError",
56
72
  "UnprocessableEntityError",
57
73
  "after_response",
74
+ "async_after_response",
75
+ "async_before_request",
76
+ "async_on_error",
58
77
  "before_request",
59
78
  "on_error",
60
79
  ]
@@ -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__")