Skip to main content

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.
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"], or Cookie["name"] to bind under a different name than the parameter (e.g. reserved from, or a header like X-Request-Id, unsupported string format arguments like ?).
  • None-valued Query, Field, Header, and Cookie parameters are omitted from the request.
  • Path parameters cannot be optional as a placeholder cannot be ommited from the URL.
  • Mismatched marker/type combinations raise a TypeError as soon as the class body runs, not at call time.
  • Literal[...] and Enum types are accepted where scalar types are (Path, Query, Field, Header, Cookie). Every literal value or enum member value must be str, int, or float (plus bool outside of Path). Enum members 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 as Path.
  • Query["cat"] binds it to a different parameter name. Unlike every other Marker, 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 dynamic Query parameter.
  • 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"], or Query["name"]).
  • An inline query field cannot be a Sequence as the placeholder reserves exactly one query spot.
  • All of the above raise a TypeError as soon as the class body runs, not at call time.

Decoding the response

The return type defines the reponse decoding:

  • httpx.Response for the raw response.
  • str or bytes for the corresponding .text or .content with no JSON round-trip.
  • pydantic.BaseModel subclasses via their own .model_validate_json.
  • Anything else msgspec.json.decode can decode: msgspec.Struct, dataclasses, TypedDict, NamedTuple, and list, dict, or other containers of those.
  • Response[Item] for a small struct with the decoded Item as .data and the raw httpx.Response in .response.
  • Plain attrs classes are decoded by msgspec, for type hinting attrs is 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 overriden by @response_handler decorator for the class.

import httpx
from mxhttp import response_handler


def ignore_errors(response: httpx.Response) -> httpx.Response:
    return response


@response_handler(ignore_errors)
class Shop(SyncConsumer): ...

The hook runs on every response before decoding.

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, statuses={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.
  • Pass retry= directly to @get/@post/etc. to override the class's Retry config for that one endpoint, or retry=None to 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]

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.

from mxhttp import streaming_response_handler


def check_status(response: httpx.Response) -> httpx.Response:
    response.raise_for_status()
    return response


@streaming_response_handler(check_status)
class Files(SyncConsumer): ...

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:

  • data is the raw payload, decode it manually if the server sends JSON.
  • Multi-line data fields are joined with \n.
  • id and retry persist 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.

Further configuration

The underlying httpx.Client or httpx.AsyncClient is stored at .session to set default headers, auth, or timeouts.

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

mxhttp-1.3.0.tar.gz (35.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mxhttp-1.3.0-py3-none-any.whl (22.4 kB view details)

Uploaded Python 3

File details

Details for the file mxhttp-1.3.0.tar.gz.

File metadata

  • Download URL: mxhttp-1.3.0.tar.gz
  • Upload date:
  • Size: 35.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for mxhttp-1.3.0.tar.gz
Algorithm Hash digest
SHA256 dc1096a0bd9db4c8016de416e14320eb4f680e7dbc08d2488f90e3238ce4c92f
MD5 2336ec62b0d232bd0106838d3cc1db53
BLAKE2b-256 59e23e91ba76df07dbeae31ca81a76f45e8bb5fb0e6ab233fa9c4c4e75ef2c4b

See more details on using hashes here.

Provenance

The following attestation bundles were made for mxhttp-1.3.0.tar.gz:

Publisher: release.yml on audivir/mxhttp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mxhttp-1.3.0-py3-none-any.whl.

File metadata

  • Download URL: mxhttp-1.3.0-py3-none-any.whl
  • Upload date:
  • Size: 22.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for mxhttp-1.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cc472d7dd38cb374d49aa25febe48eb1e93445e5b21ec42d96862a86e9a0e05a
MD5 952c88e3b733b4048d44636ed7ce3cf4
BLAKE2b-256 42c269bc864abf28cfe9470013ee0490e167770206867f02448553bbfecf2a44

See more details on using hashes here.

Provenance

The following attestation bundles were made for mxhttp-1.3.0-py3-none-any.whl:

Publisher: release.yml on audivir/mxhttp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.7.1

2 files

1.7.0

2 files

1.5.7

2 files

This release

1.3.0 This release

2 files

1.2.0

2 files

1.1.0

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page