Skip to content

feat: raise typed per-code exceptions from the error catalogue - #1373

Merged
ogenstad merged 12 commits into
pog-error-catalogue-IFC-3034from
pog-raise-per-code-IHS-295
Sep 29, 2026
Merged

ogenstad merged 12 commits into
pog-error-catalogue-IFC-3034from
pog-raise-per-code-IHS-295

Conversation

@ogenstad

@ogenstad ogenstad commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Why

Note: Once this is merged into the feature branch some of the change log entries will be consolidated and I'll run some real world testing once the various components are in place.

The SDK cannot tell one server failure from another without reading message
strings. Infrahub publishes an error catalogue and already generates TypeScript
bindings for the frontend from it; this is the SDK half.

Every catalogued code now reaches the caller as an exception class carrying its
payload as directly typed attributes, so branching on one specific failure never
means matching words in a message.

Non-goals: the generator itself, which lives in opsmill/infrahub PR opsmill/infrahub#10678.

IHS-295. Covers T063-T064 and T066-T077.

What a caller will notice

  • A catalogued failure raises its own class. UNIQUENESS_VIOLATION raises
    UniquenessViolationError with .node_kind and .fields populated from the
    payload, and so on per code. All nine descend from GraphQLError, so no
    existing except clause stops catching what it catches today.
  • The three authentication codes deliberately have no class.
    AUTHENTICATION_REQUIRED, TOKEN_EXPIRED and PERMISSION_DENIED are the
    codes a server reports for failures it rejects a request on, so they routinely
    arrive on either transport, and each transport already has a class existing
    code depends on. Catch ApiError and test exc.code to handle one whichever
    way it arrived.
  • query and variables are readable on every ApiError. Reading either
    off an AuthenticationError previously raised AttributeError — and that is
    the clause recommended for the three codes above, so it is where they had to
    exist. None means the request was not recorded, not that there was none.
  • Exceptions survive pickle and copy.deepcopy. Python rebuilds an
    exception by calling its class with the message as a lone positional argument.
    The generated classes take keyword-only payload fields, so that raised
    TypeError and lost the server's failure; GraphQLError takes the error list
    first, so the message was silently rebuilt as a placeholder. Both are fixed at
    the root of the GraphQL branch.
  • http_status falls back to the class's declared status where the envelope
    omits one, and every degradation is logged at debug level on the
    infrahub_sdk logger — including the unknown-code case, which is the one the
    cross-version guarantee is actually about.

What changed

  • infrahub_sdk/exceptions/catalogue.py — generated, committed, not to be
    hand-edited. Nine exception classes, fifteen payload models, and a dispatch
    resolving a code and its payload to a concrete exception.
  • infrahub_sdk/exceptions/factory.py — resolves a code through that generated
    dispatch, which validates each payload against its own model. The hand-rolled
    stand-in payload dataclasses it replaces are gone, so the factory assembles no
    attribute itself. A payload that violates the catalogue degrades to the
    generic class for the transport with the code still readable.
  • infrahub_sdk/exceptions/base.py — __reduce__ on GraphQLError, query
    and variables on ApiError, and a shared default-message helper.
  • infrahub_sdk/exceptions/__init__.py — re-exports the nine classes by
    name
    . Not from .catalogue import *: a wildcard would also promote the
    payload models, both lookup maps and the dispatch helper onto the package
    surface, where a name is a stability promise.
  • docs/.../topics/error_handling.mdx — new topic page: the hierarchy, catching
    by branch versus by code, the cross-version guarantees, and the two deliberate
    broadenings.
  • Tests: one fixture and one case per catalogue code, checked against the
    bindings' own code map so the set cannot fall behind; both clients driven over
    both transports; two catalogue-marked integration cases against a real
    server; pickle/deepcopy round trips for all fifteen codes.

The transport rule

