Typed httpx client parsing into pydantic v2 models, tested with respx: working versions (tested 2026-10-15)
An async httpx client that retries 5xx and transport errors, validates responses into pydantic v2 models, and is tested offline with respx route mocks.
passed6 passed · 0 failed2026-10-15 13:30 UTC- Status
- passed
- Last passed
- 2026-10-15 13:30 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("httpx-pydantic-typed-client") (install), or see them on GitHub.
httpx==0.28.1 pydantic==2.14.1 respx==0.23.1 pytest==9.1.1
import httpx
from pydantic import BaseModel
class Repo(BaseModel):
id: int
full_name: str
stargazers_count: int
class ApiClient:
def __init__(self, base_url: str = "https://api.example.com", retries: int = 2):
self.http = httpx.AsyncClient(base_url=base_url, timeout=10)
self.retries = retries
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
await self.http.aclose()
async def get_repo(self, owner: str, name: str) -> Repo:
for attempt in range(self.retries + 1):
try:
r = await self.http.get(f"/repos/{owner}/{name}")
except httpx.TransportError:
if attempt == self.retries:
raise
continue
if r.status_code < 500 or attempt == self.retries:
break
r.raise_for_status()
# validate raw bytes: invalid JSON also surfaces as pydantic ValidationError
return Repo.model_validate_json(r.content)
import asyncio
import httpx
import pytest
import respx
from pydantic import ValidationError
from client import ApiClient, Repo
URL = "https://api.example.com/repos/a/b"
OK = {"id": 1, "full_name": "a/b", "stargazers_count": 5}
def run(coro):
# sync tests + asyncio.run: no pytest-asyncio dependency needed
return asyncio.run(coro)
async def fetch(owner="a", name="b"):
async with ApiClient() as c:
return await c.get_repo(owner, name)
@respx.mock
def test_parses_and_ignores_extra_fields():
respx.get(URL).mock(return_value=httpx.Response(200, json={**OK, "extra": True}))
assert run(fetch()) == Repo(id=1, full_name="a/b", stargazers_count=5)
@respx.mock
def test_retries_5xx_then_succeeds():
route = respx.get(URL).mock(side_effect=[httpx.Response(503), httpx.Response(200, json=OK)])
run(fetch())
assert route.call_count == 2
@respx.mock
def test_retries_transport_errors():
route = respx.get(URL).mock(side_effect=[httpx.ConnectTimeout("slow"), httpx.Response(200, json=OK)])
assert run(fetch()).id == 1
assert route.call_count == 2
@respx.mock
def test_4xx_is_not_retried():
route = respx.get(URL).mock(return_value=httpx.Response(404))
with pytest.raises(httpx.HTTPStatusError):
run(fetch())
assert route.call_count == 1
@respx.mock
def test_missing_field_raises_validation_error():
respx.get(URL).mock(return_value=httpx.Response(200, json={"id": 1, "full_name": "a/b"}))
with pytest.raises(ValidationError):
run(fetch())
@respx.mock
def test_invalid_json_body_is_validation_error():
respx.get(URL).mock(return_value=httpx.Response(200, content=b"<html>", headers={"content-type": "text/html"}))
with pytest.raises(ValidationError):
run(fetch())
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
httpx async API client + respx mocks
A typed async API client on httpx with retries on 5xx and Pydantic parsing, tested offline with respx route mocks.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
httpx + tenacity async retries honoring Retry-After, tested with respx
Wrap async httpx calls in tenacity AsyncRetrying that retries 429 and 5xx, honors Retry-After, and patches the wait to zero in tests with respx mocks.
httpx client + tenacity retries with backoff, tested with respx
Retry transient HTTP failures (5xx, 429, timeouts) with tenacity around an httpx client, honoring a max attempt count, tested offline with respx.