Skip to main content

Understanding error handling in the Python SDK

Introduction

Every exception the SDK raises 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.

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 SDK observed HTTP 401 or 403
└── GraphQLError a failure read from an `errors` array
├── NodeNotFoundError
├── BranchNotFoundError
├── SchemaNotFoundError
├── UniquenessViolationError
└── ... one class per catalogued code

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.

Catching by branch, or by code

IntentClause
Anything the server rejected, on either transportexcept ApiError
Any GraphQL-path failureexcept GraphQLError
Any failure the SDK observed as HTTP 401 or 403except 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 only codes that reach the SDK on two different transports, 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, or an error carrying no extensions.
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.
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 where there was one, otherwise None.

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.

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, and parsing a response never raises.

SituationBehaviour
A code this SDK has a class forThat class, built from the payload the response carried
A code this SDK has never heard ofGraphQLError, or AuthenticationError on a 401 or 403, 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 with the code involved, 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.