ProvenPin Install

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:22 UTC
Status
passed
Last passed
2026-10-15 13:22 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions4
httpx@0.28.1pydantic@2.14.1respx@0.23.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38741369695

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.

requirements.txt
httpx==0.28.1
pydantic==2.14.1
respx==0.23.1
pytest==9.1.1
client.py
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)
tests/test_client.py
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.ini
[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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.