Skip to content

lothc logo lothc logo

lothc

Lord Of The Http Clients — a typed HTTP client for Python, built on pyreqwest (an awesome Rust-backed HTTP client), with first-class optional support for pydantic and msgspec as both decode and encode targets.

Installing

uv add 'lothc[pydantic,msgspec]'

Any subset of the extras works — the base package alone gets you bytes/dict support.

Quickstart

Every snippet below is a complete, runnable script — hit the extra clipboard icon in its top-right corner to copy a one-liner that runs it via uv run, no local install needed.

import asyncio
from lothc import HTTPClient
from msgspec import Struct


class Pokemon(Struct):
    id: int
    name: str


async def main() -> None:
    async with HTTPClient.build(base_url="https://pokeapi.co/api/v2/") as client:
        pikachu = await client.get("pokemon/pikachu", response_data_type=Pokemon)
        print(pikachu)  # Pokemon(id=25, name='pikachu')


asyncio.run(main())
import asyncio
from lothc import HTTPClient
from pydantic import BaseModel


class Pokemon(BaseModel):
    id: int
    name: str


async def main() -> None:
    async with HTTPClient.build(base_url="https://pokeapi.co/api/v2/") as client:
        pikachu = await client.get("pokemon/pikachu", response_data_type=Pokemon)
        print(pikachu)  # id=25 name='pikachu'


asyncio.run(main())
import asyncio
from lothc import HTTPClient


async def main() -> None:
    async with HTTPClient.build(base_url="https://pokeapi.co/api/v2/") as client:
        pikachu = await client.get("pokemon/pikachu", response_data_type=dict)
        print(pikachu)  # {'id': 25, 'name': 'pikachu', ...}


asyncio.run(main())

Warning

response_data_type=dict gets you a plain dict[str, Any] — no schema, no extra dependency, just parsed JSON. There's no validation at all: the shape is fully trusted, on your say-so alone.

SyncHTTPClient mirrors every method in these docs one-for-one — swap async with for with, drop the awaits, and everything still applies.

Why?

Why was lothc built, and why should you use it?

pyreqwest is a genuinely excellent HTTP client for Python, fast because the heavy lifting happens in Rust, not pure Python. But its API is a builder pattern (client.get(path).build() before you can even .send() it), unfamiliar to anyone coming from requests/httpx/niquests, where a call is just client.get(url, params=..., headers=...). lothc wraps pyreqwest with exactly that familiar shape, so you get pyreqwest's speed without giving up the ergonomics Python HTTP users already expect.

The other reason: Python's most popular third-party HTTP clients (httpx, aiohttp, niquests) all have first-class JSON support: call .json() and get back a dict. But in order for a Python project to be end-to-end type-safe, data crossing I/O boundaries must be validated, and if invalid, handled accordingly (raise an error) — a plain dict does neither. Closing that gap is exactly what lothc is designed for.

The original idea was to make lothc backend-agnostic: httpx, aiohttp, niquests, and pyreqwest all interchangeable underneath the same typed lothc interface, so switching backends never meant switching your call sites. However, in the author's opinion there's currently no compelling reason not to just use pyreqwest, so pyreqwest is the only backend implemented today, but, if there's demand for an httpx/aiohttp/niquests backend, the author is happy to consider adding one.

Performance

lothc is built on top of the awesome pyreqwest package — a Rust-based HTTP client for Python. lothc then adds some nice abstractions on top of that (discussed in the other sections of these docs): typed decode targets, retries, SSE, streaming, and so on. Because of that, there's a bit of overhead compared to using pyreqwest directly — however, this is almost negligible, as can be seen below: the heavy lifting still happens in Rust, so lothc stays far closer to pyreqwest's throughput than to any pure-Python HTTP library's.

HTTP client throughput race — lothc and pyreqwest finish in well under a fifth of a second, other libraries take much longer

Benchmarked with perf.py against a tiny Rust-based static JSON server, 10,000 requests at concurrency 100 — including lothc's fully-typed decode targets (response_data_type= a msgspec Struct or a pydantic BaseModel), not just raw bytes or an untyped dict. The object handed back from those runs isn't just parsed JSON — it's a real, constructed, field-validated instance of your own type, and that validation cost is included in the numbers, not benchmarked around. Decoding into a real msgspec Struct even edged out the unvalidated dict path in this run. See Benchmarks for the full numbers behind the chart above.

Highlights

  • Type-safe I/O boundaries, not bolted on

    response_data_type gets you a real, constructed, field-validated pydantic BaseModel or msgspec Struct straight from the client, instead of the plain dict a .json() call leaves you to validate yourself. See Benchmarks for what that actually costs (usually nothing).

  • Typed decode targets

    Pick a pydantic BaseModel, a msgspec Struct, plain dict, or raw bytes (the default) per call. No cast-laden internals — every verb is built from paired @overloads, so every call site gets a precise static type.

  • Typed request bodies too

    json= isn't limited to a plain dict — pass a pydantic BaseModel or msgspec Struct instance directly and it's encoded for you. Pydantic and msgspec are decode and encode targets in lothc, not just decode.

  • Every verb

    get, get_result, post, put, patch, delete, head, download — plus sse, stream_get, and stream_post for streaming responses. See Verbs.

  • Typed and raw params/headers

    Pass a plain dict/Mapping, or a BaseModel/Struct for free per-field validation and None-field omission.

  • Streaming, both ways

    Raw chunks by default (unbuffered, safe for large binary bodies), or switch to newline- buffered, typed NDJSON-style decoding. See Streaming.

  • SSE support

    Decode targets accept a class, a pydantic TypeAdapter, or a prebuilt msgspec Decoder — so even discriminated-union event streams decode natively. See SSE.

  • Retries with real backoff

    Implemented as a real pyreqwest with_middleware hook. Honors Retry-After, defaults to the idempotent verbs. See Retries.

  • Authentication

    A static bearer_token, or bearer_auth for a token resolved fresh on every request — plus default_headers for anything else that needs to go out on every request. See Authentication.

  • Cookies, redirects, proxy

    An in-memory cookie jar, redirect control, and proxying. See Cookies, redirects & proxy.

  • A real error hierarchy

    HTTPTransportError/HTTPTimeoutError/HTTPConnectionError for failures with no response, and a separate HTTPResponseError for 4xx/5xx. See Error handling.

pydantic and msgspec are both optional — the library works with neither, either, or both installed.