Skip to content

What's New

This page highlights the most important changes in the latest release of Arrest, ordered by priority and impact.


1. Unified Response[T] — a single response type for success and error paths

Breaking change — Arrest now returns a Response[T] for every HTTP status code, not just 2xx. Transport-level failures (timeouts, connection errors) raise the new RequestError (no status_code), while a server returning 404, 500, or any other status now produces a normal Response object you can inspect.

Previously you had to try/except ArrestHTTPException for non-2xx responses. Now you check properties on the response itself:

resp = await svc.users.get("/999")

if resp.is_client_error:
    print(f"Not found: {resp.status_code}, body: {resp.data}")

if resp.is_server_error:
    print(f"Server error: {resp.data}")

if resp.is_success:
    user: UserResponse = resp.data  # type-safe access

Convenience properties on Response[T]:

Property Range
is_success 200–299
is_redirect 300–399
is_client_error 400–499
is_server_error 500–599

Every Response[T] also carries:

Field Description
data The parsed/deserialized body (T)
status_code HTTP status code (int)
url Final request URL (httpx.URL)
elapsed Time taken for the response (timedelta \| None)
raw The underlying httpx.Response
request The httpx.Request that was sent

Migration guide:

# before (v0.1.x) — exceptions for non-2xx
try:
    resp = await svc.users.get("/123")
    print(resp.data)
except ArrestHTTPException as exc:
    print(f"Error: {exc.status_code}{exc.data}")

# after (latest) — Response for all status codes
resp = await svc.users.get("/123")
if resp.is_success:
    print(resp.data)
else:
    print(f"Error: {resp.status_code}{resp.data}")

Legacy compatibility mode:

If you prefer the old behaviour, enable raise_for_status at the Service, Resource, or per-call level. When enabled, non-2xx responses raise ArrestHTTPException (with status_code and data) just like in v0.1.x. Default is False (new behaviour).

# Service-level: all resources inherit
svc = Service(name="api", url="https://example.com", raise_for_status=True, ...)

# Resource-level (via add_resource)
svc.add_resource(user_resource, raise_for_status=True)

# Per-call (via request())
await svc.users.request(method="POST", path="/", raise_for_status=True)

Exception summary:

Scenario Default (False) raise_for_status=True
Transport failure (timeout, DNS, etc.) RequestError RequestError
Server responds 4xx / 5xx Response with is_client_error/is_server_error ArrestHTTPException
Server responds 2xx Response with is_success Response with is_success

2. H() helper — type-safe handler definitions

Defining handlers with raw tuples ("GET", "/", Request, Response) works but gives no IDE autocomplete and it's easy to forget the positional order. The new H() helper gives you keyword-argument clarity and full type-checking:

from arrest import H, Resource, GET, POST

user_resource = Resource(
    name="users",
    route="/users",
    handlers=[
        H(GET, "/"),
        H(POST, "/", request=NewUserRequest, response=UserResponse),
        H(GET, "/{user_id:str}", response=UserResponse, headers={"x-custom": "value"}),
    ],
)

H() accepts all the same fields as ResourceHandler:

def H(
    method: Methods,
    route: str,
    request: Any = None,
    response: Any = None,
    callback: Callable | None = None,
    *,
    headers: dict[str, str] | None = None,
) -> ResourceHandler: ...

The old tuple/dict syntax still works, so existing code won't break.


3. Form and File primitives for multipart/form-data and application/x-www-form-urlencoded

Arrest now supports form-encoded and multipart requests via two new field annotations: Form(...) and File(...) (imported from arrest.params).

Form fields (application/x-www-form-urlencoded)

from pydantic import BaseModel
from arrest import Resource, Service
from arrest.params import Form

class LoginRequest(BaseModel):
    username: str = Form(...)
    password: str = Form(...)

auth_resource = Resource(
    route="/auth",
    handlers=[
        ("POST", "/login", LoginRequest),
    ],
)

await svc.auth.post("/login", request=LoginRequest(username="alice", password="s3cret"))
# Content-Type: application/x-www-form-urlencoded
# Body: username=alice&password=s3cret

File uploads (multipart/form-data)

from arrest.params import File, Form
from arrest.types import UploadFile

class ProfileUpdate(BaseModel):
    display_name: str = Form(...)
    avatar: UploadFile = File(...)

# Upload from disk
with open("avatar.png", "rb") as f:
    avatar = UploadFile(filename="avatar.png", content_type="image/png", file=f)
    await svc.user.post("/profile", request=ProfileUpdate(
        display_name="Alice",
        avatar=avatar,
    ))

# Upload raw bytes
await svc.user.post("/profile", request=ProfileUpdate(
    display_name="Alice",
    avatar=b"raw image bytes...",
))

Cannot mix JSON and Form

A single request model cannot mix Body() (or unannotated) fields with Form() / File() fields. Arrest will raise a ValueError.


4. Documentation provider: MkDocs → Zensical

The documentation site has been migrated from MkDocs (with Material theme) to Zensical — a modern, fast documentation generator written in Rust.

  • Faster build times
  • Built-in search, code copy, and navigation features
  • Same Material-style look with accent color and light/dark palette
  • Configuration is now in zensical.toml at the project root

If you're contributing doc changes, use zensical serve instead of mkdocs serve.


5. Package manager: Poetry → uv

The project now uses uv as its package manager instead of Poetry.

Area Before (Poetry) After (uv)
Dependency file pyproject.toml + poetry.lock pyproject.toml + uv.lock
Build backend poetry.core hatchling
Install poetry add arrest uv add arrest
Dev setup poetry install --with dev,test,docs uv sync --group dev --group test --group docs

The pyproject.toml now uses PEP 735 dependency groups ([dependency-groups]) for organizing optional dependencies.


6. XML request and response support via pydantic-xml

Arrest now supports XML bodies using pydantic-xml's BaseXmlModel. When your request or response type subclasses BaseXmlModel, Arrest handles serialization and deserialization automatically:

from pydantic_xml import BaseXmlModel, attr, element

class XmlRequest(BaseXmlModel, tag="user"):
    name: str = element()
    email: str = element()

class XmlResponse(BaseXmlModel, tag="user"):
    id: int = attr()
    name: str = element()
    email: str = element()

res = Resource(
    route="/users",
    handlers=[("POST", "/xml", XmlRequest, XmlResponse)],
)

resp = await svc.users.post("/xml", request=XmlRequest(name="Alice", email="a@b.com"))
# Content-Type: application/xml
# resp.data is XmlResponse(id=42, name="Alice", email="a@b.com")

Note

Fields must use element() or attr() annotations. pydantic-xml is a required dependency (bundled with Arrest).


See also