From f19f61220bc8eddcce9045fb992eea4a47673abd Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 27 Jul 2026 13:42:24 +0300 Subject: [PATCH] chore: adopt ruff 0.16.0 ruff 0.16.0 stabilized CPY001 (missing-copyright-notice) out of preview, so `select = ["ALL"]` now picks it up. Ignore it, matching modern-di. 0.16.0 also formats Python code blocks inside Markdown; reformat the 51 affected file(s). No .py file changed. --- architecture/errors.md | 6 +- docs/decoders.md | 5 +- docs/errors.md | 16 ++-- docs/index.md | 4 +- docs/recipes/link-header-pagination.md | 4 +- docs/recipes/modern-di.md | 1 + docs/recipes/phase-decorator-patterns.md | 6 +- docs/resilience.md | 6 +- planning/audits/2026-06-07-deep-audit.md | 5 ++ planning/audits/2026-06-14-deep-audit.md | 20 +++-- ...6-05-31.05-request-immutability-helpers.md | 5 ++ ...026-05-31.06-msgspec-decoder-via-extras.md | 5 +- planning/changes/2026-05-31.07-asyncclient.md | 40 ++++++---- .../2026-05-31.08-recordedtransport.md | 24 +++--- .../changes/2026-06-01.01-auth-coercion.md | 28 ++----- .../2026-06-03.02-thin-httpx2-wrapper.md | 78 +++++++++++++++---- .../2026-06-04.01-pydantic-optional-extra.md | 4 +- .../2026-06-05.01-retry-and-retry-budget.md | 56 +++++++------ planning/changes/2026-06-05.02-bulkhead.md | 17 ++-- .../changes/2026-06-05.03-docs-sync-0.4.md | 2 +- planning/changes/2026-06-05.04-streaming.md | 6 +- .../changes/2026-06-05.05-observability.md | 1 + .../2026-06-05.06-extension-slot-docs.md | 1 + .../2026-06-05.07-v0.7-docs-expansion.md | 24 +++--- .../changes/2026-06-06.01-modern-di-recipe.md | 1 + planning/changes/2026-06-07.01-sync-client.md | 53 +++++++++---- .../changes/2026-06-07.02-decoder-error.md | 3 +- planning/changes/2026-06-07.03-deep-audit.md | 29 ++++--- .../2026-06-08.01-send-with-response.md | 9 ++- .../2026-06-08.02-retry-budget-cluster.md | 6 +- .../2026-06-08.04-otel-partial-install.md | 2 + .../2026-06-08.05-small-fixes-mop-up.md | 22 ++---- .../changes/2026-06-10.01-multi-decoder.md | 7 +- ...-06-13.01-msgspec-nested-customtype-fix.md | 4 +- ...26-06-13.02-circuit-breaker-and-timeout.md | 8 +- .../2026-06-14.01-docs-ux-restructure.md | 1 + .../2026-06-14.03-security-hardening.md | 2 +- ...2026-06-16.02-circuit-breaker-rate-mode.md | 6 +- .../2026-06-16.03-circuit-breaker-state.md | 1 + .../2026-06-23.01-retry-policy-extraction.md | 1 + ...26-06-23.02-decoder-resolver-extraction.md | 4 +- .../2026-06-23.03-response-body-cap.md | 22 +++--- ...026-07-13.01-body-cap-module-extraction.md | 6 +- ...3.02-client-request-assembly-extraction.md | 9 ++- ...04-bulkhead-shared-validation-rejection.md | 8 +- ...7-13.05-decoders-shared-memoizing-cache.md | 6 ++ planning/releases/0.10.0.md | 2 +- planning/releases/0.12.0.md | 4 +- planning/releases/0.13.0.md | 4 +- planning/releases/0.4.0.md | 38 +++++---- planning/releases/0.9.0.md | 6 +- pyproject.toml | 1 + 52 files changed, 387 insertions(+), 242 deletions(-) diff --git a/architecture/errors.md b/architecture/errors.md index 2522dc2..6c2b24b 100644 --- a/architecture/errors.md +++ b/architecture/errors.md @@ -3,9 +3,9 @@ `StatusError` and all its 4xx/5xx subclasses are constructed with a **single positional `response: httpx2.Response`**. Subclasses do not override `__init__`. All fields are available via `exc.response.*` (status code, headers, content, request, etc.). ```python -raise NotFoundError(response) # correct -exc.response.status_code # 404 -exc.response.request.url # URL of the failed request +raise NotFoundError(response) # correct +exc.response.status_code # 404 +exc.response.request.url # URL of the failed request ``` `__repr__` and the `str()` summary redact URL userinfo (`user:pass@`) and mask the values of known-sensitive query and fragment parameters (e.g. `token`, `api_key`, `secret`) to avoid leaking credentials in tracebacks. diff --git a/docs/decoders.md b/docs/decoders.md index 1eb866b..fc7b480 100644 --- a/docs/decoders.md +++ b/docs/decoders.md @@ -74,10 +74,7 @@ class CsvDecoder: (row_type,) = typing.get_args(model) field_types = {f.name: f.type for f in dataclasses.fields(row_type)} reader = csv.DictReader(io.StringIO(content.decode("utf-8"))) - return [ - row_type(**{name: field_types[name](value) for name, value in row.items()}) - for row in reader - ] + return [row_type(**{name: field_types[name](value) for name, value in row.items()}) for row in reader] ``` `can_decode` is total and never raises: a non-`list` model, a bare `list`, or `list[int]` all fall through to `False`. `decode` coerces each CSV cell with its field's type (CSV values arrive as strings) — a real decoder would handle optionals, dates, and missing columns; this is where your domain logic goes. Wire it ahead of the built-ins so it gets first refusal on `list[...]` models while pydantic still handles everything else: diff --git a/docs/errors.md b/docs/errors.md index bba3e94..ef5eaa8 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -106,14 +106,14 @@ async def fetch(client: AsyncClient, user_id: int) -> dict | None: For any `StatusError` subclass, the raw `httpx2.Response` is on `exc.response`: ```python -exc.response.status_code # 404 -exc.response.headers # httpx2.Headers — case-insensitive -exc.response.content # raw bytes -exc.response.text # decoded body -exc.response.json() # parsed JSON (raises if not JSON) -exc.response.request # the failing httpx2.Request -exc.response.request.url # the failing URL (httpx2.URL) -exc.response.request.method # the HTTP method +exc.response.status_code # 404 +exc.response.headers # httpx2.Headers — case-insensitive +exc.response.content # raw bytes +exc.response.text # decoded body +exc.response.json() # parsed JSON (raises if not JSON) +exc.response.request # the failing httpx2.Request +exc.response.request.url # the failing URL (httpx2.URL) +exc.response.request.method # the HTTP method ``` **Security note:** `__repr__` and the exception's summary message strip `user:pass@` userinfo and mask the values of known-sensitive query and URL-fragment parameters (`api_key`, `apikey`, `access_token`, `refresh_token`, `token`, `secret`, `client_secret`, `password`, `passwd`, `pwd`, `auth`, `authorization`, `sig`, `signature`, `key`, `private_key`, `session`, `sessionid`, `x-api-key`) as `REDACTED`, preserving the keys. Query values under other names are **not** masked, so still avoid putting non-standard secrets in query strings. Note that request *headers* (`Authorization`, `Cookie`, etc.) are never redacted — see `exc.response.request.headers` above. diff --git a/docs/index.md b/docs/index.md index 71b7af0..38f5fcd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,11 +42,13 @@ import asyncio from httpware import AsyncClient + async def main() -> None: async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client: response = await client.get("/users/1") print(response.json()) + asyncio.run(main()) ``` @@ -102,7 +104,7 @@ async def main() -> None: base_url="https://api.example.com", middleware=[ AsyncBulkhead(max_concurrent=10), # cap total in-flight - AsyncRetry(), # default: 3 attempts, full-jitter backoff + AsyncRetry(), # default: 3 attempts, full-jitter backoff ], ) as client: user = await client.get("/users/1", response_model=User) diff --git a/docs/recipes/link-header-pagination.md b/docs/recipes/link-header-pagination.md index 75d7510..56dbce5 100644 --- a/docs/recipes/link-header-pagination.md +++ b/docs/recipes/link-header-pagination.md @@ -24,8 +24,8 @@ async def main() -> None: response, tags = await client.send_with_response(request, response_model=list[Tag]) for tag in tags: process(tag) - url = next_link(response.headers.get("link")) # caller's parser - params = None # next link carries query + url = next_link(response.headers.get("link")) # caller's parser + params = None # next link carries query ``` `process` and `next_link` are caller-defined. Pick a Link-header parser that fits your project — there are several on PyPI, and the format is small enough to hand-roll. diff --git a/docs/recipes/modern-di.md b/docs/recipes/modern-di.md index ee5eada..fb2d9b9 100644 --- a/docs/recipes/modern-di.md +++ b/docs/recipes/modern-di.md @@ -64,6 +64,7 @@ class ServiceClients(Group): cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose), ) + # At Container(...) construction: # modern_di.exceptions.DuplicateProviderTypeError: Provider is duplicated by type # . To resolve this issue: ... diff --git a/docs/recipes/phase-decorator-patterns.md b/docs/recipes/phase-decorator-patterns.md index 81a0549..468900a 100644 --- a/docs/recipes/phase-decorator-patterns.md +++ b/docs/recipes/phase-decorator-patterns.md @@ -47,7 +47,8 @@ from httpware import async_before_request _CORRELATION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar( - "correlation_id", default=None, + "correlation_id", + default=None, ) @@ -132,7 +133,8 @@ from httpware import async_on_error @async_on_error async def fallback_on_network_error( - request: httpx2.Request, exc: Exception, + request: httpx2.Request, + exc: Exception, ) -> httpx2.Response | None: if isinstance(exc, NetworkError): return httpx2.Response( diff --git a/docs/resilience.md b/docs/resilience.md index 40602a5..f24fee6 100644 --- a/docs/resilience.md +++ b/docs/resilience.md @@ -152,7 +152,7 @@ When `acquire_timeout` elapses without a slot opening, `AsyncBulkhead` raises `B ```python from httpware.middleware.resilience import AsyncCircuitBreaker # async -from httpware.middleware.resilience import CircuitBreaker # sync +from httpware.middleware.resilience import CircuitBreaker # sync ``` Classic consecutive-failure circuit breaker. Counts failures and prevents requests from reaching a downstream that is known to be broken. @@ -208,8 +208,8 @@ from httpware.middleware.resilience import AsyncCircuitBreaker breaker = AsyncCircuitBreaker( failure_rate_threshold=0.5, # open at ≥50% failures - window_seconds=30.0, # over a rolling 30s window - minimum_calls=20, # but only once 20+ calls are observed + window_seconds=30.0, # over a rolling 30s window + minimum_calls=20, # but only once 20+ calls are observed ) ``` diff --git a/planning/audits/2026-06-07-deep-audit.md b/planning/audits/2026-06-07-deep-audit.md index b3e6da2..c83d200 100644 --- a/planning/audits/2026-06-07-deep-audit.md +++ b/planning/audits/2026-06-07-deep-audit.md @@ -539,8 +539,10 @@ async def test_on_error_lets_cancelled_propagate() -> None: @async_on_error async def swallow_all(request, exc) -> httpx2.Response | None: raise AssertionError("should not catch CancelledError") + async def terminal(request): raise asyncio.CancelledError + dispatch = compose_async((swallow_all,), terminal) with pytest.raises(asyncio.CancelledError): await dispatch(_make_request()) @@ -556,7 +558,10 @@ Suggested direction: add `test_on_error_lets_keyboardinterrupt_propagate` (and o ```python """Tests for the per-method API surface of AsyncClient.""" + from httpware import AsyncClient, NotFoundError + + def _client_with_handler(handler, **kwargs) -> AsyncClient: ... async def test_get_returns_httpx2_response() -> None: ... ``` diff --git a/planning/audits/2026-06-14-deep-audit.md b/planning/audits/2026-06-14-deep-audit.md index 53ed8c6..b6759ad 100644 --- a/planning/audits/2026-06-14-deep-audit.md +++ b/planning/audits/2026-06-14-deep-audit.md @@ -235,7 +235,9 @@ The discover map labels the file "Hypothesis property-based tests for retry inte ```python async def test_total_attempts_never_exceeds_max_attempts( - max_attempts: int, status: int, method: str, + max_attempts: int, + status: int, + method: str, ) -> None: ... await client.request(method, "https://example.test/x") @@ -270,8 +272,15 @@ CLAUDE.md and `architecture/errors.md` mandate that all `StatusError` subclasses ```python def test_inheritance_tree() -> None: ... - for exc in (BadRequestError, UnauthorizedError, ForbiddenError, ForbiddenError, - ConflictError, UnprocessableEntityError, RateLimitedError): + for exc in ( + BadRequestError, + UnauthorizedError, + ForbiddenError, + ForbiddenError, + ConflictError, + UnprocessableEntityError, + RateLimitedError, + ): assert issubclass(exc, ClientStatusError), exc ``` @@ -334,6 +343,7 @@ def _is_streaming_body_async(value: object) -> bool: ... return hasattr(value, "__aiter__") + def _is_streaming_body_sync(value: object) -> bool: ... return hasattr(value, "__iter__") @@ -410,9 +420,7 @@ The test asserts `len(budget._deposits) == expected_deposits`, relying on a comm ```python expected_deposits = (_N_SYNC_THREADS * _N_OPS_PER_THREAD) + _N_ASYNC_TASKS -assert len(budget._deposits) == expected_deposits, ( - f"expected {expected_deposits} deposits, got {len(budget._deposits)}" -) +assert len(budget._deposits) == expected_deposits, f"expected {expected_deposits} deposits, got {len(budget._deposits)}" ``` Panel 2/3: code_reality, reproducer. Suggested direction: pin the injected clock so no real time elapses, making the no-purge assumption an enforced invariant rather than a fragile comment. diff --git a/planning/changes/2026-05-31.05-request-immutability-helpers.md b/planning/changes/2026-05-31.05-request-immutability-helpers.md index a3b3f27..874ea13 100644 --- a/planning/changes/2026-05-31.05-request-immutability-helpers.md +++ b/planning/changes/2026-05-31.05-request-immutability-helpers.md @@ -61,18 +61,22 @@ def with_headers(self, headers: Mapping[str, str]) -> Self: """Return a copy with the given headers merged in (incoming keys override existing).""" return dataclasses.replace(self, headers={**self.headers, **headers}) + def with_cookie(self, name: str, value: str) -> Self: """Return a copy with the given cookie added or replaced.""" return dataclasses.replace(self, cookies={**self.cookies, name: value}) + def with_cookies(self, cookies: Mapping[str, str]) -> Self: """Return a copy with the given cookies merged in (incoming keys override existing).""" return dataclasses.replace(self, cookies={**self.cookies, **cookies}) + def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401 """Return a copy with the given extension entry added or replaced.""" return dataclasses.replace(self, extensions={**self.extensions, name: value}) + def with_extensions(self, extensions: Mapping[str, Any]) -> Self: """Return a copy with the given extensions merged in (incoming keys override existing).""" return dataclasses.replace(self, extensions={**self.extensions, **extensions}) @@ -92,6 +96,7 @@ def with_headers(self, headers: Mapping[str, str]) -> Self: """Return a copy with the given headers merged in (incoming keys override existing).""" return dataclasses.replace(self, headers={**self.headers, **headers}) + def with_status(self, status: int) -> Self: """Return a copy with the given status code.""" return dataclasses.replace(self, status=status) diff --git a/planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md b/planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md index f18247c..67312f4 100644 --- a/planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md +++ b/planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md @@ -75,10 +75,7 @@ if import_checker.is_msgspec_installed: import msgspec -MISSING_DEPENDENCY_MESSAGE = ( - "MsgspecDecoder requires the 'msgspec' extra. " - "Install with: pip install httpware[msgspec]" -) +MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]" T = TypeVar("T") diff --git a/planning/changes/2026-05-31.07-asyncclient.md b/planning/changes/2026-05-31.07-asyncclient.md index 6fa1222..30e3848 100644 --- a/planning/changes/2026-05-31.07-asyncclient.md +++ b/planning/changes/2026-05-31.07-asyncclient.md @@ -82,9 +82,7 @@ class AsyncClient: ) -> None: normalized_timeout = _normalize_timeout(timeout) resolved_limits = limits or Limits() - resolved_transport = transport or Httpx2Transport( - limits=resolved_limits, timeout=normalized_timeout - ) + resolved_transport = transport or Httpx2Transport(limits=resolved_limits, timeout=normalized_timeout) resolved_decoder = decoder or PydanticDecoder() resolved_middleware = tuple(middleware) if middleware is not None else () @@ -179,9 +177,7 @@ def _build_request( Body builder: ```python -def _build_body( - json_value: Any | None, content: bytes | None -) -> tuple[bytes | None, str | None]: +def _build_body(json_value: Any | None, content: bytes | None) -> tuple[bytes | None, str | None]: if json_value is not None and content is not None: raise TypeError("pass either `json` or `content`, not both") if json_value is not None: @@ -206,6 +202,7 @@ async def get( response_model: None = None, ) -> Response: ... + @overload async def get( self, @@ -218,6 +215,7 @@ async def get( response_model: type[T], ) -> T: ... + async def get( self, path: str, @@ -229,9 +227,14 @@ async def get( response_model: type[T] | None = None, ) -> Response | T: return await self._send( - "GET", path, - headers=headers, params=params, cookies=cookies, timeout=timeout, - body=None, content_type=None, + "GET", + path, + headers=headers, + params=params, + cookies=cookies, + timeout=timeout, + body=None, + content_type=None, response_model=response_model, ) ``` @@ -250,13 +253,23 @@ async def _send( method: str, path: str, *, - headers, params, cookies, timeout, body, content_type, + headers, + params, + cookies, + timeout, + body, + content_type, response_model, ): request = self._build_request( - method, path, - headers=headers, params=params, cookies=cookies, timeout=timeout, - body=body, content_type=content_type, + method, + path, + headers=headers, + params=params, + cookies=cookies, + timeout=timeout, + body=body, + content_type=content_type, ) response = await self._dispatch(request) if response_model is None: @@ -272,6 +285,7 @@ async def _send( async def __aenter__(self) -> "AsyncClient": return self + async def __aexit__(self, exc_type, exc, tb) -> None: if self._owns_transport: await self._transport.aclose() diff --git a/planning/changes/2026-05-31.08-recordedtransport.md b/planning/changes/2026-05-31.08-recordedtransport.md index b14d384..114c188 100644 --- a/planning/changes/2026-05-31.08-recordedtransport.md +++ b/planning/changes/2026-05-31.08-recordedtransport.md @@ -73,8 +73,8 @@ class RecordedTransport: default: Response | BaseException | None = None, ) -> None: ... - requests: list[Request] # appended on every __call__ - aclose_calls: int # incremented on every aclose + requests: list[Request] # appended on every __call__ + aclose_calls: int # incremented on every aclose @property def last_request(self) -> Request | None: ... @@ -95,9 +95,11 @@ Usage examples documented in the class docstring: ```python # Simple canned response for a single endpoint. -transport = RecordedTransport(routes={ - ("GET", "/users"): Response(status=200, headers={}, content=b"[]", url="/users", elapsed=0.0), -}) +transport = RecordedTransport( + routes={ + ("GET", "/users"): Response(status=200, headers={}, content=b"[]", url="/users", elapsed=0.0), + } +) # Same canned response for every request — useful for AsyncClient construction tests. transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="", elapsed=0.0)) @@ -106,9 +108,11 @@ transport = RecordedTransport(default=Response(status=200, headers={}, content=b transport = RecordedTransport() # RuntimeError("No route for ...") # Raise a specific exception on a route. -transport = RecordedTransport(routes={ - ("GET", "/error"): RuntimeError("upstream down"), -}) +transport = RecordedTransport( + routes={ + ("GET", "/error"): RuntimeError("upstream down"), + } +) # Inspect observed requests after the test. client = AsyncClient(transport=transport) @@ -157,9 +161,7 @@ class RecordedTransport: default: Response | BaseException | None = None, ) -> None: self._routes: dict[tuple[str, str], Response | BaseException] = ( - {(m.upper(), u): v for (m, u), v in routes.items()} - if routes is not None - else {} + {(m.upper(), u): v for (m, u), v in routes.items()} if routes is not None else {} ) self._default = default self.requests: list[Request] = [] diff --git a/planning/changes/2026-06-01.01-auth-coercion.md b/planning/changes/2026-06-01.01-auth-coercion.md index 64d3100..f3fc493 100644 --- a/planning/changes/2026-06-01.01-auth-coercion.md +++ b/planning/changes/2026-06-01.01-auth-coercion.md @@ -66,12 +66,7 @@ from httpware.middleware import Middleware, before_request from httpware.request import Request -AuthValue: TypeAlias = ( - str - | Callable[[], str | Awaitable[str]] - | Middleware - | None -) +AuthValue: TypeAlias = str | Callable[[], str | Awaitable[str]] | Middleware | None def _normalize_auth(value: AuthValue) -> Middleware | None: @@ -91,20 +86,14 @@ def _normalize_auth(value: AuthValue) -> Middleware | None: if isinstance(value, str): return _bearer(value) if not callable(value): - msg = ( - "`auth=` must be a string, zero-arg callable, Middleware, or None; " - f"got {type(value).__name__}" - ) + msg = f"`auth=` must be a string, zero-arg callable, Middleware, or None; got {type(value).__name__}" raise TypeError(msg) n_params = len(inspect.signature(value).parameters) if n_params == 0: return _bearer_from_provider(value) if n_params == 2: return value - msg = ( - "`auth=` callable must take 0 args (token provider) or 2 args " - f"(Middleware); got {n_params}" - ) + msg = f"`auth=` callable must take 0 args (token provider) or 2 args (Middleware); got {n_params}" raise TypeError(msg) @@ -165,12 +154,13 @@ def __init__( transport: Transport | None = None, decoder: ResponseDecoder | None = None, middleware: Sequence[Middleware] | None = None, - auth: AuthValue = None, # NEW + auth: AuthValue = None, # NEW ) -> None: normalized_timeout = _normalize_timeout(timeout) resolved_limits = limits or Limits() resolved_transport: Transport = transport or Httpx2Transport( - limits=resolved_limits, timeout=normalized_timeout, + limits=resolved_limits, + timeout=normalized_timeout, ) resolved_decoder = decoder or PydanticDecoder() resolved_user_middleware = tuple(middleware) if middleware is not None else () @@ -210,7 +200,7 @@ def with_options( timeout: Timeout | float | None = _UNSET, decoder: ResponseDecoder | None = _UNSET, middleware: Sequence[Middleware] | None = _UNSET, - auth: AuthValue | object = _UNSET, # NEW + auth: AuthValue | object = _UNSET, # NEW ) -> "AsyncClient": """...""" changes: dict[str, typing.Any] = {} @@ -235,9 +225,7 @@ def with_options( new_auth_middleware = _normalize_auth(new_auth) new_composed: tuple[Middleware, ...] = ( - new_user_middleware - if new_auth_middleware is None - else (*new_user_middleware, new_auth_middleware) + new_user_middleware if new_auth_middleware is None else (*new_user_middleware, new_auth_middleware) ) changes["middleware"] = new_composed diff --git a/planning/changes/2026-06-03.02-thin-httpx2-wrapper.md b/planning/changes/2026-06-03.02-thin-httpx2-wrapper.md index d801618..c4b8eb9 100644 --- a/planning/changes/2026-06-03.02-thin-httpx2-wrapper.md +++ b/planning/changes/2026-06-03.02-thin-httpx2-wrapper.md @@ -123,11 +123,25 @@ When `decoder` is `None`, the client falls back to `PydanticDecoder()` — same Each method has two overloads — `response_model=None` returns `httpx2.Response`, `response_model=type[T]` returns `T`. ```python -async def get( url, *, params=None, headers=None, cookies=None, - timeout=..., extensions=None, response_model=None) -> httpx2.Response | T: ... -async def post( url, *, json=None, content=None, data=None, files=None, - params=None, headers=None, cookies=None, - timeout=..., extensions=None, response_model=None) -> httpx2.Response | T: ... +async def get( + url, *, params=None, headers=None, cookies=None, timeout=..., extensions=None, response_model=None +) -> httpx2.Response | T: ... +async def post( + url, + *, + json=None, + content=None, + data=None, + files=None, + params=None, + headers=None, + cookies=None, + timeout=..., + extensions=None, + response_model=None, +) -> httpx2.Response | T: ... + + # put / patch / delete / head / options / request follow the same pattern. ``` @@ -139,6 +153,7 @@ Body kwargs (`json`, `content`, `data`, `files`) are passed straight through to def build_request(self, method: str, url: str, **kw) -> httpx2.Request: return self._httpx2_client.build_request(method, url, **kw) + async def send( self, request: httpx2.Request, @@ -153,7 +168,7 @@ async def send( ```python async def __aenter__(self) -> Self: ... -async def __aexit__(self, *exc) -> None: ... # closes self._httpx2_client only if owned +async def __aexit__(self, *exc) -> None: ... # closes self._httpx2_client only if owned ``` A boolean `self._owns_client` records ownership. `__aexit__` is idempotent. @@ -171,6 +186,7 @@ from typing import Protocol, TypeAlias, runtime_checkable type Next = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + @runtime_checkable class Middleware(Protocol): async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: ... @@ -194,8 +210,13 @@ The chain is composed once at `AsyncClient.__init__` and cached as `self._dispat ```python class ClientError(Exception): ... + + class TransportError(ClientError): ... -class TimeoutError(ClientError, builtins.TimeoutError): ... # shadows builtin intentionally + + +class TimeoutError(ClientError, builtins.TimeoutError): ... # shadows builtin intentionally + class StatusError(ClientError): response: httpx2.Response @@ -207,18 +228,39 @@ class StatusError(ClientError): def __repr__(self) -> str: ... # strips userinfo from response.request.url def _summary(self) -> str: ... # short message: " " -class ClientStatusError(StatusError): ... # base for 4xx -class ServerStatusError(StatusError): ... # base for 5xx -class BadRequestError(ClientStatusError): ... # 400 -class UnauthorizedError(ClientStatusError): ... # 401 -class ForbiddenError(ClientStatusError): ... # 403 -class NotFoundError(ClientStatusError): ... # 404 -class ConflictError(ClientStatusError): ... # 409 +class ClientStatusError(StatusError): ... # base for 4xx + + +class ServerStatusError(StatusError): ... # base for 5xx + + +class BadRequestError(ClientStatusError): ... # 400 + + +class UnauthorizedError(ClientStatusError): ... # 401 + + +class ForbiddenError(ClientStatusError): ... # 403 + + +class NotFoundError(ClientStatusError): ... # 404 + + +class ConflictError(ClientStatusError): ... # 409 + + class UnprocessableEntityError(ClientStatusError): ... # 422 -class RateLimitedError(ClientStatusError): ... # 429 -class InternalServerError(ServerStatusError): ... # 500 -class ServiceUnavailableError(ServerStatusError): ... # 503 + + +class RateLimitedError(ClientStatusError): ... # 429 + + +class InternalServerError(ServerStatusError): ... # 500 + + +class ServiceUnavailableError(ServerStatusError): ... # 503 + STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = {...} ``` @@ -288,9 +330,11 @@ Transport-level tests inject `httpx2.MockTransport` via the `httpx2_client=` par import httpx2 from httpware import AsyncClient + def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) + async def test_get_returns_response(): mock = httpx2.MockTransport(handler) async with AsyncClient(httpx2_client=httpx2.AsyncClient(transport=mock)) as client: diff --git a/planning/changes/2026-06-04.01-pydantic-optional-extra.md b/planning/changes/2026-06-04.01-pydantic-optional-extra.md index 5d99185..4d20ae0 100644 --- a/planning/changes/2026-06-04.01-pydantic-optional-extra.md +++ b/planning/changes/2026-06-04.01-pydantic-optional-extra.md @@ -185,6 +185,7 @@ def _default_pydantic_decoder() -> ResponseDecoder: if not import_checker.is_pydantic_installed: raise ImportError(_DEFAULT_DECODER_MISSING_MESSAGE) from httpware.decoders.pydantic import PydanticDecoder + return PydanticDecoder() ``` @@ -244,8 +245,7 @@ def test_importing_httpware_does_not_import_pydantic() -> None: capture_output=True, ) assert result.returncode == 0, ( - f"pydantic was loaded transitively by `import httpware`; " - f"stdout={result.stdout!r} stderr={result.stderr!r}" + f"pydantic was loaded transitively by `import httpware`; stdout={result.stdout!r} stderr={result.stderr!r}" ) ``` diff --git a/planning/changes/2026-06-05.01-retry-and-retry-budget.md b/planning/changes/2026-06-05.01-retry-and-retry-budget.md index 7760b53..643da10 100644 --- a/planning/changes/2026-06-05.01-retry-and-retry-budget.md +++ b/planning/changes/2026-06-05.01-retry-and-retry-budget.md @@ -72,9 +72,9 @@ class RetryBudget: def __init__( self, *, - ttl: float = 10.0, # seconds tokens remain valid + ttl: float = 10.0, # seconds tokens remain valid min_retries_per_sec: float = 10.0, # floor regardless of success rate - percent_can_retry: float = 0.2, # fraction of recent successes retriable + percent_can_retry: float = 0.2, # fraction of recent successes retriable ) -> None: ... def deposit(self) -> None: @@ -107,14 +107,14 @@ class Retry: def __init__( self, *, - max_attempts: int = 3, # total tries, including first - base_delay: float = 0.1, # seconds; exponential base - max_delay: float = 5.0, # cap on backoff - attempt_timeout: float | None = None, # wall-clock cap per attempt + max_attempts: int = 3, # total tries, including first + base_delay: float = 0.1, # seconds; exponential base + max_delay: float = 5.0, # cap on backoff + attempt_timeout: float | None = None, # wall-clock cap per attempt retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES, retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS, - respect_retry_after: bool = True, # honor Retry-After on 429/503 - budget: RetryBudget | None = None, # None -> fresh per-client default + respect_retry_after: bool = True, # honor Retry-After on 429/503 + budget: RetryBudget | None = None, # None -> fresh per-client default ) -> None: ... async def __call__( @@ -127,16 +127,24 @@ class Retry: Module-level constants in `retry.py` (per the user's module-constants preference): ```python -DEFAULT_RETRY_STATUS_CODES: typing.Final = frozenset({ - HTTPStatus.REQUEST_TIMEOUT, # 408 - HTTPStatus.TOO_MANY_REQUESTS, # 429 - HTTPStatus.BAD_GATEWAY, # 502 - HTTPStatus.SERVICE_UNAVAILABLE, # 503 - HTTPStatus.GATEWAY_TIMEOUT, # 504 -}) -DEFAULT_IDEMPOTENT_METHODS: typing.Final = frozenset({ - "GET", "HEAD", "OPTIONS", "PUT", "DELETE", -}) +DEFAULT_RETRY_STATUS_CODES: typing.Final = frozenset( + { + HTTPStatus.REQUEST_TIMEOUT, # 408 + HTTPStatus.TOO_MANY_REQUESTS, # 429 + HTTPStatus.BAD_GATEWAY, # 502 + HTTPStatus.SERVICE_UNAVAILABLE, # 503 + HTTPStatus.GATEWAY_TIMEOUT, # 504 + } +) +DEFAULT_IDEMPOTENT_METHODS: typing.Final = frozenset( + { + "GET", + "HEAD", + "OPTIONS", + "PUT", + "DELETE", + } +) ``` `http.HTTPStatus` is used rather than bare integers per the user preference. @@ -158,7 +166,7 @@ For each completed attempt (exception OR response), `Retry` evaluates: ### Backoff: exponential with full jitter ```python -sleep = random.uniform(0, min(max_delay, base_delay * (2 ** attempt_index))) +sleep = random.uniform(0, min(max_delay, base_delay * (2**attempt_index))) ``` This is AWS's "full jitter" formulation. `attempt_index` is 0 for the first retry. With `base_delay=0.1, max_delay=5.0, max_attempts=3`, the two retry delays draw from `U(0, 0.2)` and `U(0, 0.4)` respectively — fast enough for transient blips, slow enough not to thunderbolt a recovering downstream. @@ -180,10 +188,12 @@ When `attempt_timeout` is not `None`, each attempt runs inside `async with async Documented recommendation (not enforced): ```python -AsyncClient(middleware=[ - Retry(...), # outermost: each retry re-runs middleware below - # observability middlewares (5-x) when they land -]) +AsyncClient( + middleware=[ + Retry(...), # outermost: each retry re-runs middleware below + # observability middlewares (5-x) when they land + ] +) ``` Rationale: putting `Retry` at the outermost position means each attempt re-runs every middleware below it — relevant when, e.g., an auth-refresh middleware sits below `Retry` and needs to refresh on retry. Users who prefer "log once across all attempts" can put observability above Retry; that trade-off is documented in the docstring, not enforced. diff --git a/planning/changes/2026-06-05.02-bulkhead.md b/planning/changes/2026-06-05.02-bulkhead.md index aa672d8..c40fe33 100644 --- a/planning/changes/2026-06-05.02-bulkhead.md +++ b/planning/changes/2026-06-05.02-bulkhead.md @@ -56,7 +56,7 @@ class Bulkhead: def __init__( self, *, - max_concurrent: int, # required; no default + max_concurrent: int, # required; no default acquire_timeout: float | None = 1.0, # seconds; None = wait forever; 0 = fail fast ) -> None: ... @@ -89,10 +89,7 @@ class BulkheadFullError(ClientError): def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> None: self.max_concurrent = max_concurrent self.acquire_timeout = acquire_timeout - super().__init__( - f"bulkhead full (max_concurrent={max_concurrent}, " - f"acquire_timeout={acquire_timeout})" - ) + super().__init__(f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})") def __reduce__(self) -> tuple[Any, ...]: ... # picklable via module-level reconstructor ``` @@ -142,10 +139,12 @@ Why two-stage (`acquire` then `try/finally`) rather than `async with self._sem`: Documented recommendation (not enforced): ```python -AsyncClient(middleware=[ - Bulkhead(max_concurrent=10), # outermost: cap total concurrency - Retry(...), # retries happen *inside* the Bulkhead slot -]) +AsyncClient( + middleware=[ + Bulkhead(max_concurrent=10), # outermost: cap total concurrency + Retry(...), # retries happen *inside* the Bulkhead slot + ] +) ``` Rationale: with `Bulkhead` outside `Retry`, a single request occupies one slot across all its retry attempts. Concurrency stays bounded by `max_concurrent` even under retry storms. The opposite order — `Retry` outside `Bulkhead` — lets each retry attempt re-acquire a slot, which inflates effective concurrency under the exact load conditions retry is meant to absorb. diff --git a/planning/changes/2026-06-05.03-docs-sync-0.4.md b/planning/changes/2026-06-05.03-docs-sync-0.4.md index def7a2e..ea55842 100644 --- a/planning/changes/2026-06-05.03-docs-sync-0.4.md +++ b/planning/changes/2026-06-05.03-docs-sync-0.4.md @@ -46,7 +46,7 @@ This is the closing story of Epic 3. The original framing ("document the extensi base_url="https://api.example.com", middleware=[ Bulkhead(max_concurrent=10), # cap total in-flight - Retry(), # default: 3 attempts, full-jitter backoff + Retry(), # default: 3 attempts, full-jitter backoff ], ) as client: user = await client.get("/users/1", response_model=User) diff --git a/planning/changes/2026-06-05.04-streaming.md b/planning/changes/2026-06-05.04-streaming.md index b09e13a..cde208b 100644 --- a/planning/changes/2026-06-05.04-streaming.md +++ b/planning/changes/2026-06-05.04-streaming.md @@ -49,6 +49,7 @@ src/httpware/ ```python import contextlib + @contextlib.asynccontextmanager async def stream( self, @@ -109,6 +110,7 @@ async def body() -> AsyncIterator[bytes]: async for chunk in some_source(): yield chunk + async with client.stream("POST", "/upload", content=body()) as response: ... ``` @@ -226,9 +228,7 @@ if request.extensions.get("httpware.streaming_body"): if last_exc is None: # pragma: no cover — invariant msg = "Retry: streaming-body refusal reached with no last_exc" raise AssertionError(msg) - last_exc.add_note( - "httpware: not retrying — request body is a stream that cannot replay across attempts" - ) + last_exc.add_note("httpware: not retrying — request body is a stream that cannot replay across attempts") raise last_exc ``` diff --git a/planning/changes/2026-06-05.05-observability.md b/planning/changes/2026-06-05.05-observability.md index 85ae062..88a9e61 100644 --- a/planning/changes/2026-06-05.05-observability.md +++ b/planning/changes/2026-06-05.05-observability.md @@ -90,6 +90,7 @@ def _emit_event( logger.log(level, message, extra=attributes) if import_checker.is_otel_installed: from opentelemetry import trace # noqa: PLC0415 — lazy by design + trace.get_current_span().add_event(event_name, attributes=attributes) ``` diff --git a/planning/changes/2026-06-05.06-extension-slot-docs.md b/planning/changes/2026-06-05.06-extension-slot-docs.md index 94ecfd4..4032f33 100644 --- a/planning/changes/2026-06-05.06-extension-slot-docs.md +++ b/planning/changes/2026-06-05.06-extension-slot-docs.md @@ -40,6 +40,7 @@ Approximately 150 lines markdown, structured as: Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + @runtime_checkable class Middleware(Protocol): async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: ... diff --git a/planning/changes/2026-06-05.07-v0.7-docs-expansion.md b/planning/changes/2026-06-05.07-v0.7-docs-expansion.md index 6534503..f7f64a4 100644 --- a/planning/changes/2026-06-05.07-v0.7-docs-expansion.md +++ b/planning/changes/2026-06-05.07-v0.7-docs-expansion.md @@ -111,8 +111,14 @@ The full exception tree and how to catch what. ```python from httpware import ( - AsyncClient, ClientError, StatusError, NetworkError, TimeoutError, - NotFoundError, RetryBudgetExhaustedError, BulkheadFullError, + AsyncClient, + ClientError, + StatusError, + NetworkError, + TimeoutError, + NotFoundError, + RetryBudgetExhaustedError, + BulkheadFullError, ) try: @@ -143,13 +149,13 @@ The full exception tree and how to catch what. 5. **`exc.response.*` access pattern (~20 lines)** — the response object on `StatusError` subclasses is a `httpx2.Response`. Examples: ```python - exc.response.status_code # 404 - exc.response.headers # httpx2.Headers — case-insensitive - exc.response.content # raw bytes - exc.response.text # decoded body - exc.response.json() # parsed JSON - exc.response.request # the failing httpx2.Request - exc.response.request.url # the failing URL + exc.response.status_code # 404 + exc.response.headers # httpx2.Headers — case-insensitive + exc.response.content # raw bytes + exc.response.text # decoded body + exc.response.json() # parsed JSON + exc.response.request # the failing httpx2.Request + exc.response.request.url # the failing URL ``` Note: `__repr__` and the exception summary strip `user:pass@` userinfo from the URL to avoid credential leaks in tracebacks. Query-string secrets are NOT stripped — keep secrets out of query strings. diff --git a/planning/changes/2026-06-06.01-modern-di-recipe.md b/planning/changes/2026-06-06.01-modern-di-recipe.md index 442c329..3261c19 100644 --- a/planning/changes/2026-06-06.01-modern-di-recipe.md +++ b/planning/changes/2026-06-06.01-modern-di-recipe.md @@ -133,6 +133,7 @@ class ServiceClients(Group): cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose), ) + # At Container construction: # modern_di.exceptions.DuplicateProviderTypeError: AsyncClient is already registered ``` diff --git a/planning/changes/2026-06-07.01-sync-client.md b/planning/changes/2026-06-07.01-sync-client.md index 1d36a88..d4d2d7e 100644 --- a/planning/changes/2026-06-07.01-sync-client.md +++ b/planning/changes/2026-06-07.01-sync-client.md @@ -46,12 +46,23 @@ from httpware import async_before_request, async_after_response, async_on_error from httpware import ( RetryBudget, ResponseDecoder, - StatusError, ClientStatusError, ServerStatusError, - NotFoundError, BadRequestError, UnauthorizedError, ForbiddenError, - ConflictError, UnprocessableEntityError, RateLimitedError, - InternalServerError, ServiceUnavailableError, - NetworkError, TimeoutError, TransportError, - BulkheadFullError, RetryBudgetExhaustedError, + StatusError, + ClientStatusError, + ServerStatusError, + NotFoundError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + ConflictError, + UnprocessableEntityError, + RateLimitedError, + InternalServerError, + ServiceUnavailableError, + NetworkError, + TimeoutError, + TransportError, + BulkheadFullError, + RetryBudgetExhaustedError, ClientError, STATUS_TO_EXCEPTION, ) @@ -142,9 +153,10 @@ The version decision (`0.8.0` vs `1.0.0`) is deliberately left open. `1.0.0` is ```python # middleware/__init__.py — both protocols coexist -Next: TypeAlias = Callable[[httpx2.Request], httpx2.Response] +Next: TypeAlias = Callable[[httpx2.Request], httpx2.Response] AsyncNext: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + @runtime_checkable class Middleware(Protocol): """Structural protocol every sync middleware satisfies.""" @@ -153,12 +165,12 @@ class Middleware(Protocol): """Process `request`; call `next(request)` to forward, or synthesize a Response.""" ... + @runtime_checkable class AsyncMiddleware(Protocol): """Structural protocol every async middleware satisfies.""" - async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: - ... + async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: ... ``` ### `compose` @@ -178,12 +190,17 @@ Both implement the same `dispatch = terminal` then `for layer in reversed(middle def before_request(f: Callable[[httpx2.Request], httpx2.Request]) -> Middleware: """Wrap a sync request transform into a sync Middleware.""" + def after_response(f: Callable[[httpx2.Request, httpx2.Response], httpx2.Response]) -> Middleware: ... + def on_error(f: Callable[[httpx2.Request, Exception], httpx2.Response | None]) -> Middleware: ... + def async_before_request(f: Callable[[httpx2.Request], Awaitable[httpx2.Request]]) -> AsyncMiddleware: ... -def async_after_response(f: Callable[[httpx2.Request, httpx2.Response], Awaitable[httpx2.Response]]) -> AsyncMiddleware: ... +def async_after_response( + f: Callable[[httpx2.Request, httpx2.Response], Awaitable[httpx2.Response]], +) -> AsyncMiddleware: ... def async_on_error(f: Callable[[httpx2.Request, Exception], Awaitable[httpx2.Response | None]]) -> AsyncMiddleware: ... ``` @@ -277,6 +294,7 @@ We wrap mutations in a `threading.Lock`. One class, both worlds: import threading from collections import deque + class RetryBudget: """Token-bucket retry budget — thread-safe and asyncio-safe. @@ -285,9 +303,14 @@ class RetryBudget: (sync Client, async AsyncClient) pairs in the same process. """ - def __init__(self, *, ttl: float = 10.0, min_retries_per_sec: float = 10.0, - percent_can_retry: float = 0.2, - _now: Callable[[], float] = time.monotonic) -> None: + def __init__( + self, + *, + ttl: float = 10.0, + min_retries_per_sec: float = 10.0, + percent_can_retry: float = 0.2, + _now: Callable[[], float] = time.monotonic, + ) -> None: ... self._lock = threading.Lock() self._deposits: deque[float] = deque() @@ -415,6 +438,7 @@ The marker `STREAMING_BODY_MARKER = "httpware.streaming_body"` moves to `_intern # _internal/status.py STREAMING_BODY_MARKER = "httpware.streaming_body" + def _is_streaming_body_async(value: object) -> bool: """True if value is an async-iterable body that can't be safely replayed for retry.""" if value is None: @@ -423,6 +447,7 @@ def _is_streaming_body_async(value: object) -> bool: return False return hasattr(value, "__aiter__") + def _is_streaming_body_sync(value: object) -> bool: """True if value is a sync iterable body that can't be safely replayed for retry.""" if value is None: @@ -489,6 +514,7 @@ Behavior reference (same shape as `AsyncClient.stream` in the streaming spec, ma def __enter__(self) -> typing.Self: return self + def __exit__( self, exc_type: type[BaseException] | None, @@ -498,6 +524,7 @@ def __exit__( if self._owns_client and not self._httpx2_client.is_closed: self._httpx2_client.close() + def close(self) -> None: """Close the underlying httpx2 client if we own it. Idempotent. diff --git a/planning/changes/2026-06-07.02-decoder-error.md b/planning/changes/2026-06-07.02-decoder-error.md index 39c9192..5ecf175 100644 --- a/planning/changes/2026-06-07.02-decoder-error.md +++ b/planning/changes/2026-06-07.02-decoder-error.md @@ -77,8 +77,7 @@ def __init__( response: httpx2.Response, model: type, original: BaseException, -) -> None: - ... +) -> None: ... ``` - `response` — the full `httpx2.Response` returned by `_dispatch`. Carries status code, headers, request URL — everything consumers need for logging or translation. The body has already been fully read by the time `send` reaches the decoder, so there is no streaming-resource concern. diff --git a/planning/changes/2026-06-07.03-deep-audit.md b/planning/changes/2026-06-07.03-deep-audit.md index 79377de..a40a321 100644 --- a/planning/changes/2026-06-07.03-deep-audit.md +++ b/planning/changes/2026-06-07.03-deep-audit.md @@ -117,15 +117,24 @@ Every code block in `docs/*.md` must import and run against current code (especi ```python class Finding(TypedDict): - dimension: Literal["correctness", "concurrency", "error_contract", "public_api", - "optional_extras", "tests", "docs", "planning_docs"] - title: str # one-line summary - file: str # repo-relative path - line_hint: int # approximate line; not load-bearing - claim: str # 1-3 sentences of what's wrong + why - evidence_quote: str # 5-15 lines of the cited code, verbatim + dimension: Literal[ + "correctness", + "concurrency", + "error_contract", + "public_api", + "optional_extras", + "tests", + "docs", + "planning_docs", + ] + title: str # one-line summary + file: str # repo-relative path + line_hint: int # approximate line; not load-bearing + claim: str # 1-3 sentences of what's wrong + why + evidence_quote: str # 5-15 lines of the cited code, verbatim suspected_severity: Literal["blocker", "high", "medium", "low", "nit"] - reproducer_hint: str | None # how to demonstrate, if applicable + reproducer_hint: str | None # how to demonstrate, if applicable + class FinderResult(TypedDict): findings: list[Finding] @@ -137,8 +146,8 @@ class FinderResult(TypedDict): class Verdict(TypedDict): lens: Literal["code_reality", "reproducer", "spec_grounded"] confirmed: bool - reason: str # 1-3 sentences citing evidence - quoted_evidence: str | None # short snippet supporting the verdict + reason: str # 1-3 sentences citing evidence + quoted_evidence: str | None # short snippet supporting the verdict severity_adjustment: Literal["unchanged", "raise", "lower"] | None ``` diff --git a/planning/changes/2026-06-08.01-send-with-response.md b/planning/changes/2026-06-08.01-send-with-response.md index 06237eb..3428ef8 100644 --- a/planning/changes/2026-06-08.01-send-with-response.md +++ b/planning/changes/2026-06-08.01-send-with-response.md @@ -17,7 +17,7 @@ Today these callers fall back to raw `send(request)` and re-decode `response.con ```python response = self.http.send(self.http.build_request("GET", url, params=params)) -items = _validate_tag_list(response) # manual decode helper +items = _validate_tag_list(response) # manual decode helper ``` The manual decode bypasses the configured `ResponseDecoder` (pydantic vs. msgspec swappability is wasted), and the decoder library's exceptions leak past `except httpware.ClientError` — the same hole `DecodeError` closed in `0.8.1` for the `send(..., response_model=)` path, now re-opened at the call site. @@ -26,7 +26,10 @@ This spec adds one method per client class: ```python def send_with_response( - self, request: httpx2.Request, *, response_model: type[T], + self, + request: httpx2.Request, + *, + response_model: type[T], ) -> tuple[httpx2.Response, T]: ... ``` @@ -147,7 +150,7 @@ The reuse of `DecodeError` is deliberate. `DecodeError.response` carries the sam try: response, page = client.send_with_response(req, response_model=Page) except DecodeError as exc: - headers = exc.response.headers # same response as the success path + headers = exc.response.headers # same response as the success path ... ``` diff --git a/planning/changes/2026-06-08.02-retry-budget-cluster.md b/planning/changes/2026-06-08.02-retry-budget-cluster.md index f657254..6aa17f7 100644 --- a/planning/changes/2026-06-08.02-retry-budget-cluster.md +++ b/planning/changes/2026-06-08.02-retry-budget-cluster.md @@ -94,7 +94,8 @@ if retry_after is not None: if last_exc is not None: last_exc.add_note( _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format( - retry_after=retry_after, max_delay=self.max_delay, + retry_after=retry_after, + max_delay=self.max_delay, ), ) raise last_exc @@ -124,6 +125,7 @@ _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE = ( ```python import math + # ... ceiling = math.ceil(len(self._deposits) * self._percent_can_retry) + floor ``` @@ -148,6 +150,7 @@ The test computes its expected bound with the same `int(...)` truncation as prod ```python import math + expected_ceiling = math.ceil(deposits * percent) + floor # permitted must equal the post-fix ceiling exactly (production now matches the test's math) assert permitted == expected_ceiling @@ -180,6 +183,7 @@ async def test_budget_exhaustion_raises_retry_budget_exhausted_error( budget.deposit() # Drain the permitted ceiling by raw withdrawals (mock the budget caller path) import math + ceiling = math.ceil(deposits * percent) for _ in range(ceiling): assert budget.try_withdraw() diff --git a/planning/changes/2026-06-08.04-otel-partial-install.md b/planning/changes/2026-06-08.04-otel-partial-install.md index 2eb2175..cb82bce 100644 --- a/planning/changes/2026-06-08.04-otel-partial-install.md +++ b/planning/changes/2026-06-08.04-otel-partial-install.md @@ -175,9 +175,11 @@ def test_is_otel_installed_uses_opentelemetry_trace_probe() -> None: fails, the module-load-time constant in import_checker.py is using the wrong probe. """ from importlib.util import find_spec + assert find_spec("opentelemetry.trace") is not None # opentelemetry-api IS installed in CI # The boolean derived from the probe must match. from httpware._internal import import_checker + assert import_checker.is_otel_installed is True ``` diff --git a/planning/changes/2026-06-08.05-small-fixes-mop-up.md b/planning/changes/2026-06-08.05-small-fixes-mop-up.md index 66ddfbb..ec8abed 100644 --- a/planning/changes/2026-06-08.05-small-fixes-mop-up.md +++ b/planning/changes/2026-06-08.05-small-fixes-mop-up.md @@ -64,12 +64,10 @@ _AsyncNext: typing.TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Respo _Next: typing.TypeAlias = Callable[[httpx2.Request], httpx2.Response] -def compose_async(middleware: "Sequence[AsyncMiddleware]", terminal: _AsyncNext) -> _AsyncNext: - ... +def compose_async(middleware: "Sequence[AsyncMiddleware]", terminal: _AsyncNext) -> _AsyncNext: ... -def compose(middleware: "Sequence[Middleware]", terminal: _Next) -> _Next: - ... +def compose(middleware: "Sequence[Middleware]", terminal: _Next) -> _Next: ... ``` The two function signatures use string annotations referencing `AsyncMiddleware` / `Middleware`, which are only imported when `typing.TYPE_CHECKING`. `typing.get_type_hints(compose_async)` at runtime raises `NameError: name 'AsyncMiddleware' is not defined`. @@ -91,20 +89,16 @@ _AsyncNext: typing.TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Respo _Next: typing.TypeAlias = Callable[[httpx2.Request], httpx2.Response] -def compose_async(middleware: Sequence[AsyncMiddleware], terminal: _AsyncNext) -> _AsyncNext: - ... +def compose_async(middleware: Sequence[AsyncMiddleware], terminal: _AsyncNext) -> _AsyncNext: ... -def _wrap(layer: AsyncMiddleware, inner: _AsyncNext) -> _AsyncNext: - ... +def _wrap(layer: AsyncMiddleware, inner: _AsyncNext) -> _AsyncNext: ... -def compose(middleware: Sequence[Middleware], terminal: _Next) -> _Next: - ... +def compose(middleware: Sequence[Middleware], terminal: _Next) -> _Next: ... -def _wrap_sync(layer: Middleware, inner: _Next) -> _Next: - ... +def _wrap_sync(layer: Middleware, inner: _Next) -> _Next: ... ``` (String annotations on `_wrap` and `_wrap_sync` also unquoted.) @@ -209,9 +203,7 @@ Catches symbols in `expected` not in `__all__`, but not the reverse — a symbol ```python actual = set(httpware.__all__) assert expected == actual, ( - f"__all__ mismatch:\n" - f" missing from __all__: {expected - actual}\n" - f" unexpected in __all__: {actual - expected}" + f"__all__ mismatch:\n missing from __all__: {expected - actual}\n unexpected in __all__: {actual - expected}" ) ``` diff --git a/planning/changes/2026-06-10.01-multi-decoder.md b/planning/changes/2026-06-10.01-multi-decoder.md index 2a3d160..d6af8dd 100644 --- a/planning/changes/2026-06-10.01-multi-decoder.md +++ b/planning/changes/2026-06-10.01-multi-decoder.md @@ -161,9 +161,11 @@ def _build_default_decoders() -> tuple[ResponseDecoder, ...]: decoders: list[ResponseDecoder] = [] if import_checker.is_pydantic_installed: from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design + decoders.append(PydanticDecoder()) if import_checker.is_msgspec_installed: from httpware.decoders.msgspec import MsgspecDecoder # noqa: PLC0415 — lazy by design + decoders.append(MsgspecDecoder()) return tuple(decoders) ``` @@ -228,10 +230,7 @@ def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> ) else: joined = " + ".join(registered_names) - hint = ( - f"registered decoders ({joined}) all rejected it. " - f"Pass a custom decoder via decoders=[...]." - ) + hint = f"registered decoders ({joined}) all rejected it. Pass a custom decoder via decoders=[...]." return f"no decoder for response_model={model!r}: {hint}" diff --git a/planning/changes/2026-06-13.01-msgspec-nested-customtype-fix.md b/planning/changes/2026-06-13.01-msgspec-nested-customtype-fix.md index 18807cf..4d40e20 100644 --- a/planning/changes/2026-06-13.01-msgspec-nested-customtype-fix.md +++ b/planning/changes/2026-06-13.01-msgspec-nested-customtype-fix.md @@ -64,9 +64,7 @@ def _contains_custom_type(info: "msgspec.inspect.Type") -> bool: if isinstance(value, msgspec.inspect.Type): if _contains_custom_type(value): return True - elif isinstance(value, tuple) and value and all( - isinstance(item, msgspec.inspect.Type) for item in value - ): + elif isinstance(value, tuple) and value and all(isinstance(item, msgspec.inspect.Type) for item in value): if any(_contains_custom_type(item) for item in value): return True return False diff --git a/planning/changes/2026-06-13.02-circuit-breaker-and-timeout.md b/planning/changes/2026-06-13.02-circuit-breaker-and-timeout.md index 1c7a5c4..3030f2c 100644 --- a/planning/changes/2026-06-13.02-circuit-breaker-and-timeout.md +++ b/planning/changes/2026-06-13.02-circuit-breaker-and-timeout.md @@ -193,11 +193,11 @@ class AsyncCircuitBreaker: def __init__( self, *, - failure_threshold: int = 5, # consecutive failures that open; >= 1 - reset_timeout: float = 30.0, # seconds OPEN before a probe; >= 0 - success_threshold: int = 1, # consecutive half-open successes to close; >= 1 + failure_threshold: int = 5, # consecutive failures that open; >= 1 + reset_timeout: float = 30.0, # seconds OPEN before a probe; >= 0 + success_threshold: int = 1, # consecutive half-open successes to close; >= 1 failure_status_codes: frozenset[int] | None = None, # None -> all 5xx (500-599) - _now: Callable[[], float] = time.monotonic, # seam for deterministic tests + _now: Callable[[], float] = time.monotonic, # seam for deterministic tests ) -> None: ... ``` diff --git a/planning/changes/2026-06-14.01-docs-ux-restructure.md b/planning/changes/2026-06-14.01-docs-ux-restructure.md index 868858c..056417e 100644 --- a/planning/changes/2026-06-14.01-docs-ux-restructure.md +++ b/planning/changes/2026-06-14.01-docs-ux-restructure.md @@ -108,6 +108,7 @@ class User(BaseModel): id: int name: str + user = await client.get("/users/1", response_model=User) ``` diff --git a/planning/changes/2026-06-14.03-security-hardening.md b/planning/changes/2026-06-14.03-security-hardening.md index 14d3f75..f1d34ad 100644 --- a/planning/changes/2026-06-14.03-security-hardening.md +++ b/planning/changes/2026-06-14.03-security-hardening.md @@ -119,7 +119,7 @@ if HTTPStatus.BAD_REQUEST <= response.status_code < 600: limit=self._max_error_body_bytes, content_length=content_length, ) - await response.aread() # within cap, or no declared length, or no cap set + await response.aread() # within cap, or no declared length, or no cap set _raise_on_status_error(response) ``` diff --git a/planning/changes/2026-06-16.02-circuit-breaker-rate-mode.md b/planning/changes/2026-06-16.02-circuit-breaker-rate-mode.md index 5966cb4..f9571d7 100644 --- a/planning/changes/2026-06-16.02-circuit-breaker-rate-mode.md +++ b/planning/changes/2026-06-16.02-circuit-breaker-rate-mode.md @@ -50,9 +50,9 @@ and it is consistent with the existing wall-clock `reset_timeout`. ```python AsyncCircuitBreaker( - failure_rate_threshold=0.5, # None (default) = classic; set = rate mode - window_seconds=30.0, # rolling window duration (default 30.0) - minimum_calls=20, # floor before the rate is evaluated (default 20) + failure_rate_threshold=0.5, # None (default) = classic; set = rate mode + window_seconds=30.0, # rolling window duration (default 30.0) + minimum_calls=20, # floor before the rate is evaluated (default 20) # unchanged, shared by both modes: reset_timeout=30.0, success_threshold=1, diff --git a/planning/changes/2026-06-16.03-circuit-breaker-state.md b/planning/changes/2026-06-16.03-circuit-breaker-state.md index 7fd2001..75883e2 100644 --- a/planning/changes/2026-06-16.03-circuit-breaker-state.md +++ b/planning/changes/2026-06-16.03-circuit-breaker-state.md @@ -57,6 +57,7 @@ A pure, side-effect-free read: def state(self) -> CircuitState: return self._state + # on AsyncCircuitBreaker and CircuitBreaker @property def state(self) -> CircuitState: diff --git a/planning/changes/2026-06-23.01-retry-policy-extraction.md b/planning/changes/2026-06-23.01-retry-policy-extraction.md index fe8cc15..2098ac2 100644 --- a/planning/changes/2026-06-23.01-retry-policy-extraction.md +++ b/planning/changes/2026-06-23.01-retry-policy-extraction.md @@ -92,6 +92,7 @@ already raises `CircuitOpenError` rather than returning a rejected value. ```python _RETRYABLE_EXCEPTIONS = (StatusError, NetworkError, TimeoutError) + async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: self.budget.deposit() for attempt in range(self._policy.max_attempts): diff --git a/planning/changes/2026-06-23.02-decoder-resolver-extraction.md b/planning/changes/2026-06-23.02-decoder-resolver-extraction.md index 4010133..4171d97 100644 --- a/planning/changes/2026-06-23.02-decoder-resolver-extraction.md +++ b/planning/changes/2026-06-23.02-decoder-resolver-extraction.md @@ -85,9 +85,9 @@ orchestrates, no import cycle (`decoders/` never imports `client`). ### 2. The four call sites collapse ```python -bound = self._decoder_resolver.resolve(response_model) # pre-flight; may raise MissingDecoderError +bound = self._decoder_resolver.resolve(response_model) # pre-flight; may raise MissingDecoderError response = self._dispatch(request) -return bound.decode(response) # post-HTTP; wraps DecodeError +return bound.decode(response) # post-HTTP; wraps DecodeError ``` `send_with_response` is the same but returns `(response, bound.decode(response))`. diff --git a/planning/changes/2026-06-23.03-response-body-cap.md b/planning/changes/2026-06-23.03-response-body-cap.md index 982b1d8..4bd58f5 100644 --- a/planning/changes/2026-06-23.03-response-body-cap.md +++ b/planning/changes/2026-06-23.03-response-body-cap.md @@ -85,7 +85,7 @@ def _accumulate_capped(chunks: Iterable[bytes], cap: int) -> bytes: for chunk in chunks: buf += chunk if len(buf) > cap: - raise _CapExceeded(read=len(buf)) # internal signal + raise _CapExceeded(read=len(buf)) # internal signal return bytes(buf) ``` @@ -97,17 +97,19 @@ wrap it with the early reject and the `Response` rebuild: async def _read_capped_async(response, cap, request) -> httpx2.Response: cl = _parse_content_length(response.headers.get("content-length")) if cl is not None and cl > cap: - raise ResponseTooLargeError(status_code=response.status_code, limit=cap, - content_length=cl, reason="declared") + raise ResponseTooLargeError(status_code=response.status_code, limit=cap, content_length=cl, reason="declared") try: content = _accumulate_capped_sync_over(response.aiter_bytes(), cap) # async variant except _CapExceeded: - raise ResponseTooLargeError(status_code=response.status_code, limit=cap, - content_length=cl, reason="streamed") - return httpx2.Response(status_code=response.status_code, headers=response.headers, - content=content, request=request, - extensions=_safe_extensions(response.extensions), - history=response.history) + raise ResponseTooLargeError(status_code=response.status_code, limit=cap, content_length=cl, reason="streamed") + return httpx2.Response( + status_code=response.status_code, + headers=response.headers, + content=content, + request=request, + extensions=_safe_extensions(response.extensions), + history=response.history, + ) ``` `_read_capped` takes a *Response*, not a client — so it is agnostic to whether @@ -126,7 +128,7 @@ stream and route through `_read_capped`, owning the stream lifecycle: async def _terminal(self, request): async with _httpx2_exception_mapper(): if self._max_response_body_bytes is None: - response = await self._httpx2_client.send(request) # unchanged fast path + response = await self._httpx2_client.send(request) # unchanged fast path else: resp = await self._httpx2_client.send(request, stream=True) try: diff --git a/planning/changes/2026-07-13.01-body-cap-module-extraction.md b/planning/changes/2026-07-13.01-body-cap-module-extraction.md index f04df5f..b1623a0 100644 --- a/planning/changes/2026-07-13.01-body-cap-module-extraction.md +++ b/planning/changes/2026-07-13.01-body-cap-module-extraction.md @@ -92,7 +92,11 @@ imported by name, same call sites as today (`client.py:283`, `:1151`, ```python from httpware._internal.body_cap import _read_capped, _read_capped_async, _validate_max_response_body_bytes -from httpware._internal.exception_mapping import _httpx2_exception_mapper, _httpx2_exception_mapper_sync, map_httpx2_exception +from httpware._internal.exception_mapping import ( + _httpx2_exception_mapper, + _httpx2_exception_mapper_sync, + map_httpx2_exception, +) ``` Nine functions/one class collapse behind a three-name import in `client.py` diff --git a/planning/changes/2026-07-13.02-client-request-assembly-extraction.md b/planning/changes/2026-07-13.02-client-request-assembly-extraction.md index 293b9cd..a60040a 100644 --- a/planning/changes/2026-07-13.02-client-request-assembly-extraction.md +++ b/planning/changes/2026-07-13.02-client-request-assembly-extraction.md @@ -144,8 +144,13 @@ Replaces `client.py:101-115` (async) and the equivalent sync block. Each ```python kwargs = _assemble_httpx2_client_kwargs( - base_url=base_url, headers=headers, params=params, cookies=cookies, - timeout=timeout, limits=limits, auth=auth, + base_url=base_url, + headers=headers, + params=params, + cookies=cookies, + timeout=timeout, + limits=limits, + auth=auth, ) self._httpx2_client = httpx2.AsyncClient(**kwargs) # or httpx2.Client(**kwargs) self._owns_client = True diff --git a/planning/changes/2026-07-13.04-bulkhead-shared-validation-rejection.md b/planning/changes/2026-07-13.04-bulkhead-shared-validation-rejection.md index 6a4093b..7d174c7 100644 --- a/planning/changes/2026-07-13.04-bulkhead-shared-validation-rejection.md +++ b/planning/changes/2026-07-13.04-bulkhead-shared-validation-rejection.md @@ -118,12 +118,16 @@ today: ```python # AsyncBulkhead.__call__, inside except TimeoutError as exc: raise _emit_bulkhead_rejected( - request, max_concurrent=self._max_concurrent, acquire_timeout=self._acquire_timeout, + request, + max_concurrent=self._max_concurrent, + acquire_timeout=self._acquire_timeout, ) from exc # Bulkhead.__call__, inside if not acquired: raise _emit_bulkhead_rejected( - request, max_concurrent=self._max_concurrent, acquire_timeout=self._acquire_timeout, + request, + max_concurrent=self._max_concurrent, + acquire_timeout=self._acquire_timeout, ) ``` diff --git a/planning/changes/2026-07-13.05-decoders-shared-memoizing-cache.md b/planning/changes/2026-07-13.05-decoders-shared-memoizing-cache.md index f8cb099..e513c14 100644 --- a/planning/changes/2026-07-13.05-decoders-shared-memoizing-cache.md +++ b/planning/changes/2026-07-13.05-decoders-shared-memoizing-cache.md @@ -152,9 +152,11 @@ from httpware.decoders._caching import _get_or_build # ... + def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]": return _get_or_build(self._adapters, model, lambda: TypeAdapter(model)) + def can_decode(self, model: type) -> bool: """Return True iff pydantic can build a schema for `model`. @@ -164,6 +166,7 @@ def can_decode(self, model: type) -> bool: """ return _get_or_build(self._can_decode_results, model, lambda: self._probe_can_decode(model)) + def decode(self, content: bytes, model: type[T]) -> T: """Validate `content` as JSON against `model` in a single parse pass.""" adapter = self._get_adapter(model) @@ -179,9 +182,11 @@ from httpware.decoders._caching import _get_or_build # ... + def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]": return _get_or_build(self._msgspec_decoders, model, lambda: msgspec.json.Decoder(model)) + def can_decode(self, model: type) -> bool: """Return True iff msgspec natively understands `model` end-to-end. @@ -191,6 +196,7 @@ def can_decode(self, model: type) -> bool: """ return _get_or_build(self._can_decode_results, model, lambda: self._probe_can_decode(model)) + def decode(self, content: bytes, model: type[T]) -> T: """Validate `content` as JSON against `model` in a single parse pass.""" decoder = self._get_msgspec_decoder(model) diff --git a/planning/releases/0.10.0.md b/planning/releases/0.10.0.md index 5656244..79a34fd 100644 --- a/planning/releases/0.10.0.md +++ b/planning/releases/0.10.0.md @@ -6,7 +6,7 @@ ```python from httpware.middleware.resilience import AsyncCircuitBreaker # async -from httpware.middleware.resilience import CircuitBreaker # sync +from httpware.middleware.resilience import CircuitBreaker # sync from httpware.middleware.resilience import AsyncTimeout from httpware import CircuitOpenError ``` diff --git a/planning/releases/0.12.0.md b/planning/releases/0.12.0.md index 4abb83b..4c9fcb2 100644 --- a/planning/releases/0.12.0.md +++ b/planning/releases/0.12.0.md @@ -17,9 +17,7 @@ from httpware import AsyncClient client = AsyncClient(base_url="https://api.example.com", decoders=[...]) # One call — response metadata and typed body together -response, users = await client.get_with_response( - "/users", params={"page": 2}, response_model=list[User] -) +response, users = await client.get_with_response("/users", params={"page": 2}, response_model=list[User]) next_url = response.headers.get("Link") etag = response.headers.get("ETag") ``` diff --git a/planning/releases/0.13.0.md b/planning/releases/0.13.0.md index c7b1f8d..61883c7 100644 --- a/planning/releases/0.13.0.md +++ b/planning/releases/0.13.0.md @@ -22,8 +22,8 @@ from httpware.middleware.resilience import AsyncCircuitBreaker breaker = AsyncCircuitBreaker( failure_rate_threshold=0.5, # open at ≥50% failures - window_seconds=30.0, # over a rolling 30s window - minimum_calls=20, # but only once 20+ calls are observed + window_seconds=30.0, # over a rolling 30s window + minimum_calls=20, # but only once 20+ calls are observed ) async with AsyncClient( diff --git a/planning/releases/0.4.0.md b/planning/releases/0.4.0.md index 7320722..4d2e079 100644 --- a/planning/releases/0.4.0.md +++ b/planning/releases/0.4.0.md @@ -52,13 +52,16 @@ from httpware import AsyncClient, Retry, RetryBudget shared_budget = RetryBudget() # one bucket, shared -async with AsyncClient( - base_url="https://upstream-a.example.com", - middleware=[Retry(budget=shared_budget)], -) as client_a, AsyncClient( - base_url="https://upstream-b.example.com", - middleware=[Retry(budget=shared_budget)], -) as client_b: +async with ( + AsyncClient( + base_url="https://upstream-a.example.com", + middleware=[Retry(budget=shared_budget)], + ) as client_a, + AsyncClient( + base_url="https://upstream-b.example.com", + middleware=[Retry(budget=shared_budget)], + ) as client_b, +): ... ``` @@ -85,7 +88,7 @@ Retry( max_attempts=5, base_delay=0.05, max_delay=1.0, - attempt_timeout=0.5, # cap each attempt at 500ms wall-clock + attempt_timeout=0.5, # cap each attempt at 500ms wall-clock retry_methods=frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "POST"}), budget=RetryBudget(percent_can_retry=0.1), # tighter cap ) @@ -100,7 +103,7 @@ async with AsyncClient( base_url="https://api.example.com", middleware=[ Bulkhead(max_concurrent=10), # cap total in-flight at 10 - Retry(), # retries happen inside the Bulkhead slot + Retry(), # retries happen inside the Bulkhead slot ], ) as client: user = await client.get("/users/1", response_model=User) @@ -126,13 +129,16 @@ Share a Bulkhead across multiple clients hitting the same downstream: ```python shared_bulkhead = Bulkhead(max_concurrent=20) -async with AsyncClient( - base_url="https://upstream.example.com/v1", - middleware=[shared_bulkhead], -) as client_a, AsyncClient( - base_url="https://upstream.example.com/v2", - middleware=[shared_bulkhead], -) as client_b: +async with ( + AsyncClient( + base_url="https://upstream.example.com/v1", + middleware=[shared_bulkhead], + ) as client_a, + AsyncClient( + base_url="https://upstream.example.com/v2", + middleware=[shared_bulkhead], + ) as client_b, +): ... # the 20-slot cap is enforced jointly across A and B ``` diff --git a/planning/releases/0.9.0.md b/planning/releases/0.9.0.md index 94bcbe3..6afea63 100644 --- a/planning/releases/0.9.0.md +++ b/planning/releases/0.9.0.md @@ -35,6 +35,7 @@ Users who relied on the eager `ImportError` for container-image validation shoul ```python from httpware._internal import import_checker + assert import_checker.is_pydantic_installed, "pydantic extra missing" ``` @@ -63,10 +64,9 @@ If you have your own `ResponseDecoder` implementation, add `can_decode`. The tri ```python class MyDecoder: def can_decode(self, model: type) -> bool: - return True # claim everything; existing behavior preserved + return True # claim everything; existing behavior preserved - def decode(self, content: bytes, model: type) -> object: - ... + def decode(self, content: bytes, model: type) -> object: ... ``` If your decoder is specialized to certain model types, gate `can_decode` accordingly so it doesn't claim models it can't actually handle — otherwise the dispatcher will route to your decoder and you'll raise at `decode()` time, wrapped as `DecodeError`. The clean shape is for `can_decode` to reject what you can't handle, letting another decoder in the list try. diff --git a/pyproject.toml b/pyproject.toml index e27f3ce..c8b0a8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ ignore = [ "D213", # "multi-line-summary-second-line" conflicting with D212 "COM812", # flake8-commas "Trailing comma missing" "ISC001", # flake8-implicit-str-concat + "CPY001", # no per-file copyright header ] isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"]