Skip to main content

Async HTTP client built on httpx + pydantic v2 with first-class Server-Sent Events support.

Project description

pydantic_httpx - Symbiosis of Pydantic, HTTPX and httpx-sse

A small, fully-async HTTP client that pairs httpx with pydantic v2: pass models in, get models out, with first-class Server-Sent Events support via httpx-sse.

It is the successor of pydantic_aiohttp — same ergonomics, but built on top of httpx and with native SSE streaming.

Features

  • Pydantic models accepted everywhere the wire takes data — body, params, headers, cookies.
  • Successful responses parsed into a model via response_model=... using pydantic.TypeAdapter.validate_json (one pass, no intermediate json.loads).
  • Typed exceptions for every standard HTTP status (HTTPNotFound, HTTPUnauthorized, ...) and per-status pydantic error payloads via error_response_models.
  • Server-Sent Events as an async for iterator — yields validated pydantic models when response_model= is set.
  • Streaming file download / chunked upload via aiofiles.
  • Pluggable parsers — ResponseClass for whole-response parsing, SSEEventClass for individual SSE frames.
  • Modern Python: targets 3.11+, uses PEP 604 / PEP 585 syntax, no typing.Optional/Union/List/Dict.

Installation

pip install pydantic-httpx
# or
uv add pydantic-httpx

Examples

Basic example

import asyncio

import pydantic

from pydantic_httpx import Client
from pydantic_httpx.responses import (
    JSONResponseClass,
    PlainTextResponseClass,
    PydanticModelResponseClass,
)


class Todo(pydantic.BaseModel):
    userId: int
    id: int
    title: str
    completed: bool


async def main():
    client = Client('https://jsonplaceholder.typicode.com')

    async with client:
        # Plain text response
        todo = await client.get('/todos/1', response_class=PlainTextResponseClass)
        print(isinstance(todo, str))  # True

        # Bare JSON dict
        todo = await client.get('/todos/1', response_class=JSONResponseClass)
        print(isinstance(todo, dict))  # True

        # Validated pydantic model
        todo = await client.get(
            '/todos/1',
            response_class=PydanticModelResponseClass,
            response_model=Todo,
        )
        print(isinstance(todo, Todo))  # True

        # PydanticModelResponseClass is the default — `response_class` can be omitted
        todo = await client.get('/todos/1', response_model=Todo)
        print(isinstance(todo, Todo))  # True


if __name__ == '__main__':
    asyncio.run(main())

Explicit close vs context manager

import asyncio

import pydantic
from pydantic_httpx import Client


class Todo(pydantic.BaseModel):
    userId: int
    id: int
    title: str
    completed: bool


async def with_context_manager():
    # Preferred — auto-closes the underlying httpx.AsyncClient.
    async with Client('https://jsonplaceholder.typicode.com') as client:
        await client.get('/todos/1', response_model=Todo)


async def with_explicit_close():
    client = Client('https://jsonplaceholder.typicode.com')
    try:
        await client.get('/todos/1', response_model=Todo)
    finally:
        await client.close()


asyncio.run(with_context_manager())

Query params via pydantic models

import pydantic
from pydantic_httpx import Client


class CommentsFilter(pydantic.BaseModel):
    postId: int
    # Default values that aren't explicitly set are dropped (exclude_unset=True).
    # Booleans are serialized as lowercase "true" / "false".
    only_unread: bool = False


class Comment(pydantic.BaseModel):
    postId: int
    id: int
    name: str
    email: str
    body: str


async def fetch_comments():
    async with Client('https://jsonplaceholder.typicode.com') as client:
        comments = await client.get(
            '/comments',
            params=CommentsFilter(postId=1),
            response_model=list[Comment],
        )
        for c in comments[:3]:
            print(c.email)

Sending a body

import pydantic
from pydantic_httpx import Client


class NewPost(pydantic.BaseModel):
    userId: int
    title: str
    body: str


class Post(NewPost):
    id: int


async def create_post():
    async with Client('https://jsonplaceholder.typicode.com') as client:
        post = await client.post(
            '/posts',
            body=NewPost(userId=1, title='hello', body='world'),
            response_model=Post,
        )
        print(post.id)  # 101

Bring your own httpx.AsyncClient

Need event hooks, a custom transport, httpx.Auth, http2=True, custom Limits, or proxy config? Build the httpx.AsyncClient yourself and hand it to Client(httpx_client=...).

import httpx
from pydantic_httpx import Client


async def on_request(request: httpx.Request) -> None:
    print(f"-> {request.method} {request.url}")


async def on_response(response: httpx.Response) -> None:
    print(f"<- {response.status_code} {response.request.url}")


external = httpx.AsyncClient(
    base_url='https://api.example.com',
    http2=True,
    event_hooks={'request': [on_request], 'response': [on_response]},
    limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)

async def main():
    # We do NOT close the user-supplied client — caller owns its lifecycle.
    async with Client(httpx_client=external) as client:
        result = await client.get('/v1/things', response_model=list[Thing])
    # external is still alive here
    await external.aclose()

When httpx_client= is provided, the transport-level kwargs (base_url, headers, cookies, params) are rejected with a clear ValueError — configure them on the AsyncClient itself. bearer_token= still works (it's auth, not transport config); response_class= / error_response_models= / timeout= / follow_redirects= are pydantic_httpx-level concerns and behave as usual (the last two are ignored when you bring your own client).

Bearer-token authentication

import pydantic
from pydantic_httpx import Client

# A plain str or pydantic.SecretStr — both are accepted.
client = Client(
    'https://api.example.com',
    bearer_token=pydantic.SecretStr('my-token'),
)
# Every request now carries `Authorization: Bearer my-token`.

Handling errors parsed as pydantic models

import http
import asyncio

import pydantic

import pydantic_httpx
from pydantic_httpx import Client


class FastAPIValidationError(pydantic.BaseModel):
    loc: list[str]
    msg: str
    type: str


class FastAPIUnprocessableEntityError(pydantic.BaseModel):
    detail: list[FastAPIValidationError]


class User(pydantic.BaseModel):
    id: str
    email: str
    first_name: str
    last_name: str
    is_admin: bool


async def main():
    client = Client(
        'https://fastapi.example.com',
        error_response_models={
            http.HTTPStatus.UNPROCESSABLE_ENTITY: FastAPIUnprocessableEntityError,
        },
    )

    try:
        await client.post(
            '/users',
            body={'first_name': 'John', 'last_name': 'Doe'},  # email missing
            response_model=User,
        )
    except pydantic_httpx.HTTPUnprocessableEntity as e:
        # e.response is now a validated FastAPIUnprocessableEntityError instance
        print(e.response.detail[0].model_dump_json(indent=4))
    finally:
        await client.close()


asyncio.run(main())

Per-request error_response_models= override the client-wide defaults for that specific call. If the body cannot be parsed against the model (or isn't JSON at all), pydantic_httpx.ResponseParseError is raised with the raw body preserved on .raw_response.

Downloading files

import asyncio

from pydantic_httpx import Client


async def main():
    async with Client('https://httpbin.org') as client:
        # Streams the response body straight to disk via httpx.AsyncClient.stream
        # + aiofiles — the full payload is never held in memory.
        filepath = await client.download_file(
            '/image/png',
            filepath='downloaded.png',
            chunk_size=64 * 1024,
        )
        print(filepath)


asyncio.run(main())

Uploading files

from pydantic_httpx import Client

async def upload():
    async with Client('https://api.example.com') as client:
        # multipart/form-data upload
        await client.upload_file('/upload', '/path/to/file.bin', form_key='file')

        # streamed (chunked) raw upload — no multipart framing
        await client.stream_file('/upload-raw', '/path/to/file.bin')

Server-Sent Events

Client.sse(...) returns an async iterator and never leaks the third-party httpx_sse.ServerSentEvent type by default — it yields either the pydantic SSEEvent mirror or a validated response_model instance.

Default — yields SSEEvent pydantic models

from pydantic_httpx import Client, SSEEvent


async def consume():
    async with Client('https://api.example.com') as client:
        async for event in client.sse('/v1/events'):
            assert isinstance(event, SSEEvent)
            print(event.event, event.data, event.id, event.retry)

With response_model — yields validated models

import pydantic
from pydantic_httpx import Client


class StreamRequest(pydantic.BaseModel):
    prompt: str
    stream: bool = True


class Chunk(pydantic.BaseModel):
    delta: str
    index: int


async def stream_chunks():
    async with Client('https://api.example.com') as client:
        async for chunk in client.sse(
            '/v1/chat/completions',
            method='POST',
            body=StreamRequest(prompt='hi'),
            response_model=Chunk,
        ):
            print(chunk.delta, end='', flush=True)

response_model is forwarded to pydantic.TypeAdapter.validate_json(event.data), so anything TypeAdapter accepts works — BaseModel, list[Model], dataclass, dict[str, Foo], etc.

Opt-in raw access — RawSSEEventClass

from pydantic_httpx import Client, ServerSentEvent
from pydantic_httpx.responses import RawSSEEventClass


async def raw_events():
    async with Client('https://api.example.com') as client:
        async for sse in client.sse('/v1/events', event_class=RawSSEEventClass):
            assert isinstance(sse, ServerSentEvent)
            print(sse.event, sse.data, sse.id, sse.retry)

Other built-in event parsers

Class Yields Notes
SSEPydanticEventClass SSEEvent or response_model Default — recommended.
RawSSEEventClass httpx_sse.ServerSentEvent Opt-in raw access.
JSONSSEEventClass Any (parsed JSON) When you want a dict without a schema.
PlainTextSSEEventClass str Returns event.data verbatim.

Live demo: Wikimedia EventStreams

A working end-to-end SSE example you can run without any API keys — stream.wikimedia.org publishes every Wikipedia/Wikidata edit as a live SSE feed:

import asyncio
import pydantic

from pydantic_httpx import Client


class Meta(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(extra='allow')
    domain: str
    stream: str
    dt: str


class RecentChange(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(extra='allow')
    type: str
    title: str
    meta: Meta


async def main():
    # Wikimedia requires an identifying User-Agent.
    async with Client(
        'https://stream.wikimedia.org',
        headers={'User-Agent': 'my-app/0.1 (contact@example.com)'},
    ) as client:
        async for change in client.sse('/v2/stream/recentchange', response_model=RecentChange):
            print(f"[{change.meta.domain}] {change.type}: {change.title}")


asyncio.run(main())

Custom response_class / event_class

Both ResponseClass and SSEEventClass are tiny abstract base classes — you can plug your own parser if the built-ins don't fit.

import abc
import csv
from io import StringIO

import httpx

from pydantic_httpx.responses import ResponseClass


class CSVResponseClass(ResponseClass[list[dict[str, str]]]):
    async def parse(self, *args, **kwargs) -> list[dict[str, str]]:
        reader = csv.DictReader(StringIO(self.response.text))
        return list(reader)


# usage
async def main():
    async with Client('https://api.example.com') as client:
        rows = await client.get('/data.csv', response_class=CSVResponseClass)

Quick reference

Client(
    base_url='',
    *,
    httpx_client=None,     # httpx.AsyncClient — bring your own for hooks/transports/etc.
    headers=None,          # dict | pydantic model
    cookies=None,          # dict | pydantic model
    params=None,           # dict | pydantic model
    error_response_models=None,    # {status: pydantic model}
    bearer_token=None,     # str | pydantic.SecretStr
    response_class=PydanticModelResponseClass,
    timeout=300.0,         # ignored when httpx_client is provided
    follow_redirects=True, # ignored when httpx_client is provided
)

# Per-request methods (all async, all accept the same kwargs):
client.get / post / put / patch / delete(
    path,
    *,
    body=None,             # dict | pydantic model            (json= on the wire)
    data=None,             # dict | str | bytes               (data= form on the wire)
    content=None,          # bytes | iterator                 (raw content=)
    files=None,            # for multipart                    (files=)
    headers=None, cookies=None, params=None,
    response_model=None,
    response_class=None,
    error_response_models=None,
    timeout=None,
)

client.download_file(path, filepath, *, chunk_size=64*1024, ...)
client.upload_file(path, file, *, form_key='file', ...)
client.stream_file(path, file, ...)

client.sse(
    path,
    *,
    method='GET',
    body=None, data=None, content=None,
    headers=None, cookies=None, params=None,
    response_model=None,
    event_class=None,
    error_response_models=None,
    timeout=None,
)

Testing

The project ships with an extensive test suite (~140 tests, 99% coverage on the unit layer). The unit suite mocks the network through the official httpx.MockTransport recommendation — the full client pipeline (cookies, redirects, content decoding) is still exercised, just without real I/O.

# unit tests only (no network) — ~1s
uv run pytest

# opt-in integration tests against real public APIs
# (JSONPlaceholder for REST CRUD, httpbin.org for file transfer,
#  Wikimedia EventStreams for SSE) — ~25s, requires internet
uv run pytest -m integration

License

This project is licensed under the terms of the MIT license.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydantic_httpx-0.3.0.tar.gz (15.3 kB view details)

Uploaded Source

Built Distribution

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

pydantic_httpx-0.3.0-py3-none-any.whl (17.7 kB view details)

Uploaded Python 3

File details

Details for the file pydantic_httpx-0.3.0.tar.gz.

File metadata

  • Download URL: pydantic_httpx-0.3.0.tar.gz
  • Upload date:
  • Size: 15.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pydantic_httpx-0.3.0.tar.gz
Algorithm Hash digest
SHA256 fe9b49abdd720d2c20d500a2ce2a3eeea022c9403a818c2032d9ffef323eba37
MD5 33448f7fe8762a39147811ea1518ddf4
BLAKE2b-256 dc4d3175249dcab0d174a8822484c5b5474df7b453c8620880ab67389593f13c

See more details on using hashes here.

File details

Details for the file pydantic_httpx-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: pydantic_httpx-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 17.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pydantic_httpx-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8058290a65be49574cd2a2a8d1ca7f37f05a152ac3dd75d9f445a9b5eee69f54
MD5 53efd70f151a93df5f53610a8c993f1d
BLAKE2b-256 8d23acead850fa124093742b98c97cac55895f213cc801ce7cc6eb670e42fce8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page