Understanding error handling in the Python SDK
Introduction
Every exception the SDK defines is importable from infrahub_sdk.exceptions. That is the supported
import path, and the only one: the modules beneath it are internal and their layout may change. A
call can still raise a built-in such as ValueError for an argument the SDK rejects before it builds
a request; what follows covers the exceptions the SDK defines.
from infrahub_sdk.exceptions import ApiError, GraphQLError, UniquenessViolationError
When Infrahub rejects a request, it describes the failure with a stable code from its error catalogue, an HTTP status, and a typed payload. The SDK turns that description into an exception class of its own, with the payload's fields as directly typed attributes, so branching on a specific failure never means matching words in a message.
The hierarchy
Error every exception the SDK raises
└── ApiError the server rejected the request
├── AuthenticationError the request was rejected before it ran
└── GraphQLError a failure read from an `errors` array
├── NodeNotFoundError
├── BranchNotFoundError
├── SchemaNotFoundError
├── UniquenessViolationError
├── UndefinedError
└── ... a class for most catalogued codes
The tree is plain: no class has more than one parent, and AuthenticationError and GraphQLError
are siblings. Anything the SDK raises without a server behind it - a timeout, an unreadable file, a
malformed query - stays under Error and outside ApiError.
Most catalogued codes have a class; the three authentication codes deliberately do not, for the
reason below. AuthenticationError covers the responses the SDK rejected before the query ran,
which is usually an HTTP 401 or 403 but also a token refresh that failed on any other status.
Catching by branch, or by code
| Intent | Clause |
|---|---|
| Anything the server rejected, on either transport | except ApiError |
| Any GraphQL-path failure | except GraphQLError |
| Any request the SDK saw rejected before it ran | except AuthenticationError |
| One specific catalogued failure | except UniquenessViolationError, and so on per code |
| Anything the SDK raises | except Error |
Catching the specific class is the shortest route to the payload, because its attributes are typed exactly as the catalogue declares them and a required field needs no guard:
- Async
- Sync
from infrahub_sdk.exceptions import ApiError, UniquenessViolationError
try:
await node.save()
except UniquenessViolationError as exc:
print(exc.node_kind, exc.fields)
except ApiError as exc:
print("some other failure:", exc.code)
from infrahub_sdk.exceptions import ApiError, UniquenessViolationError
try:
node.save()
except UniquenessViolationError as exc:
print(exc.node_kind, exc.fields)
except ApiError as exc:
print("some other failure:", exc.code)
Both clients raise the same class with the same attributes for the same failure.
The three authentication codes
AUTHENTICATION_REQUIRED, TOKEN_EXPIRED, and PERMISSION_DENIED have no class of their own. They
are the codes a server reports for the failures it rejects a request on, so they are the ones that
routinely arrive on either transport, and each transport already has a class that existing code
depends on:
| Arrival | Class raised | exc.code |
|---|---|---|
| A real 401 or 403, when the failure escapes before the query runs | AuthenticationError | the catalogue code |
Inside an HTTP 200 errors array, when a resolver raised it | GraphQLError | the catalogue code |
Any of the three can arrive either way, so the arrival path is a property of how the server happened
to fail rather than of the code. To handle one of them whichever way it arrived, catch ApiError and
test the code:
except ApiError as exc:
if exc.code == "TOKEN_EXPIRED":
...
AuthenticationError descends from ApiError, so an except ApiError clause placed first makes any
later except AuthenticationError unreachable.
Reading a caught error
These are readable on every ApiError, including one raised with no server response behind it, so
inspecting them never needs a guard for a missing attribute:
| Attribute | Contract |
|---|---|
code | The catalogue code string, or None. Never an integer. None means no code was resolved: a server predating the catalogue, a REST failure, an error carrying no extensions, or an integer code on the wire, which is what a pre-catalogue server puts there. |
http_status | The status the failure declares, or None. This is metadata about the failure, not the status the transport observed: a catalogued data error arrives as HTTP 200. Where the envelope declares none, a class that declares its own supplies it - which the generated classes do and the three lookup-miss classes below do not, since they are also raised with no server behind them. |
extensions | The raw extensions mapping of the governing error, or None. |
errors | The complete server error list, in the order the server sent it. Empty for a raise the SDK decided on its own. |
query, variables | The GraphQL query and variables the failed request carried, where the exception recorded them. None means the request was not recorded rather than that there was none - an authentication failure is observed at the transport, which has neither to hand. |
The payload's fields are not on the base class. Each catalogued class carries its own, typed as the
catalogue declares them. NodeNotFoundError, BranchNotFoundError, and SchemaNotFoundError are the
exception: the SDK also raises those three on its own, for a lookup that returned nothing and for the
REST 404 behind a missing file, so their attributes may be unpopulated. Test exc.code is not None to
tell a server-reported raise from an SDK one.
The raw payload stays in exc.extensions["data"] for anything that forwards a failure verbatim.
Messages
A failure the catalogue describes carries a message naming the code and the server's own words, with no query text:
UNIQUENESS_VIOLATION: Node of kind TestPerson already has name 'John'
Where the catalogue provides them, those words name the failing action and the resource kind, so that
detail now appears in logs and CLI output in place of the query text that used to be there. The query
itself stays readable on exc.query.
A failure the catalogue does not describe keeps the message it has always had, query text and full
error list included. Since a current server codes every error it reports, falling back to
UNDEFINED_ERROR where its catalogue has no entry, exc.code is not None is not the test for whether
the server described a failure. code_names_the_failure(exc.code) is, and it is importable from
infrahub_sdk.exceptions.
UNDEFINED_ERROR has a class like any other code, UndefinedError, so a failure the server could not
describe still raises a subclass of GraphQLError rather than GraphQLError itself. Match on
except GraphQLError or on exc.code, never on type(exc) is GraphQLError.
Where a response carries several errors, the first one determines the class raised and is the only one
named beside the code. The complete list stays on exc.errors, in the order the server sent it.
Talking to any server version
Any SDK version talks to any server version. Once the SDK has a decoded response in hand, reading the
catalogue out of it never raises: an unknown code, a payload that does not match what the catalogue
declares, and an envelope from a server that predates it all degrade to a class the caller can catch.
A body the SDK cannot decode as JSON at all is a separate failure and still raises JsonDecodeError,
before any of this runs.
| Situation | Behaviour |
|---|---|
A code this SDK has a class for, read from an errors array | That class, built from the payload the response carried |
| Any code at all, on a request the SDK saw rejected before it ran | AuthenticationError, with exc.code set |
| A code this SDK has never heard of | The generic class for the transport, with exc.code set to the string the server sent |
| A known code whose payload gained a field | The unknown field is ignored |
A server predating the catalogue, or an error with no extensions | exc.code is None, and the message is the one that version of the SDK has always produced |
| A payload that does not match what the catalogue declares | The generic class for the transport, with the code still readable |
Every fallback is logged at debug level on the infrahub_sdk logger, naming the code where the
response carried one, so an SDK meeting a newer server is diagnosable without a debugger.
Which generic class a fallback lands on follows the transport the SDK observed, never the status the
code declares. A code read from an errors array raises GraphQLError even when it declares 401.
Two clauses that now catch more
Existing except clauses keep catching everything they caught before. Two of them now catch more.
except GraphQLError also catches node, branch, and schema lookup misses that involved no GraphQL
request at all, because those three classes are re-rooted under it. Code that relied on them escaping
such a clause should catch the specific class ahead of it, as an ordered except ladder already must.
A ladder that handles one of those three specifically now sees server-reported failures arrive there
as well as the ones the SDK decides on its own. That is the point of binding a code to a class, and exc.code is not None separates the two.