Skip to main content

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​

IntentClause
Anything the server rejected, on either transportexcept ApiError
Any GraphQL-path failureexcept GraphQLError
Any request the SDK saw rejected before it ranexcept AuthenticationError
One specific catalogued failureexcept UniquenessViolationError, and so on per code
Anything the SDK raisesexcept 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:

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)

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:

ArrivalClass raisedexc.code
A real 401 or 403, when the failure escapes before the query runsAuthenticationErrorthe catalogue code
Inside an HTTP 200 errors array, when a resolver raised itGraphQLErrorthe 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:

AttributeContract
codeThe 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_statusThe 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.
extensionsThe raw extensions mapping of the governing error, or None.
errorsThe complete server error list, in the order the server sent it. Empty for a raise the SDK decided on its own.
query, variablesThe 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.

SituationBehaviour
A code this SDK has a class for, read from an errors arrayThat class, built from the payload the response carried
Any code at all, on a request the SDK saw rejected before it ranAuthenticationError, with exc.code set
A code this SDK has never heard ofThe generic class for the transport, with exc.code set to the string the server sent
A known code whose payload gained a fieldThe unknown field is ignored
A server predating the catalogue, or an error with no extensionsexc.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 declaresThe 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.