feat: raise typed per-code exceptions from the error catalogue - #1373
Conversation
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.
Deploying infrahub-sdk-python with
|
| 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 |
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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
All reported issues were addressed across 5 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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.
There was a problem hiding this comment.
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
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.
There was a problem hiding this comment.
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
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.
There was a problem hiding this comment.
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
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.
There was a problem hiding this comment.
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>
`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`.
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/infrahubPR opsmill/infrahub#10678.IHS-295. Covers T063-T064 and T066-T077.
What a caller will notice
UNIQUENESS_VIOLATIONraisesUniquenessViolationErrorwith.node_kindand.fieldspopulated from thepayload, and so on per code. All nine descend from
GraphQLError, so noexisting
exceptclause stops catching what it catches today.AUTHENTICATION_REQUIRED,TOKEN_EXPIREDandPERMISSION_DENIEDare thecodes 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
ApiErrorand testexc.codeto handle one whicheverway it arrived.
queryandvariablesare readable on everyApiError. Reading eitheroff an
AuthenticationErrorpreviously raisedAttributeError— and that isthe clause recommended for the three codes above, so it is where they had to
exist.
Nonemeans the request was not recorded, not that there was none.pickleandcopy.deepcopy. Python rebuilds anexception by calling its class with the message as a lone positional argument.
The generated classes take keyword-only payload fields, so that raised
TypeErrorand lost the server's failure;GraphQLErrortakes the error listfirst, so the message was silently rebuilt as a placeholder. Both are fixed at
the root of the GraphQL branch.
http_statusfalls back to the class's declared status where the envelopeomits one, and every degradation is logged at debug level on the
infrahub_sdklogger — including the unknown-code case, which is the one thecross-version guarantee is actually about.
What changed
infrahub_sdk/exceptions/catalogue.py— generated, committed, not to behand-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 generateddispatch, 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__onGraphQLError,queryand
variablesonApiError, and a shared default-message helper.infrahub_sdk/exceptions/__init__.py— re-exports the nine classes byname. Not
from .catalogue import *: a wildcard would also promote thepayload 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, catchingby branch versus by code, the cross-version guarantees, and the two deliberate
broadenings.
bindings' own code map so the set cannot fall behind; both clients driven over
both transports; two
catalogue-marked integration cases against a realserver; 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
errorsarray raises
GraphQLErroreven when it declares 401; a response the SDKrejected before the query ran raises
AuthenticationErrorwhatever code itcarries. Every catalogued class descends from
GraphQLError, so raising one onthe authentication branch would put that failure out of reach of
except AuthenticationError.TestTheFallbackFollowsTheObservedTransportpinsboth directions.
How to review
catalogue.pyis machine output — review the generator inopsmill/infrahubPR #10678 instead, and read this copy only to confirm itsshape: 9 classes, 15 models, no class for the three 401/403 codes.
The hand-written core is
infrahub_sdk/exceptions/factory.py; everything elsefollows 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.