mxhttp
Declarative HTTP client on top of msgspec and httpx. Write an API as a class of annotated stub methods and mxhttp will handle the rest (request building, sending, and response decoding).
Install
pip install mxhttp
Usage
from typing import Annotated
import msgspec
from mxhttp import Body, Query, SyncConsumer, get, post
class Item(msgspec.Struct):
id: int
name: str
price: float
class NewItem(msgspec.Struct):
name: str
price: float
class Shop(SyncConsumer):
@get("/items/{item_id}")
def get_item(self, item_id: int) -> Item: ... # type: ignore[empty-body]
@get("/search")
def search(self, q: Annotated[str, Query], limit: Annotated[int, Query] = 20) -> list[Item]: ... # type: ignore[empty-body]
@post("/items")
def create_item(self, item: Annotated[NewItem, Body]) -> Item: ... # type: ignore[empty-body]
shop = Shop("https://api.example.com")
item = shop.get_item(item_id=7)
new = shop.create_item(item=NewItem(name="Gadget", price=4.5))
The method body is never run as it is replaced by the decorator. Parameters are bound based on their Annotated[...] marker:
| Marker class | Request Target | Info |
|---|---|---|
Path (or implicit) |
Path | Matched by parameter name unless annotated explicitly (Path["name"]). Must be a non-nullable str, int, or float. |
RawPath |
Path | Like Path, but spliced into the URL without percent-encoding, bypassing any query-parameter checks (duplicate keys, structure, etc.) that normally apply. Must be a non-nullable str. Use only for values that are already encoded relative paths or query fragments. |
Query |
Query | Must be a nullable str, int, float, or bool, or a Sequence of those, sent as key=a&key=b&.... |
Field |
Form Field | application/x-www-form-urlencoded. Accepts same types as Query. |
Part |
Multipart File Part | Forces the whole request to be multipart and any Field params on the same call will become multipart fields as well. Accepts the same types httpx takes for files=. |
Header |
HTTP Header | Must be str, int, float, or bool, but no list of those. |
Cookie |
Cookie | Is superseded by the cookie jar of the client if it already has a same-named cookie, unless override=True is set. Accepts same types as Header |
Body |
JSON Body | Whole object, serialized with msgspec.to_builtins. Can't be a scalar type. |
- Use
Path["name"],Query["name"],Field["name"],Header["name"], orCookie["name"]to bind under a different name than the parameter (e.g. reservedfrom, or a header likeX-Request-Id, unsupported string format arguments like?). None-valuedQuery,Field,Header, andCookieparameters are omitted from the request.Pathparameters cannot be optional as a placeholder cannot be ommited from the URL.- Mismatched marker/type combinations raise a
TypeErroras soon as the class body runs, not at call time. Literal[...]andEnumtypes are accepted where scalar types are (Path,Query,Field,Header,Cookie). Every literal value or enum member value must bestr,int, orfloat(plusbooloutside ofPath).Enummembers are serialized by their.value.
Inline query parameters
A path template can bake query parameters directly into the string:
class Shop(SyncConsumer):
@get("/items?category={cat}")
def by_category(self, cat: str) -> list[Item]: ... # type: ignore[empty-body]
- An unmarked parameter binds implicitly to a
{name}placeholder, same mechanism asPath. Query["cat"]binds it to a different parameter name. Unlike every otherMarker, the brackets aren't the wire name here — the wire name is whatever query key the template assigned to{cat}.- A query entry with no placeholder (e.g.
?active=true) is a static value sent on every call. That key also cannot be reused by a dynamicQueryparameter. - Each query key, and each placeholder field, can only be used once per path template (e.g.
/things?a={x}&a={y}is rejected). - A placeholder field also cannot be reused by a real path segment (
/{id}?other={id}is rejected). - A placeholder cannot be mixed with literal text in the same value (
key=prefix{name}), and cannot stand in for the key itself ({name}with no=). - Every
{name}field must be bound by exactly one parameter (implicit,Path["name"], orQuery["name"]). - An inline query field cannot be a
Sequenceas the placeholder reserves exactly one query spot. - All of the above raise a
TypeErroras soon as the class body runs, not at call time.
Decoding the response
The return type defines the reponse decoding:
httpx.Responsefor the raw response.strorbytesfor the corresponding.textor.contentwith no JSON round-trip.pydantic.BaseModelsubclasses via their own.model_validate_json.- Anything else
msgspec.json.decodecan decode:msgspec.Struct, dataclasses,TypedDict,NamedTuple, andlist,dict, or other containers of those. Response[Item]for a small struct with the decodedItemas.dataand the rawhttpx.Responsein.response.- Plain
attrsclasses are decoded bymsgspec, for type hintingattrsis needed as dependency.
For an async client, subclass AsyncConsumer and declare the methods async def, everything else stays the same.
Response handling
By default, every response is checked by response.raise_for_status() before decoding, so errors during the request raise httpx.HTTPStatusError automatically. This behavior can be overridden by @response_handler and @streaming_response_handler class decorators.
import httpx
from mxhttp import response_handler, streaming_response_handler
def ignore_errors(response: httpx.Response) -> httpx.Response:
return response
@response_handler(ignore_errors)
@streaming_response_handler(ignore_errors)
class Shop(SyncConsumer): ...
The @response_handler hook runs on buffered responses before decoding. For streaming and SSE endpoints, @streaming_response_handler inspects the initial status line and headers before chunks or events are yielded.
Retries
Configure automatic retries with exponential backoff via @retry on the class, so individual endpoints don't need to hand-roll their own retry loop:
from mxhttp import Retry, SyncConsumer, get, retry
@retry(Retry(attempts=3, on={429, 500, 502, 503, 504}))
class Shop(SyncConsumer):
@get("/items/{item_id}")
def get_item(self, item_id: int) -> Item: ... # type: ignore[empty-body]
- The last attempt's response (still checked by the response handler) or exception is what's ultimately raised/returned.
- Only applies to regular (non-streaming, non-SSE) endpoints.
onaccepts a mix of status codes, exception types, andCallable[[httpx.Response], bool]predicates. Any single entry matching the outcome of an attempt triggers a retry; combine conditions withandinside one predicate if you need all of them to hold at once.- When a matched response carries a
Retry-Afterheader (seconds or an HTTP-date), its delay is used instead of the computed backoff if it is larger, still capped bymax_delay. Setrespect_retry_after=FalseonRetryto always use the computed backoff. - Pass
retry=directly to@get/@post/etc. to override the class'sRetryconfig for that one endpoint, orretry=Noneto disable retries for it:
class Shop(SyncConsumer):
@get("/items/{item_id}", retry=Retry(attempts=5))
def get_item(self, item_id: int) -> Item: ... # type: ignore[empty-body]
@post("/items", retry=None)
def create_item(self, item: Annotated[NewItem, Body]) -> Item: ... # type: ignore[empty-body]
Rate limiting
Configure a maximum call rate with @ratelimit on the class, so individual endpoints don't need to hand-roll their own throttling:
from mxhttp import RateLimit, SyncConsumer, get, ratelimit
@ratelimit(RateLimit(calls=5, period=1))
class Shop(SyncConsumer):
@get("/items/{item_id}")
def get_item(self, item_id: int) -> Item: ... # type: ignore[empty-body]
- The limit is scoped to the
(host, port)being called, shared across consumer instances and endpoints using the same configuration. - Unlike
@retry, this applies to every endpoint kind, including streaming, SSE, and downloads. - By default, a call over the limit blocks until the current window resets. Set
block=Falseto raiseRateLimitExceededErrorimmediately instead, ormax_delay=to raise instead of blocking past that many seconds. - Pass
key="custom_pool"toRateLimitto partition rate limits into dedicated pools or keep method quotas isolated from each other. - Pass
ratelimit=directly to@get/@post/etc. to override the class configuration for that one endpoint, orratelimit=Noneto disable rate limiting for it, the same wayretry=works.
Streaming responses
Annotate the return type as Iterator[bytes] (sync) or AsyncIterator[bytes] (async) to stream the response body in chunks.
from collections.abc import AsyncIterator, Iterator
class Files(SyncConsumer):
@get("/files/{file_id}")
def download(self, file_id: int) -> Iterator[bytes]: ... # type: ignore[empty-body]
for chunk in shop_files.download(file_id=7):
...
class AsyncFiles(AsyncConsumer):
@get("/files/{file_id}")
def download(self, file_id: int) -> AsyncIterator[bytes]: ... # type: ignore[empty-body]
async for chunk in await shop_async_files.download(file_id=7):
...
httpx already decompresses chunks before responding according to Content-Encoding (gzip/deflate/br/zstd).
Streaming responses run @streaming_response_handler instead of @response_handler (defaults to raise_for_status as well).
The handler can only inspect status line and headers.
Resumable downloads
Pass resumable=Retry(...) to @get on a byte-streaming endpoint to reconnect with a Range request instead of restarting from scratch if the connection drops mid-download:
class Files(SyncConsumer):
@get("/files/{file_id}", resumable=Retry(attempts=3, backoff=1.0))
def download(self, file_id: int) -> Iterator[bytes]: ... # type: ignore[empty-body]
- Only valid for
GETendpoints returningIterator[bytes]/AsyncIterator[bytes]; raisesTypeErrorat class-body time otherwise. - The
Retryconfig controls reconnect attempts and backoff the same way it does for regular endpoints;ondecides which mid-stream exceptions trigger a reconnect. - If the server sent an
ETagorLast-Modifiedon the first response, it is sent back asIf-Rangeon reconnects. - If a reconnect gets a full (
200) response instead of a partial (206) one, meaning the server ignoredRangeor the underlying resource changed,ResumeLostErroris raised rather than silently restarting or splicing mismatched bytes.
Resumable downloads to disk
Annotate the return type as Downloader (sync) or AsyncDownloader (async) instead of Iterator[bytes] to get a callable that downloads straight to a file, resuming an interrupted download by calling it again with the same path, even across separate process runs:
from mxhttp import Downloader
class Files(SyncConsumer):
@get("/files/{file_id}")
def download(self, file_id: int) -> Downloader: ... # type: ignore[empty-body]
downloader = shop_files.download(file_id=7)
path = downloader(
"/tmp/report.pdf",
on_progress=lambda received, total: print(f"Progress: {received}/{total}"),
)
# if the process is killed partway through, calling it again resumes from disk:
path = shop_files.download(file_id=7)("/tmp/report.pdf")
- The endpoint is called once to bind it (no network activity yet); the returned
Downloader/AsyncDownloaderis then called with a destination path to actually run (or resume) the download. - Reconnects the same way
resumable=streaming does, defaulting toRetry()if noresumable=override is given, since resumability is the point of this return type. - Downloads to
{path}.partplus a{path}.part.jsonsidecar recording the source URL andETag/Last-Modified. Only on a clean finish is{path}.partatomically renamed topathand the sidecar removed, sopathitself is never observed half-written. - Non-blocking advisory file locking protects concurrent writers from data corruption, raising
DownloadLockErroron contention and releasing cleanly on process exit or termination signals. - Calling the
Downloaderagain for the samepathresumes from{path}.partif its sidecar identity matches the current request; a mismatch raisesDownloadIdentityErrorrather than silently appending to or discarding the wrong data. Passoverwrite=Trueto discard whatever is there and start over. - Pass
on_progress=to receive progress updates(received_bytes, total_bytes_or_None)as chunks arrive.
Server-Sent Events
Annotate the return type as Iterator[Event] (sync) or AsyncIterator[Event] (async) to parse the response as a Server-Sent Events stream instead of raw bytes:
from collections.abc import Iterator
from mxhttp import Event
class Chat(SyncConsumer):
@get("/stream")
def events(self) -> Iterator[Event]: ... # type: ignore[empty-body]
for event in chat.events():
print(event.event, event.data) # event.event defaults to "message"
Event has four attributes, data, event, id, and retry:
datais the raw payload, decode it manually if the server sends JSON.- Multi-line
datafields are joined with\n. idandretrypersist across events once set and reset on reconnect only.- An event without a trailing blank line at the end of the stream is discarded.
SSE streams use @streaming_response_handler matching byte streaming above.
Authentication
Pass auth= to the consumer constructor, accepting anything httpx.Client/httpx.AsyncClient do: an httpx.Auth instance (httpx.BasicAuth, httpx.DigestAuth, or a custom multi-step flow), or a (username, password) tuple as Basic auth shorthand.
shop = Shop("https://api.example.com", auth=httpx.BasicAuth("alice", "secret"))
Further configuration
The underlying httpx.Client or httpx.AsyncClient is stored at .session to set default headers or other client options after construction.
Typing
The package and all its generators are typed.
Tests
pytest
Acknowledgements
mxhttp is inspired by Uplink but combining it with Python typing features.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mxhttp-1.5.7.tar.gz.
File metadata
- Download URL: mxhttp-1.5.7.tar.gz
- Upload date:
- Size: 53.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa777d444d4805a8d96229c4a6c57134724ffdeb1ab88b6af3787671a13d4c4a
|
|
| MD5 |
59b925d63a1025636d41bbd57a1abaf5
|
|
| BLAKE2b-256 |
daeb50d7b848a3446d1fe09596a1a0d2967b328aea6047f79f99c6224a8363c4
|
Provenance
The following attestation bundles were made for mxhttp-1.5.7.tar.gz:
Publisher:
release.yml on audivir/mxhttp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mxhttp-1.5.7.tar.gz -
Subject digest:
aa777d444d4805a8d96229c4a6c57134724ffdeb1ab88b6af3787671a13d4c4a - Sigstore transparency entry: 2625690948
- Sigstore integration time:
-
Permalink:
audivir/mxhttp@0dd3cdae5eea0a7e572192c75697f601092e6683 -
Branch / Tag:
refs/tags/v1.5.7 - Owner: https://github.com/audivir
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0dd3cdae5eea0a7e572192c75697f601092e6683 -
Trigger Event:
release
-
Statement type:
File details
Details for the file mxhttp-1.5.7-py3-none-any.whl.
File metadata
- Download URL: mxhttp-1.5.7-py3-none-any.whl
- Upload date:
- Size: 31.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
56300b573447c6541d483b620007c1301766031bdd6605715b3de514786c8896
|
|
| MD5 |
f472311366a976a74cc4a72d183bcf82
|
|
| BLAKE2b-256 |
87d2e62057c0cdf45afd4869a207f71a62bb1c8d6a320687bc4c32af97cd8b2b
|
Provenance
The following attestation bundles were made for mxhttp-1.5.7-py3-none-any.whl:
Publisher:
release.yml on audivir/mxhttp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mxhttp-1.5.7-py3-none-any.whl -
Subject digest:
56300b573447c6541d483b620007c1301766031bdd6605715b3de514786c8896 - Sigstore transparency entry: 2625691014
- Sigstore integration time:
-
Permalink:
audivir/mxhttp@0dd3cdae5eea0a7e572192c75697f601092e6683 -
Branch / Tag:
refs/tags/v1.5.7 - Owner: https://github.com/audivir
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0dd3cdae5eea0a7e572192c75697f601092e6683 -
Trigger Event:
release
-
Statement type: