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, ErrorResponseClass for error-payload parsing.
  • 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.

Custom error parsing with error_response_class

error_response_models is convenient, but it's all-or-nothing: if the body doesn't validate against the registered model, you get ResponseParseError instead of the status-based exception — you lose the status code along the way. error_response_class is the escape hatch: it's the error-side twin of response_class, a pluggable ErrorResponseClass whose parse() returns whatever payload should be attached to the raised HTTPNotAcceptable / HTTPNotFound / ... exception. Since you write parse() yourself, you can validate against a typed model on the happy path and gracefully fall back to the raw body otherwise, so the status-based exception is still raised with the status code preserved — never ResponseParseError.

import asyncio

import pydantic
import pydantic_httpx as ph


class ApiError(pydantic.BaseModel):
    code: str
    detail: str | None = None


class MyErrorParser(ph.ErrorResponseClass):
    async def parse(self):
        # Happy path: a typed error payload.
        try:
            return ApiError.model_validate_json(self.response.content)
        except (pydantic.ValidationError, ValueError):
            # Non-conforming body (e.g. an infra HTML 502): fall back to the raw
            # body so the status-based exception is still raised with the status
            # code — never ResponseParseError.
            try:
                return self.response.json()
            except ValueError:
                return self.response.text or None


async def main():
    async with ph.Client(
        base_url='https://api.example.com',
        error_response_class=MyErrorParser,
    ) as client:
        try:
            await client.get('/thing')
        except ph.HTTPError as e:
            # e is the status-based exception (HTTPNotAcceptable, HTTPNotFound, ...).
            if isinstance(e.response, ApiError):
                print(e.response.code)
            print(e.status_code)


asyncio.run(main())

error_response_class=DefaultErrorResponseClass is the client-wide default and reproduces the error_response_models behavior described above. Like response_class, error_response_class can also be passed per-request (client.get(..., error_response_class=MyErrorParser)), overriding the client-wide default for that call only.

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}
    error_response_class=DefaultErrorResponseClass,
    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,
    error_response_class=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,
    error_response_class=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.4.1.tar.gz (16.6 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.4.1-py3-none-any.whl (18.8 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: pydantic_httpx-0.4.1.tar.gz
  • Upload date:
  • Size: 16.6 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.4.1.tar.gz
Algorithm Hash digest
SHA256 43923b0321227298c1e3e837be094ad556ae50fd7f4af59e0683f459685c00e2
MD5 88754c74ea2567b31eb840ad8b2663c8
BLAKE2b-256 942b3e30ce646b22899de41ca4cfb5e32a6a9ecbd2a7075b4a4e5606d1ee2630

See more details on using hashes here.

File details

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

File metadata

  • Download URL: pydantic_httpx-0.4.1-py3-none-any.whl
  • Upload date:
  • Size: 18.8 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.4.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ecdf307fd42802bf0294e633b9c40ad8b8a67af714763d91846f2f46472ae1b3
MD5 b47fcf09926bbd72e3e130cdc75def10
BLAKE2b-256 99a10387655eac8f2aee0af8952a1507fcf8cc5c33b2c04332a7cdd97042c101

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