Error Handling

Minimal-activitypub raises typed exceptions so you can respond precisely to different failure modes. All exceptions inherit from ActivityPubError, making it easy to catch everything at once or be selective.

Exception Hierarchy

ActivityPubError          ← base class for all library exceptions
├── NetworkError          ← connection / transport failure
├── ApiError              ← generic API error (not commonly raised directly)
├── ServerError           ← 5xx responses
└── ClientError           ← 4xx responses (catch-all for unmapped status codes)
    ├── UnauthorizedError     401 — bad or missing access token
    ├── ForbiddenError        403 — token lacks the required scope
    ├── NotFoundError         404 — resource does not exist
    ├── ConflictError         409 — action conflicts with current state
    ├── GoneError             410 — resource has been deleted
    ├── UnprocessedError      422 — server rejected the request parameters
    └── RatelimitError        429 — rate limit exceeded

Import exceptions directly:

from minimal_activitypub import (
    ActivityPubError,
    NetworkError,
    ServerError,
    ClientError,
    UnauthorizedError,
    ForbiddenError,
    NotFoundError,
    RatelimitError,
    UnprocessedError,
)

Exception Attributes

Every exception exposes the following attributes:

Attribute Type Description
status_code int \| None HTTP status code (e.g. 401)
reason_phrase str \| None HTTP reason phrase (e.g. "Unauthorized")
message str \| None Error text extracted from the API response body
endpoint str \| None Full URL that was requested (includes query parameters)
method str \| None HTTP verb used (e.g. "GET", "POST")
request_id str \| None Server-assigned request ID from the X-Request-Id response header, if the server included one
response_text str \| None Raw response body, truncated to 500 characters
occurred_at Instant Timestamp when the exception was raised (whenever.Instant)

NetworkError is raised via exception chaining (raise NetworkError from transport_error) so status_code, endpoint, and related attributes will be None; use __cause__ to access the underlying httpx error.

Note on request body parameters: Request body content (status text, media descriptions, etc.) is intentionally not captured in exceptions to avoid logging sensitive or user-authored content. Query parameters are accessible via endpoint.

Common Patterns

Catch specific exceptions

from minimal_activitypub import RatelimitError, UnauthorizedError, NetworkError
from minimal_activitypub.client_2_server import ActivityPub
from httpx import AsyncClient

async def post_safely(ap: ActivityPub, text: str) -> dict | None:
    try:
        return await ap.post_status(text)
    except UnauthorizedError:
        print("Access token is invalid or expired — re-authenticate.")
    except RatelimitError as e:
        print(f"Rate limited at {e.endpoint}. Reset at: {ap.ratelimit_reset}")
    except NetworkError as e:
        print(f"Connection failed: {e.__cause__}")
    return None

Inspect error context

from minimal_activitypub import ActivityPubError

async def call_with_logging(ap: ActivityPub) -> list:
    try:
        return await ap.get_home_timeline()
    except ActivityPubError as e:
        print(f"[{e.occurred_at}] {e.method} {e.endpoint} → {e.status_code} {e.reason_phrase}")
        if e.request_id:
            print(f"  Server request ID: {e.request_id}  (quote this when reporting to instance admin)")
        if e.message:
            print(f"  API message: {e.message}")
        raise

Handle 422 Unprocessable Entity

Mastodon returns 422 when request parameters are rejected — for example posting an empty status or providing an invalid visibility value.

from minimal_activitypub import UnprocessedError

try:
    await ap.post_status("")          # empty status
except UnprocessedError as e:
    print(f"Rejected: {e.message}")   # e.g. "Validation failed: Text can't be blank"

Handle rate limiting

Rate limit metadata is tracked on the ActivityPub instance after every response. When RatelimitError is raised, ap.ratelimit_reset tells you when the limit resets.

import asyncio
from whenever import Instant
from minimal_activitypub import RatelimitError

async def post_with_backoff(ap: ActivityPub, text: str) -> dict:
    try:
        return await ap.post_status(text)
    except RatelimitError:
        reset = ap.ratelimit_reset
        if reset is not None:
            wait = max(0, (reset - Instant.now()).in_seconds())
            print(f"Rate limited. Waiting {wait:.0f}s until reset.")
            await asyncio.sleep(wait + 1)
        return await ap.post_status(text)

Catch all library errors

from minimal_activitypub import ActivityPubError

try:
    timeline = await ap.get_public_timeline()
except ActivityPubError as e:
    print(f"Error: {e}")   # str(e) → "404 Not Found at https://instance/api/v1/... — Record not found"

Common Error Scenarios

401 Unauthorized

Raised when the access token is missing, expired, or revoked.

from minimal_activitypub import UnauthorizedError

try:
    await ap.get_home_timeline()
except UnauthorizedError:
    # Re-authenticate and obtain a new token
    new_token = await get_fresh_token()

403 Forbidden

Raised when the token is valid but lacks the required OAuth scope (e.g. trying to write:statuses with a read-only token).

404 Not Found

Raised when the requested resource does not exist — for example, deleting a status that has already been deleted, or looking up a non-existent account ID.

410 Gone

Raised when a resource existed but has been permanently removed. Unlike 404, this signals that retrying will never succeed.

422 Unprocessable Entity

Raised when the server rejects the request parameters. The message attribute contains the server's explanation (e.g. "Validation failed: Visibility is not included in the list").

429 Rate Limited

Raised when the request rate limit is exceeded. Check ap.ratelimit_remaining proactively or handle RatelimitError reactively. Takahe and Pleroma instances may not return rate limit headers; the library defaults to a 5-minute reset window in that case.

5xx Server Error

ServerError covers all 5xx responses. These indicate a problem on the instance's side. The response_text attribute contains the raw response body (truncated to 500 characters) which may include a stack trace or error page useful for reporting to instance admins.

Network errors

NetworkError wraps low-level httpx transport errors (connection refused, DNS failure, timeout). Access the original exception via __cause__:

from minimal_activitypub import NetworkError

try:
    await ap.verify_credentials()
except NetworkError as e:
    original = e.__cause__
    print(f"Transport error: {type(original).__name__}: {original}")