Verbs¶
Every verb below exists on both HTTPClient and SyncHTTPClient. All examples assume:
GET¶
body = await client.get("items/7") # bytes by default
item = await client.get(
"items/7",
params={"q": "pikachu", "page": 2},
headers={"x-custom": "header-value"},
response_data_type=ItemModel,
)
Typed params and headers¶
params/headers also accept a BaseModel/Struct instead of a plain mapping — fields set to
None are omitted rather than sent as "None":
from msgspec import Struct
from pydantic import BaseModel
class SearchParams(BaseModel):
q: str
page: int
limit: int | None = None # omitted from the query string entirely, not sent as "None"
class SearchStructParams(Struct):
q: str
page: int
cursor: str | None = None # same omission behavior, msgspec Struct instead of BaseModel
result = await client.get(
"items", params=SearchParams(q="pikachu", page=1), response_data_type=SearchResult
)
result = await client.get(
"items", params=SearchStructParams(q="pikachu", page=1), response_data_type=SearchResult
)
headers works the same way for outgoing request headers — pass a BaseModel/Struct instead
of a dict[str, str] and get the same None-omission for free.
GET, with status and headers — get_result¶
Same signature as get, but returns a Result carrying the decoded body alongside the status
code and response headers:
result = await client.get_result("items/7", response_data_type=ItemModel)
result.data # ItemModel(id=7, name="item-7")
result.status # 200
result.headers # {"content-type": "application/json", ...}
result.request.method # "GET"
result.request.url # "https://api.example.com/items/7"
result.request.path # "/items/7"
result.request.host # "api.example.com"
result.request is the request actually sent — useful for logging, or for telling apart which
call a Result came from when you're juggling several. It reflects the target you asked for, not
necessarily the one a final response came from if redirects were followed.
Pass response_headers_type (a BaseModel/Struct) to get the response headers validated and parsed
too, via result.typed_headers. Header names are lowercased and - becomes _ before matching
against your type's field names, so a Content-Type response header maps onto a content_type
field:
class ItemHeaders(BaseModel):
content_type: str | None = None
result = await client.get_result(
"items/7", response_data_type=ItemModel, response_headers_type=ItemHeaders
)
result.typed_headers.content_type # "application/json"
response_headers_type works the same way on head() — see below.
POST, PUT, PATCH¶
All three take the same body options — provide at most one of json, form, or content
(passing more than one raises ValueError):
await client.post("items", json={"name": "new-item"})
await client.put("items/7", json=ItemModel(id=7, name="replaced"))
await client.patch("items/7", json={"name": "renamed"})
json also accepts a BaseModel/Struct directly (serialized for you). content sends a raw
str/bytes body as-is.
Each has a _result variant too — post_result/put_result/patch_result — same body options,
but returning a Result alongside status and headers, exactly like get_result above (including
the same response_headers_type option):
result = await client.post_result("items", json={"name": "new-item"}, response_data_type=ItemModel)
result.data # ItemModel(...)
result.status # 200
Multipart forms and file uploads¶
form builds a real multipart/form-data body from a dict. Each value's type decides how
it's sent:
str/int— a plain form field.bytes— a form field too (no filename), for raw binary data that isn't a "file" as such.list/dict(or aBaseModel/Structinstance) — JSON-encoded as that one part's body, withContent-Type: application/jsonset automatically.- A file, in one of four shapes:
pathlib.Path— read and streamed from disk; filename is the path's own.name.(filename, bytes | Path | file_object)— an explicit filename paired with the content.(filename, bytes | Path | file_object, content_type)— same, plus an explicit content-type override.(Path, content_type)— keeps the path's own auto-derived filename, but overrides just the content-type.- An already-opened binary file object (anything
io.BufferedIOBase, e.g.open(path, "rb")or aBytesIO) also works directly, filename taken from.nameif the object has one. - A
tupleof any of the above repeats that field name once per element — multiple parts, all sharing the same name (a realmultipart/form-datacapability, not something most HTTP client libraries expose). Note this is atuplespecifically, not alist— alistvalue always means "JSON-encode me as one part," never "repeat."
By default, a file part's content-type is guessed from its filename's extension (via the stdlib
mimetypes module) unless you gave one explicitly. Pass infer_mime_type_from_file_extension=False
to disable guessing — the part then goes out with no Content-Type header at all unless you set
one explicitly.
from pathlib import Path
await client.post(
"upload",
form={
"note": "shiny",
"avatar": b"raw-bytes-field", # a field, not a file (no filename)
"manual": Path("pikachu-manual.pdf"), # a file, filename + content-type inferred
"photo": ("photo.png", b"...png-bytes..."), # a file, explicit filename, mime inferred
"scan": ("scan.bin", b"...bytes...", "application/pdf"), # explicit content-type override
"tags": ["shiny", "starter"], # one part, JSON-encoded array body
"photos": (("a.png", b"..."), ("b.png", b"...")), # two parts, both named "photos"
},
)
DELETE¶
await client.delete("items/7") # bytes by default
item = await client.delete("items/7", response_data_type=ItemModel)
delete_result mirrors get_result too — same response_data_type/response_headers_type
options, returning a Result instead of the bare decoded body.
HEAD¶
Headers-only — no body is ever decoded, so there's no response_data_type:
Same response_headers_type option as get_result, via result.typed_headers.
Downloading large bodies — download¶
get()'s default bytes return is fine for the small JSON bodies this library is designed
around, but for something genuinely large (a presigned S3 GET URL, a big export) it costs extra
memory copies internally. download() is a get-shaped verb built to avoid that:
body = await client.download("exports/large-file.csv") # bytes, ~1/3 the peak memory of get()
await client.download("exports/large-file.csv", dest=Path("large-file.csv")) # None returned
With no dest, the body still ends up fully in memory as bytes, just streamed into one buffer
instead of copied several times along the way. Pass dest: Path to stream straight to disk
instead — memory then stays O(chunk size) regardless of how large the body is, and the call
returns None rather than the body. download() only ever does a GET; there's no
json/form/content body option and no response_data_type — the response is always raw
bytes, on disk or in memory. Same params/headers/error_for_status as every other verb.