Error handling
Every exception the SDK raises inherits from AtProtocolError. One except clause catches everything the SDK can throw:
from atproto.exceptions import AtProtocolError
try:
client.send_post(text='Hello')
except AtProtocolError as e:
print('Something went wrong:', e)
That is the floor, not the goal. The interesting distinctions are below it.
The hierarchy
AtProtocolError
βββ ModelError
β βββ ModelFieldNotFoundError
βββ RequestErrorBase
β βββ NetworkError
β β βββ InvokeTimeoutError
β βββ UnauthorizedError
β βββ BadRequestError
β βββ RequestException
β βββ RateLimitExceededError
βββ LoginRequiredError
βββ InvalidAtUriError
βββ InvalidNsidError
βββ InvalidCARFile
βββ DAGCBORDecodingError
- ModelError
Data did not validate against the model it was supposed to fit. Raised by get_or_create in strict mode, and by every namespace method when the serverβs response does not match the lexicon. ModelFieldNotFoundError is the narrower case of a field that is not there at all.
- RequestErrorBase
The base of everything that comes back from an HTTP request. Carries a
.response.- RateLimitExceededError
A 429. Subclasses
RequestException, so an existingexcept RequestExceptionstill catches it.- LoginRequiredError
Raised locally, before any request, by a method that needs a session when there is none. See Authentication.
Inspecting the failure
RequestErrorBase carries .response, a Response with success, status_code, content, and headers.
When the server answered with JSON, which an AT Protocol service does for every error it generates itself, content is an XrpcError with two fields: error, the machine-readable name, and message, the human-readable text. Those names are what you branch on:
from atproto.exceptions import RequestErrorBase
try:
client.login('my-handle.bsky.social', 'my-password')
except RequestErrorBase as e:
if e.response and e.response.content.error == 'AuthFactorTokenRequired':
... # ask the user for their 2FA code
str(e) already summarizes as <status> <error>: <message>, so logging the exception is usually enough.
Warning
.response is None when the failure happened before a response arrived: a DNS failure, a refused connection, a timeout. .content can also be raw bytes rather than an XrpcError when something in front of the PDS (a proxy, a CDN) generated the error page. Check before you reach into it.
from atproto import Client
from atproto.exceptions import (
BadRequestError,
InvokeTimeoutError,
RateLimitExceededError,
RequestErrorBase,
UnauthorizedError,
)
from atproto_client.models.common import XrpcError
USERNAME = 'example.com'
PASSWORD = 'hunter2' # noqa: S105 never hardcode your password in a real application
def describe(error: RequestErrorBase) -> str:
"""Summarize what the server said about a failed request."""
content = error.response.content if error.response else None
if isinstance(content, XrpcError):
return f'{content.error}: {content.message}'
# a non-JSON body (an HTML error page from a proxy, for example) arrives as raw bytes
return repr(content)
def main() -> None:
client = Client()
try:
client.login(USERNAME, PASSWORD)
except UnauthorizedError as e:
print('Login rejected:', describe(e))
return
except RateLimitExceededError as e:
# createSession is rate limited by handle: 30/5 min, 300/day
print('Too many logins. Retry at:', e.reset_at)
return
except InvokeTimeoutError:
print('The PDS did not answer in time.')
return
try:
client.com.atproto.identity.resolve_handle({'handle': 'not a handle'})
except BadRequestError as e:
print('Status code:', e.response.status_code)
print('Server said:', describe(e))
if __name__ == '__main__':
main()
Which status maps to which exception
A non-2xx response is turned into an exception by status code:
Status |
Exception |
|---|---|
400 |
|
401, 403 |
|
409, 413, 502 |
|
429 |
|
any other non-2xx |
Note what this means in practice:
409, 413, and 502 are
NetworkError, which is otherwise the transport-failure class. A swap-commit conflict (409) and a payload that is too large (413) land there because they are worth retrying the same way a 502 is.UnauthorizedErrorcovers both βyour token is wrongβ (401) and βyour token is fine but does not grant thisβ (403). Theerrorname in the body separates them. ABad token scopefrom a chat call means the app password lacks the direct-message grant.
Failures that never reach a status code are mapped from the underlying httpx exception instead: a timeout becomes InvokeTimeoutError, and any other network error becomes NetworkError. Both are raised without a response, so .response is None.
Timeouts
The SDK uses httpx, which enforces timeouts everywhere by default. The default is 5 seconds. A request that exceeds it raises InvokeTimeoutError.
Set your own by constructing the Request yourself and passing it to the client. Every keyword argument is forwarded to the underlying httpx client:
from atproto import Client, Request
from httpx import Timeout
request = Request() # default 5s everywhere
request = Request(timeout=Timeout(timeout=10.0)) # 10s everywhere
request = Request(timeout=None) # no timeouts at all
client = Client(request=request)
from atproto import AsyncClient, AsyncRequest
from httpx import Timeout
request = AsyncRequest() # default 5s everywhere
request = AsyncRequest(timeout=Timeout(timeout=10.0)) # 10s everywhere
request = AsyncRequest(timeout=None) # no timeouts at all
client = AsyncClient(request=request)
The usual reason to raise it is uploading blobs: videos, images, anything large. Five seconds is not much of a budget for a video.
Fine-tuning is documented in the HTTPX timeout guide. A custom Request is also where proxies and retry transports go. See HTTP and transport.
Rate limits
Rate-limited responses come back as 429, which is a RateLimitExceededError. It reads the budget out of the response headers for you:
from atproto.exceptions import RateLimitExceededError
try:
...
except RateLimitExceededError as e:
print('Reset at:', e.reset_at) # datetime in UTC, or None
limit, remaining and reset_at come from ratelimit-limit, ratelimit-remaining and ratelimit-reset; policy from ratelimit-policy; retry_after from retry-after. Each is None when the server did not send that header, and services differ in which ones they send: the PDS sends the ratelimit-* family, while the Jetstream archive answers a spent byte quota with retry-after. The raw headers are still on e.response.headers.
retry_after is seconds to wait, as a float. HTTP allows the header to carry either a number of seconds or a date; both come back as seconds from now, never negative.
RateLimitExceededError subclasses RequestException, which is what a 429 was raised as before, so code that already catches RequestException keeps working.
The limits that bite hardest are per-handle rather than per-request: createSession allows 30 requests per 5 minutes and 300 per day, so a script that constructs a fresh client and logs in on every tick will exhaust it. Keep one client alive, or reuse the session string. See Authentication.
Current limits are published at bsky.network/docs/rate-limits.
Where each packageβs exceptions live
Every package defines its own module, and atproto.exceptions re-exports all of them. Importing from atproto.exceptions always works; the per-package modules are listed here so you know where each name comes from.
atproto_core.exceptionsAtProtocolErrorand the parsing failures of the core primitives:InvalidNsidError,InvalidAtUriError,InvalidCARFile,DAGCBORDecodingError.atproto_client.exceptionsEverything on this page: the model and request errors.
atproto_identity.exceptionsResolution failures:
DidNotFoundError,DidPlcResolverError,DidWebResolverError,PoorlyFormattedDidError,UnsupportedDidMethodError,PoorlyFormattedDidDocumentError,UnsupportedDidWebPathError,AtprotoDataParseError.atproto_crypto.exceptionsKey and signature failures:
DidKeyErrorand its subclasses,InvalidCompressedPubkeyError,UnsupportedSignatureAlgorithmError.atproto_server.exceptionsJWT verification failures:
InvalidTokenErrorand its subclasses, includingTokenExpiredSignatureErrorandTokenInvalidSignatureError. See Building a feed generator.atproto_lexicon.exceptionsLexiconParsingError.atproto_subscription.exceptionsSubscriptionError and FrameDecodingError, the base of both streaming clients.
atproto_firehose.exceptionsFirehoseErrorandFirehoseDecodingError, aliases of the two above, kept for backward compatibility.atproto_jetstream.exceptionsJetstreamError,JetstreamDecodingError, plus two conditions worth handling separately:JetstreamConsumerTooSlowError(the server dropped you for falling behind) andJetstreamCursorTooOldError(your cursor is below the serverβs retention floor).