The rule worth reviewing closely, because it is what makes the asymmetry work:
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; a response the SDK
rejected before the query ran raises AuthenticationError whatever code it
carries. Every catalogued class descends from GraphQLError, so raising one on
the authentication branch would put that failure out of reach of
except AuthenticationError. TestTheFallbackFollowsTheObservedTransport pins
both directions.

How to review

catalogue.py is machine output — review the generator in
opsmill/infrahub PR #10678 instead, and read this copy only to confirm its
shape: 9 classes, 15 models, no class for the three 401/403 codes.

The hand-written core is infrahub_sdk/exceptions/factory.py; everything else
follows from it.

One integration case skips where the server's catalogue has no entry for a
uniqueness violation, which released Infrahub does not yet have. It skips only
when the server described nothing; any code it did describe falls through and
fails.

Generated by `uv run invoke backend.generate` from the Infrahub branch that
adds the generator. Nine exception classes, fifteen payload models, and a
dispatch that resolves a catalogue code and its payload to a concrete
exception. The three codes the server reports as 401 or 403 get a payload
model but no class: the SDK routes those by the response it saw.

This file is generated and must not be hand-edited. Infrahub owns it, the
same way it owns the schema models and the protocols.
infrahub_sdk.exceptions is the supported import path, so the nine classes
the catalogue generates belong on it. They are listed by name rather than
star-imported: a wildcard would also promote fifteen payload models, both
lookup maps and the dispatch helper to the package surface, where a name
is a stability promise. Those stay importable from the catalogue module,
which is where the factory wants them.

The layering test learns that the generated module sits above base and
below factory, and the public-names snapshot gains the nine classes, so
the surface stays a deliberate choice rather than a side effect.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: a7e3ffb
Status: ✅  Deploy successful!
Preview URL: https://add834ac.infrahub-sdk-python.pages.dev
Branch Preview URL: https://pog-raise-per-code-ihs-295.infrahub-sdk-python.pages.dev

View logs

@codecov

codecov Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

@@                       Coverage Diff                        @@
##           pog-error-catalogue-IFC-3034    #1373      +/-   ##
================================================================
+ Coverage                         86.49%   86.68%   +0.18%     
================================================================
  Files                               151      152       +1     
  Lines                             14497    14694     +197     
  Branches                           1987     1982       -5     
================================================================
+ Hits                              12539    12737     +198     
  Misses                             1388     1388              
