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=...usingpydantic.TypeAdapter.validate_json(one pass, no intermediatejson.loads). - Typed exceptions for every standard HTTP status (
HTTPNotFound,HTTPUnauthorized, ...) and per-status pydantic error payloads viaerror_response_models. - Server-Sent Events as an
async foriterator — yields validated pydantic models whenresponse_model=is set. - Streaming file download / chunked upload via
aiofiles. - Pluggable parsers —
ResponseClassfor whole-response parsing,SSEEventClassfor 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
Release history Release notifications | RSS feed
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 pydantic_httpx-0.2.0.tar.gz.
File metadata
- Download URL: pydantic_httpx-0.2.0.tar.gz
- Upload date:
- Size: 14.8 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
99a7388f754cd4d37d5d4252b7f215813611aab5aa05c5632afdbaf4ef410ae8
|
|
| MD5 |
36f781f44e57761fb5a1fb362cd9f25a
|
|
| BLAKE2b-256 |
4e7bdbcd6c573b6af02d28335912ceb3cfb0a86166bcb778d6fafd9c2216ae0f
|
File details
Details for the file pydantic_httpx-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pydantic_httpx-0.2.0-py3-none-any.whl
- Upload date:
- Size: 17.3 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ca047d9b2cd7c32f2bb97300a0d775377d8dfdfdd6d879761236b5041a15e35b
|
|
| MD5 |
48ac760f72fe54bf8b564e1d12e83d5c
|
|
| BLAKE2b-256 |
c7a9fded197f4a6f405fb7c4e1697c75aaa4abd14ef1fa00509fc192621d29b0
|