+ Partials                            570      569       -1     
Flag Coverage Δ
integration-tests 42.32% <11.06%> (-0.38%) ⬇️
python-3.10 60.75% <33.19%> (-0.47%) ⬇️
python-3.11 60.75% <33.19%> (-0.47%) ⬇️
python-3.12 60.75% <33.19%> (-0.49%) ⬇️
python-3.13 60.73% <33.19%> (-0.49%) ⬇️
python-3.14 60.73% <33.19%> (-0.49%) ⬇️
python-filler-3.12 23.00% <66.80%> (+0.70%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/exceptions/__init__.py 100.00% <100.00%> (ø)
infrahub_sdk/exceptions/base.py 94.71% <100.00%> (+0.23%) ⬆️
infrahub_sdk/exceptions/catalogue.py 100.00% <100.00%> (ø)
infrahub_sdk/exceptions/factory.py 98.29% <100.00%> (+0.56%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread infrahub_sdk/exceptions/catalogue.py
Comment thread infrahub_sdk/exceptions/catalogue.py
Comment thread tests/unit/sdk/test_exceptions_public_names.py
The GraphQL factory now resolves a catalogue code through the generated
bindings' own dispatch, which validates the payload against that code's model
and hands it to the class's `from_payload`. The hand-written stand-in payload
dataclasses and the three-way branch that read them go away with it, so the
factory assembles no attribute itself and every catalogued code - not only the
three adopted ones - reaches a class carrying its payload as typed attributes.

A payload that violates what the catalogue declares falls back to the generic
class for the transport, with the code still readable and the raw extensions
retained. Which generic class it falls back to follows the transport the SDK
observed, never the status the code declares: the GraphQL branch for anything
read from an `errors` array, the authentication branch only for a response the
SDK saw as 401 or 403. That is the only rule under which the three
authentication codes, which deliberately have no class of their own, reach the
class an existing `except` clause expects.

A declared HTTP status is now assigned only when the envelope carried one, so a
generated class keeps the status the catalogue gave it. A class built from its
payload alone carries the GraphQL placeholder message; where the failure was not
described, the factory replaces that with the message the call site has always
produced rather than leaving a sentence naming no query and no errors.

Tests cover every code in the bindings, checked against the bindings' own code
list so the set cannot fall behind: the class raised, each promoted attribute's
value, and the code and status, with no case reading a message. Both clients are
driven over both transports and the file-upload path, and two integration cases
prove the envelope against a real server. The uniqueness case skips where the
server's catalogue has no entry for that failure yet.
@github-actions github-actions Bot added the type/documentation Improvements or additions to documentation label Sep 18, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 26 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread docs/docs/python-sdk/topics/error_handling.mdx Outdated
Comment thread docs/docs/python-sdk/topics/error_handling.mdx Outdated
Comment thread docs/docs/python-sdk/topics/error_handling.mdx Outdated
Comment thread docs/docs/python-sdk/topics/error_handling.mdx Outdated
Comment thread docs/docs/python-sdk/topics/error_handling.mdx Outdated
Comment thread tests/integration/test_infrahub_client_sync.py
Comment thread dev/specs/ifc-3034-error-catalogue/tasks.md
Comment thread tests/integration/test_infrahub_client.py Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/tasks.md
Comment thread changelog/+typed-per-code-exceptions.added.md Outdated
Two of the five documented fallbacks reached the caller silently: a code these
bindings have no class for, and an error carrying no `extensions` at all. The
first is the cross-version case the contract names by hand - an SDK meeting a
newer server - so the promise that every fallback is diagnosable from a debug
log was false exactly where it was most needed. Resolution now logs when no
class is bound to a code, the unresolved-code log fires whatever the reason,
and both log the whole `extensions` mapping rather than a `code` that is
usually absent. The payload-mismatch log carries the validation error itself,
which names the offending field.

The conditional that lets a generated class keep its declared status was
unpinned: every captured envelope declares the status its code's class already
carries, so removing the conditional left the suite green. Tests now drive the
three shapes that tell the sources apart - an envelope omitting the status, one
declaring a different status, and one of the three lookup-miss classes, which
declare none and so stay None. The contract said a class's declared status
fills in an omitted one without that carve-out; it now names it.

The integration gate for the uniqueness case skipped on the code under test, so
a regression in the binding it exists to prove would have gone green, and the
message pattern accepted the uncatalogued text as well. It now skips only where
the server did not describe the failure at all, which is the one tolerable
reason, and asserts the code outright otherwise.

The topic page claimed a class per catalogued code where three have none, named
no class for `UNDEFINED_ERROR` though it now raises one, described
`AuthenticationError` as an observed 401 or 403 when a failed token refresh
reaches it on any status, left the cross-version table's first row unqualified
by transport, and omitted the integer-code case from the `code` row.
Python rebuilds an exception by calling its class with the message as a lone
positional argument, and neither shape on this branch can accept that.
`GraphQLError` takes the server's error list first, so the call filed the
message under `errors` and the message a caller read came back as the
placeholder built from it - silently, since nothing raised. The per-code
classes take their payload fields as required keyword arguments, the shape that
lets a caller read one without a guard, so the same call raised a TypeError from
inside the SDK and lost the server's failure altogether.

`__reduce__` on `GraphQLError` reconstructs without replaying the constructor:
the instance is allocated, `args` restored explicitly because it lives on the
exception rather than in `__dict__` and `str()` reads it, and the rest of the
state applied over it. Every subclass inherits this, the generated classes
included, so the type, the message, the promoted payload attributes and the
whole envelope come back as they went in and an `except` clause still catches a
restored exception by its own class.

Fixing it here rather than in the generated bindings is deliberate.
`GraphQLError` is hand-written and carries the defect independently, so this
module had to change either way; emitting a second copy per generated class
would leave two implementations of one rule in two repositories with nothing
checking they agree.

The guard covers all fifteen codes through both `pickle` and `deepcopy`, and
fails on twenty-nine of its thirty-one cases with the fix removed.
Telling a class with a sentence of its own from one carrying only the GraphQL
placeholder was done by comparing against a module constant snapshotted at
import. That is a string identity spanning two repositories: the placeholder is
built in the SDK's hand-written base, and the classes that carry it are
generated from Infrahub. A generated `from_payload` that ever passed a query or
an error list would stop matching it, and the branch would flip with nothing to
catch it.

Comparing against the default for what the instance actually holds asks the same
question of the object instead. It is equivalent at the call site, where the
query and errors are still unset, and stays correct if that changes.

Three tests went with it. One asserted a strict subset of the per-code table two
definitions above it; three more repeated rows of that table for the adopted
classes, whose distinct claim - that a client-side raise carries no code - is
covered by the test that remains; and two client-parity cases duplicated the
pre-existing upload and 403 coverage, which spans both statuses rather than one.
Dropping the last two removes the upload flag and the nested branch in that
test, leaving it to make the claim only it makes: a promoted payload attribute
reaches the caller the same way from either client.

The docstrings trimmed here named a call site, argued against a shape that was
not taken, or restated the line below them. One in the facade, echoed in its
test, said the catalogue's lookup maps were the factory's business; the factory
imports only the dispatch helper.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/integration/test_infrahub_client.py Outdated
Comment thread tests/integration/test_infrahub_client_sync.py Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
Comment thread changelog/+graphql-error-survives-serialisation.fixed.md Outdated
Tightening the match to the two code names closed the path the skip exists to
serve. A server that reports no catalogue code messages the exception with the
text the call site falls back to, which names neither code, so the raise failed
on DID NOT MATCH before the skip could run - while the skip beside it still
claimed to tolerate exactly that server. Matching the fallback text instead
covers both undescribed shapes at once, since an `UNDEFINED_ERROR` carries that
same text, and a failure the server described as some other code matches
neither alternative and fails, which is what the assertions are for.

The permission-denied fixture claimed to be a captured response but sent `kind`
where the catalogue declares `resource_kind`; the payload model ignores extras,
so validating it dropped the resource name and left the field None. The server
builds that payload by dumping the catalogue's own model, so the fixture was
hand-authored rather than recorded - the failure mode its own README warns
about.

Two claims corrected. The status row said the lookup-miss classes are raised
with no server behind them, which the REST 404 the file handler turns into a
`NodeNotFoundError` contradicts; no catalogue code is what they lack. And the
serialisation note said those classes were unaffected: their attributes and
`str()` did survive, but `args` came back as the class's default sentence
rather than the message raised, so only `AuthenticationError` was untouched.
`query` and `variables` were declared on the GraphQL branch alone, so reading
either off an `AuthenticationError` raised AttributeError. That is the clause
the SDK points callers at for the three authentication codes, since each can
arrive either on a real 401 or 403 or inside an `errors` array, so it is the
one place the attributes had to exist. Declaring them on `ApiError` beside
`code`, `http_status`, `extensions` and `errors` - which are already there for
this reason - lets a clause that spans both transports read the whole envelope
without guarding.

`GraphQLError` keeps populating both from the request it was raised for and
loses only its redundant redeclaration. `None` now 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, and the contract says so rather
than leaving the reader to infer a query never existed.

The contract already promised this in its table while its own rationale scoped
it to `except GraphQLError`, and the topic page repeated the promise. Both now
describe what the code does.

Removing either attribute from the base fails three of the new cases.
The topic page made three claims the code contradicts. Parsing was said never
to raise, when a body that is not JSON raises `JsonDecodeError` before the
catalogue is read at all; the guarantee is about reading a decoded response, so
it now says which failures degrade and which one still raises. Every exception
the SDK raises was said to be importable from the package, when a call can
still raise a built-in for an argument rejected before a request is built. And
the three authentication codes were called the only ones that arrive on two
transports, which a test in this same branch disproves by driving a data code
through a real 401 - they are the codes a server reports for the failures it
rejects a request on, which is why they routinely arrive either way.

The typed-errors fragment opened by promising a class for every catalogue code
and explained two paragraphs later that three deliberately have none.

The spec set still described a re-export the implementation reversed. Four
places said the façade uses `from .base import *` and `from .catalogue import
*`; it lists both by explicit name, because a wildcard would promote the
payload models, the lookup maps and the dispatch helper onto the package
surface. The plan's lint row went further and recorded a justified `F403`
silencing, but no `per-file-ignores` entry was ever added, since without a
wildcard there is nothing to silence. T063 and T064 were also still unticked
though this branch performs both, and T074's transport condition omitted the
refresh path that reaches the authentication factory on any status.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/plan.md
Correcting the constitution row left two references to the same decision
standing elsewhere, because both name it by its symptom rather than its form:
the project structure block credited `pyproject.toml` with a per-file-ignore
for the façade's star imports, and T006 stood ticked for adding an `F403`/`F405`
entry. Neither exists - `per-file-ignores` carries only the entry for the
generated schema models - so the file this feature does touch carries the
`catalogue` marker instead, and T006 is struck through as superseded by the two
tasks that chose named re-exports.

Grepping for the wildcard alone missed both, which is why they outlived the row
that justified them. The terms around the decision - `F403`, `F405`, star
import, per-file-ignore - now turn up nothing stale. What remains is accurate:
two rejected alternatives in the research, the task that recorded the choice,
and a dated critique describing what was true when it was written.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="dev/specs/ifc-3034-error-catalogue/tasks.md">

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/tasks.md:78">
P3: The `catalogue` marker is not the only one this feature registers. `pyproject.toml`'s `[tool.pytest.ini_options] markers` list also registers `crossversion` ("Parses an envelope from a server whose version differs from the SDK's"), `malformed` ("Parses an envelope that violates the shape the SDK expects"), and `message` ("...in either the catalogued or the uncatalogued direction"), all of which describe this error-catalogue work. The supersede note is accurate on its core point (no `F403`/`F405` per-file-ignore was added), so drop the "only" claim or list the markers actually registered.</violation>
</file>

Comment thread dev/specs/ifc-3034-error-catalogue/tasks.md Outdated
`catalogue.__all__` is what every other check in this file reads to decide which
generated classes the façade owes, so a class the generator defines but omits
from it is invisible: the façade check compares two lists that have both already
lost the name, and the snapshot never had it to miss. The class ships defined
and unexported, and a caller importing it by name is the first to find out.

`base` has carried this guard since it gained an `__all__`, for the same reason
its docstring gives. The generated module is trusted the same way and had none,
and it is the side that changes without anyone editing it.
The T006 supersede note credited `pyproject.toml` with only the `catalogue`
marker, and the project structure block named the same one. The markers list
also carries `crossversion`, `malformed` and `message`, each registered by
this work and each required by `--strict-markers`.
@ogenstad
ogenstad marked this pull request as ready for review September 28, 2026 15:04
@ogenstad
ogenstad requested a review from a team as a code owner September 28, 2026 15:04
@ogenstad
ogenstad merged commit b02d2fc into pog-error-catalogue-IFC-3034 Sep 29, 2026
31 checks passed
@ogenstad
ogenstad deleted the pog-raise-per-code-IHS-295 branch September 29, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants