Skip to content

feat: typed exceptions for the server's error catalogue - #1266

Open
ogenstad wants to merge 18 commits into
infrahub-developfrom
pog-error-catalogue-IFC-3034
Open

ogenstad wants to merge 18 commits into
infrahub-developfrom
pog-error-catalogue-IFC-3034

Conversation

@ogenstad

@ogenstad ogenstad commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

The SDK's half of Infrahub's error catalogue, end to end. Every catalogued code now reaches the
caller as an exception class carrying its payload as directly typed attributes, so branching on one
specific server failure never means matching words in a message.

Ref: IFC-3034. Related: IFC-2279 (spike), INFP-468 (backend catalogue), GitHub #7498 (out of scope).


👀 What actually needs review

Most of this PR has already been reviewed and approved in three separate pull requests (listed
below). What has not been through a review is small:

1. a2abeca — a CLI rendering fix (6 files, +44/-7) ← the only production-code change here

infrahub_sdk/ctl/cli_commands.py. Re-rooting the not-found classes under GraphQLError in #1363
put them inside the except GraphQLError that the transform path uses, where the renderer prints a
server error list a client-side lookup miss does not have, and its actual message goes unshown. They
now propagate to the shared handler, which has a branch for them.

That clause also raised typer.Abort, which is an ordinary exception with no branch in
handle_exception, so it reached the tail that prints Error: and dumps a traceback after the
command had already rendered the real failure. It exits instead.

Found by cubic on this PR; the same hazard was fixed in ctl/utils.py during #1363 but this second
site was missed.

2. 2af53e7 — retry-suite alignment (2 files, +23/-4)

tests/unit/sdk/test_retry.py plus the pyproject.toml pytest markers. Pushed straight to this
branch rather than through one of the PRs below.

3. beb37f1 — changelog de-duplication (2 files, +5/-5)

Three entries were repeating each other's sentences. Each passage now sits in the single entry that
owns it.


✅ Already reviewed and approved

PR What it landed Approved by
#1348 Parse the server error envelope onto the base exception classes. ApiError above both transports; code, http_status, extensions, errors on every server-reported failure. @polmichel
#1363 Unify and re-root the not-found classes under GraphQLError, so one class covers a lookup miss however it arose. @ajtmccarty
#1373 Raise a typed exception per catalogue code: nine generated classes, the generated bindings, per-code resolution with a validation-failure fallback, both-client parity, live-server integration tests, the topic page. @gmazoyer

What a caller will notice

  • A catalogued failure raises its own class. UNIQUENESS_VIOLATION raises
    UniquenessViolationError with .node_kind and .fields populated. All of them 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.
  • query and variables are readable on every ApiError, where reading either off an
    AuthenticationError previously raised AttributeError.
  • Exceptions survive pickle and copy.deepcopy, which the keyword-only generated constructors
    and GraphQLError's error-list-first constructor both broke.
  • The silent token refresh is decided by code, not message text — TOKEN_EXPIRED, falling back
    to the legacy "Expired Signature" string for pre-catalogue servers. An API-token client no longer
    replays a stale token it cannot refresh.

The rule worth understanding

Which class a failure 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.

That is what lets the three authentication codes have no class of their own: each reaches the SDK on
either transport, and every catalogued class descends from GraphQLError, so raising one on the
authentication branch would put that failure out of reach of except AuthenticationError.

Decisions settled while drafting

  • A new ApiError base above both AuthenticationError and GraphQLError. Authentication failures reach consumers from the REST path as well as GraphQL, so they cannot simply be re-rooted under GraphQLError. A 401/403 on a GraphQL call is handled as an httpx.HTTPStatusError and raises AuthenticationError before the body is parsed for GraphQL errors — so except GraphQLError never caught auth failures, and no dual inheritance is needed to preserve compatibility.
  • .code is a catalogue string or None. The /api/... envelope's extensions.code is an integer mirroring the HTTP status, a different thing with a different type; it is not surfaced through .code. The catalogue is GraphQL-only today.
  • Infrahub generates the bindings into this repo as its python_sdk submodule, matching how protocols.py and the generated schema models already arrive. No copy of the catalogue schema is vendored here, so there is one freshness invariant instead of two, policed by extending Infrahub's existing validate-generated check.
  • Query text is dropped from the message for catalogued errors only; uncatalogued errors keep today's message verbatim.
  • The façade re-exports by explicit name, not import *. A wildcard would promote the payload models, both lookup maps and the dispatch helper onto the package surface, where a name is a stability promise. Three tests hold the hand-written lists in step with their sources.

Hazards the code survey found

  • An ordered isinstance ladder gets shadowed. infrahub_sdk/ctl/utils.py tested GraphQLError before the not-found classes. Re-rooting makes the later branch unreachable, silently changing CLI output for exactly the errors this feature makes specific. Fixed in feat: unify and re-root the not-found classes under GraphQLError #1363 — and item 1 above is the second site that fix missed.
  • A renderer with no server errors to render. That same branch renders exc.errors; a unified NodeNotFoundError raised purely client-side has an empty list.
  • identifier carries two types. The client-side NodeNotFoundError has identifier as a mapping of filters; the catalogue payload has it as a single string. The annotation widens, called out in its own changelog fragment.

Scope and checks

Six prioritised user stories, 28 functional requirements. FR-025 to FR-027 land in the Infrahub
repository (generation plus the extended drift check) and are tagged as such; everything else lands
here. The generator is Infrahub #10678, which must merge after this one — it pins its submodule
pointer at the commit this branch produces.

invoke format lint-code clean with zero suppressions; docs-generate/docs-validate show no drift;
2315 unit tests pass; requirements checklist 16/16.


Summary by cubic

Makes the SDK consume Infrahub's GraphQL error catalogue: authentication and GraphQL failures surface the server's error envelope, every catalogued code raises an exception class of its own carrying typed payload attributes, and the three lookup-miss codes now raise their own classes under GraphQLError. The IFC-3034 specification, plan, contracts, and 83-task breakdown land in dev/specs/ifc-3034-error-catalogue/ alongside the generated bindings.

What changed

  • AuthenticationError and GraphQLError share a new ApiError base carrying code, http_status, extensions, errors, query, and variables, so callers can branch on the catalogue code instead of matching message text.
  • Nine catalogue codes raise a generated class each with the payload as typed attributes (UniquenessViolationError.node_kind, .fields); the three 401/403 codes get payload models but no class, since the SDK routes those by the transport it observed.
  • NODE_NOT_FOUND, BRANCH_NOT_FOUND, and SCHEMA_NOT_FOUND raise NodeNotFoundError, BranchNotFoundError, and SchemaNotFoundError, all re-rooted under GraphQLError; NodeInvalidError inherits the re-rooting with CODE cleared.
  • A catalogued failure's message names the code and the server's message instead of embedding the query text; uncatalogued failures keep the old message verbatim.
  • GraphQLError and its subclasses now survive pickle and copy.deepcopy, which previously corrupted the message or raised TypeError on the per-code classes.
  • A payload that violates the catalogue falls back to the generic class for the transport with the code still readable; an unknown code from a newer server surfaces the same way.
  • A 401/403 with an unreadable body raises AuthenticationError with the best reason available instead of JsonDecodeError, TypeError, or the generic default; a file 404 always raises NodeNotFoundError whatever the body contains.
  • infrahubctl prints the exception's message instead of "0 error(s)" for an unreadable GraphQL failure and escapes bracketed text so rich no longer eats branch names.
  • A client authenticating with an API token no longer retries after an expired-token 401.
  • infrahub_sdk/exceptions.py became a strictly layered package (base, catalogue, factory, façade); infrahub_sdk.exceptions stays the one supported import path, pinned by a snapshot test.

Design notes

  • The hierarchy is single-inheritance; the raised class comes from the response's first error code and the transport observed, never from payload validity or binding freshness.
  • code_names_the_failure() separates a described failure from a merely coded one: UNDEFINED_ERROR stays readable on exc.code but does not shape the message.
  • The Infrahub-side generator that produces the bindings lands in a separate pull request; this PR carries its generated output.

Written for commit a040b89. Summary will update on new commits.

Review in cubic

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: a2abeca
Status: ✅  Deploy successful!
Preview URL: https://e5607236.infrahub-sdk-python.pages.dev
Branch Preview URL: https://pog-error-catalogue-ifc-3034.infrahub-sdk-python.pages.dev

View logs

@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 2 files

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/spec.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/checklists/requirements.md Outdated

@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 8 files (changes from recent commits).

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/quickstart.md">

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/quickstart.md:1">
P3: This is a pure documentation/specification change (dev/specs/...), so it can't affect a running product and should ship on the stable release vehicle rather than the develop train. Per the release-vehicle guideline, pure docs changes belong on stable; target develop instead.</violation>
</file>

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/research.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/spec.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
@@ -0,0 +1,196 @@
# Quickstart: validating the error catalogue in the SDK

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.

P3: This is a pure documentation/specification change (dev/specs/...), so it can't affect a running product and should ship on the stable release vehicle rather than the develop train. Per the release-vehicle guideline, pure docs changes belong on stable; target develop instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/quickstart.md, line 1:

<comment>This is a pure documentation/specification change (dev/specs/...), so it can't affect a running product and should ship on the stable release vehicle rather than the develop train. Per the release-vehicle guideline, pure docs changes belong on stable; target develop instead.</comment>

<file context>
@@ -0,0 +1,196 @@
+# Quickstart: validating the error catalogue in the SDK
+
+Runnable checks that prove the feature works end to end. Each scenario names what it proves and the
</file context>

Comment thread dev/specs/ifc-3034-error-catalogue/plan.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/data-model.md Outdated

@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 6 files (changes from recent commits).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/data-model.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/spec.md Outdated

@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 existing issue remains and no new issues found across 5 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 dev/specs/ifc-3034-error-catalogue/spec.md Outdated

@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).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md Outdated
Specification, implementation plan, research decisions, data model, interface
contracts, and a validation quickstart for making ordinary SDK operations raise
the specific exception for the failure the server reported, on both the async and
sync clients, without ever raising on a payload the SDK does not recognise.

The design in brief:

- A new `ApiError` base carries the parsed envelope — catalogue code, declared
  HTTP status, raw extensions, and the server's error list — with `GraphQLError`
  and `AuthenticationError` descending from it. One raise-time factory serves
  every existing raise site, so the code is readable against any server version
  even with no generated bindings present.
- A catalogued error's payload is read as directly typed attributes on the
  exception (`exc.node_kind`, `exc.fields`), typed exactly as the catalogue
  declares them. The pydantic payload model validates the envelope and populates
  them; it is not the access path. Nothing is typed `Any` beyond raw decoded
  JSON, and no type-check suppression is anticipated.
- `infrahub_sdk/exceptions.py` becomes a strictly layered package — hand-written
  base, generated catalogue, factory, façade — with imports pointing only
  downward and a test enforcing it. `infrahub_sdk.exceptions` remains the one
  supported import path, and a snapshot test pins that no name importable from it
  disappears.
- Codes declaring 401 or 403 descend from both branches. Infrahub returns HTTP
  200 for resolver-raised errors, so a permission failure arrives on the data
  path inside a response `except GraphQLError` catches today.
- The raised class is a function of the response's first error code and the
  transport observed — never of payload validity, binding freshness, or a code's
  declared status.
- Infrahub generates the bindings into the submodule from `backend.generate`,
  alongside the schema models and protocols it already generates there, and
  `backend.validate-generated` fails when they are stale.

Three broadenings are accepted deliberately and recorded in the spec, together
with the `NodeNotFoundError.identifier` widening that unification forces.

Requirements FR-025 to FR-027 land in the Infrahub repository; everything else
lands here.
@ogenstad
ogenstad force-pushed the pog-error-catalogue-IFC-3034 branch from a1e35dd to a35e9c1 Compare September 2, 2026 16:04

@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 9 files (changes from recent commits).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/research.md
Comment thread dev/specs/ifc-3034-error-catalogue/research.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/plan.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/data-model.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/quickstart.md
Comment thread dev/specs/ifc-3034-error-catalogue/plan.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/spec.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/spec.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/data-model.md Outdated
Seven valid findings, three rejected. Most had been present since the original
plan commit and survived six incremental reviews, which only ever read the delta.

Valid:

- The plan's Constraints section still said the raised class never depends on
  payload validity or on the transport observed, contradicting FR-004 and
  FR-012. Stale since the E3 reversal, and it would have led an implementer to
  route a payload-invalid 401/403 code to the authentication branch and lose the
  `except GraphQLError` coverage FR-018 requires.
- Nothing documented set `code` on the three adopted classes, so the
  `exc.code is not None` test for "came from the server" could not work. The
  factory sets it per-instance there; a class attribute cannot, since the same
  class must report None on a client-side raise.
- FR-002's "the base GraphQL error itself" was ambiguous once FR-001 introduced a
  shared base above both branches. It now names that base and excludes the
  per-code classes.
- FR-003 and FR-012 described `code` as "absent" while two acceptance scenarios
  promise `None` — different observable contracts. Standardised on
  always-exists-and-may-be-None.
- The Messages guarantee claimed every catalogued failure names the server's
  message, but three catalogued classes can be raised client-side with no server
  response. Qualified in FR-022 and the contract.
- data-model.md said "nothing here is typed Any" two paragraphs above an
  `extensions: dict[str, Any]` row.
- A local absolute developer path in plan.md.

Rejected, but the gap behind two of them fixed: the catalogue counts and the
`UNIQUENESS_VIOLATION` example are correct for `opsmill/infrahub@develop`, which
this feature pairs with, and wrong only against the stable line the reviewer
read. Nothing in the artefacts said which ref the numbers came from, so the
survey now pins `develop` and records that US1 scenario 1 needs a catalogue
containing that code. The third rejection — that the "Expired Signature" grep
should expect two sites — misses that R10 puts both the code check and the legacy
fallback in one shared helper; R10 and the quickstart now say so explicitly.

@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 7 files (changes from recent commits).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
The Messages qualifier said the three unified classes are raised "with no server
response behind them", but the file handler raises NodeNotFoundError from a REST
404 that does carry a response and a message — a case the broadenings list two
paragraphs above. Restated around the operative property instead: no catalogue
code behind them, which covers both the client-side miss and the REST 404, whose
response carries the legacy envelope. FR-022 corrected the same way.

The critique's trend paragraph also claimed all seven ninth-pass defects had
survived since the original plan commit, while the section it summarises says
"most" — and its own example only became a contradiction at the E3 reversal.
The three 401/403 codes no longer get their own exception class. They are the
only codes that reach the SDK on two different transports, and each transport
already has a class an existing `except` clause depends on: a real 401/403 raises
AuthenticationError, and a resolver-raised failure inside an HTTP 200 raises
GraphQLError. The transport rule already in FR-012 picks the right one, and
`exc.code` carries the identity.

One class per code could satisfy both only by inheriting from both branches. That
diamond had already produced three defects, all found in review rather than by
design: the positional-argument corruption, since method resolution handed those
classes GraphQLError.__init__ whose first parameter is `errors`; the cooperative
super().__init__ reaching AuthenticationError.__init__ on the GraphQL path, where
its default message escaped substitution only by accident; and two generated
class shapes instead of one. It also misread as GraphQLError descending from
AuthenticationError on first contact. All to distinguish three codes whose
payloads are empty or entirely nullable and usually unset.

The hierarchy is now a tree with no class having more than one parent. FR-008
changes from "which parents" to "which codes get classes" — one rule, and the
factory needs no special case, since the lookup simply misses for those codes.

Amended: US3's acceptance scenarios, which specified distinct types before the
HTTP 200 behaviour was verified, plus FR-005, FR-008, SC-001, and the Key
Entities description. The third broadening added in the previous round
disappears, because a real 401/403 once again produces AuthenticationError and
nothing else.

Accepted cost: those three codes have no typed payload attributes, reachable only
through `exc.extensions["data"]` if the catalogue later gives them substantive
fields.

@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 8 files (changes from recent commits).

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/data-model.md">

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/data-model.md:76">
P2: The new AuthenticationError text says "It gains no subclasses", but spec.md FR-015 still promises the opposite: "MUST ... remain the class raised for REST authentication failures, while gaining the three catalogue subclasses beneath it" (dev/specs/ifc-3034-error-catalogue/spec.md:352). At the current PR head the two spec documents contradict each other on whether AuthenticationError has subclasses. Update FR-015 to drop "while gaining the three catalogue subclasses beneath it", and audit FR-012's rationale, which still refers to "the dual inheritance in FR-008" after FR-008's dual-inheritance wording was removed.</violation>
</file>

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md

## AuthenticationError

Name, constructor, and default message unchanged (FR-015). It gains no subclasses and inherits the

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.

P2: The new AuthenticationError text says "It gains no subclasses", but spec.md FR-015 still promises the opposite: "MUST ... remain the class raised for REST authentication failures, while gaining the three catalogue subclasses beneath it" (dev/specs/ifc-3034-error-catalogue/spec.md:352). At the current PR head the two spec documents contradict each other on whether AuthenticationError has subclasses. Update FR-015 to drop "while gaining the three catalogue subclasses beneath it", and audit FR-012's rationale, which still refers to "the dual inheritance in FR-008" after FR-008's dual-inheritance wording was removed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/data-model.md, line 76:

<comment>The new AuthenticationError text says "It gains no subclasses", but spec.md FR-015 still promises the opposite: "MUST ... remain the class raised for REST authentication failures, while gaining the three catalogue subclasses beneath it" (dev/specs/ifc-3034-error-catalogue/spec.md:352). At the current PR head the two spec documents contradict each other on whether AuthenticationError has subclasses. Update FR-015 to drop "while gaining the three catalogue subclasses beneath it", and audit FR-012's rationale, which still refers to "the dual inheritance in FR-008" after FR-008's dual-inheritance wording was removed.</comment>

<file context>
@@ -74,9 +73,15 @@ default.
-Name, constructor, and default message unchanged (FR-015). It remains the class raised for REST
-authentication failures, where `code` is `None`. It gains the three generated subclasses and the
-inherited `ApiError` attributes.
+Name, constructor, and default message unchanged (FR-015). It gains no subclasses and inherits the
+`ApiError` attributes. It remains the class raised for every failure the SDK observed as HTTP 401 or
+403, with `code` set per-instance by the factory: `None` on the REST path, which carries no catalogue
</file context>

Comment thread dev/specs/ifc-3034-error-catalogue/research.md Outdated
Neither provenance can populate a unified class's full attribute set, and they
fail to in both directions: the catalogue supplies no `branch_name` at all, while
four of the seven client-side raise sites supply no node kind and fall back to
"unknown". So a promoted attribute on `NodeNotFoundError`,
`BranchNotFoundError`, or `SchemaNotFoundError` must be optional even where the
catalogue declares the underlying field required — a required attribute is a
promise a class raised from two provenances cannot keep.

Whether the SDK could fill a future catalogue field client-side depends entirely
on the field, which is why the policy has to be optional-by-default rather than
decided per field. Recorded with a tripwire for revisiting: an adopted code
gaining a field that is both required and semantically server-only.

Also corrects this plan's own framing of R9. Earlier rounds treated the
unification as the design's weak point. A split is in fact more expensive than
the optional attribute, because the derived name for NODE_NOT_FOUND is the
existing class name — so splitting means breaking FR-005, FR-006, or every
`except NodeNotFoundError` around a store lookup.

Records the direction of travel the maintainer set: separate classes for the
SDK's own failures, which would retire `identifier`'s dual meaning and make
`branch_name` coherent, reached through the constitution's deprecation path as a
later change rather than inside this one.

@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).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
Comment thread dev/specs/ifc-3034-error-catalogue/research.md Outdated
Five findings from the review of the two design commits, all valid at least in
part.

Two were real contradictions the MI removal left behind:

- FR-015 still promised "gaining the three catalogue subclasses beneath it" while
  the data model said AuthenticationError gains none, and FR-012's rationale
  still cited "the dual inheritance in FR-008" after that wording was removed.
- The catching table still promised `except AuthenticationError` for "any
  authentication or permission failure, either transport", which the tree design
  does not deliver: a resolver-raised permission failure inside an HTTP 200
  raises GraphQLError. The table and the example described the diamond's
  capability. `except ApiError` is now named as the clause that spans both
  arrival paths. Rejected the finding's FR-018 claim, which measured coverage
  against the unshipped diamond rather than against today's behaviour, where
  `except AuthenticationError` does not catch such a response either.

Three were accuracy fixes:

- R5 said "twelve generated classes" where nine are generated and three adopted.
- The optional-attribute row justified itself as "raised without a server
  response", but the REST 404 carries a response and merely no catalogue code.
  Same error already fixed in the Messages section, reintroduced verbatim.
- The NodeNotFoundError construction-site counts were wrong. Re-derived from
  source: nine sites, eight `raise` plus one deferred construction at
  store.py:184; node_type supplied by five, omitted by the four store raises;
  identifier a mapping at eight and a plain string at one.

@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).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md Outdated
The worked examples commented that TOKEN_EXPIRED and AUTHENTICATION_REQUIRED
arrive as a real 401/403 while PERMISSION_DENIED is the resolver-raised one. R5
records the opposite, having verified it: the formatter maps any error escaping a
resolver onto a catalogue code, AuthorizationError included, so all three can
arrive inside an HTTP 200. The example would have sent a consumer to
`except AuthenticationError` and had them miss every 200-path arrival of the same
code.

The examples now reference the arrival table instead of restating it, and lead
with `except ApiError` as the form to reach for. US3 scenario 5 generalised from
"a permission failure" to "any of the three authentication codes".

This is the fifth restated-mechanism defect, so the mitigation is recorded as a
rule rather than another fix: state each load-bearing mechanism in one place and
point at it from everywhere else.

@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 3 files (changes from recent commits).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md
The review finding: the two catch forms were in one code block, and since
AuthenticationError descends from ApiError, copying the block as a handler
sequence leaves the final clause unreachable. Split into alternatives with the
shadowing stated.

The ast discovery walk (R3, R4) was executed against the real sources. It finds
all 32 classes in exceptions.py and classifies every catalogue code as the plan
claims: nine to generate, three with no class, three colliding. It confirms the
9/3/3 split independently, fires the collision check on VALIDATION_ERROR,
RATE_LIMIT, INVALID_RESPONSE and TIMESTAMP_FORMAT, and is correctly blind to an
inherited CODE.

It also surfaced an ordering constraint the plan had not stated: generation
aborts until base.py declares the three CODE attributes, so adoption is a
prerequisite for the generator running at all, not just for its output. Recorded
in R4 and as a prerequisite section in the plan, since the instinct is to build
the generator first.

The generated class shape (R15) is clean under both mypy 1.11.2 and ty 0.0.14
with zero suppressions. The types a consumer sees were asserted by typed
assignment rather than inspected, so a wrong one would have been an error:
node_kind is str rather than optional or Any, fields is list[str], code is
str | None, and from_payload returns the concrete class through Self.

@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 4 files (changes from recent commits).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/research.md Outdated
The review found R4's spike paragraph enumerating a different collision set from
R3's in the same document, four codes against five. Both lists were factually
correct, which is why neither had been caught: the defect is that two lists each
read as *the* set. Reconciled to one list of six in R3, verified against the
current module, with R4 referring to it rather than repeating it.

A sweep of the ten load-bearing mechanisms against their authoritative statements
found two more, both stale text describing a superseded design:

- R1's decision said "a package of five modules" above a four-row layer table,
  left over from when the payload models had their own module.
- The plan's summary said "12 on the GraphQL branch, 3 on the authentication
  branch", which describes the diamond. There are no classes on the
  authentication branch now.

And one in the same family: the quickstart's message expectation lacked the
"server-reported" qualifier that FR-022 and the contract carry, so it read as
promising a catalogued message for a client-side raise.

The other seven mechanisms are consistent: transport-to-class (including a check
for the inverted mapping, which appears nowhere), the `exc.code` contract, the
payload-validation fallback, generation placement, the broadening count, promoted
attribute optionality, and the raise-site counts.
Phase order follows dependency rather than the priority labels, which the plan's
Sequencing section requires but which reads as wrong at first glance: US1 is P1
and lands last among the behaviour phases. Two constraints force it. The three
CODE declarations are a prerequisite for generation succeeding at all, not merely
for its output, so the US3 hierarchy work precedes the generator; and the
generator produces the per-code classes US1 delivers. US2 and US4 need no
bindings, so they land first.

Also records the mapping from tasks to pull requests, since 83 tasks reads as far
more delivery than this needs. Four issues: the envelope with no bindings, the
re-rooting, the typed per-code classes, and the Infrahub generator. The
boundaries come from the repository split, the SDK-first landing order, and
keeping the compatibility-sensitive re-rooting under its own review rather than
buried in a restructure.

The one inversion worth stating: the generator issue is developed before the
typed-classes issue but merges after it, because that pull request has to carry
the generated artefact before Infrahub's content-level validation can pass
against the pointer it bumps to.

@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 1 file (changes from recent commits).

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

Re-trigger cubic

Comment thread dev/specs/ifc-3034-error-catalogue/tasks.md Outdated
@ogenstad ogenstad changed the title docs: add specification for the error catalogue in the SDK docs: add the error catalogue specification, plan, and task breakdown Sep 9, 2026
Review finding: the section heading claimed phase order was dependency order,
while the document's own dependency list two screens down showed US2, US3, US4
and US6 depending on Foundational alone, and the parallel-opportunities bullet
said three of them run concurrently. The heading was the load-bearing statement a
reader would act on, so it would have serialized four independent phases.

Only two adjacencies are actually forced: US3 before US5, because the CODE
declarations are a prerequisite for generation succeeding at all, and US5 before
US1, because the generator produces the classes US1 delivers. Both still cut
against the priority labels, which was the point the section existed to make, so
the correction narrows the claim rather than removing it.

The parallel-opportunities bullet also understated the set and overstated the
independence: it named phases 3, 5 and 6 but not 4, and said the three share no
files beyond the factory. US3 and US6 both add cases to test_exceptions.py. Both
overlaps are now named, since they are what actually needs coordinating.
…#1348)

* feat: expose the server error envelope on SDK exceptions

Turn infrahub_sdk/exceptions.py into a package and funnel every
authentication and GraphQL raise site through two raise-time factories,
so envelope parsing lives in one place instead of being repeated at
fifteen call sites.

AuthenticationError and GraphQLError now share an ApiError base carrying
the parsed envelope (code, http_status, extensions, errors), letting a
caller branch on the server's catalogue code rather than matching on
message text. Exceptions raised from a status code alone, such as
URLNotFoundError and RateLimitError, deliberately stay under Error.

Also fixes an AttributeError escaping the client when a 401 carried a
body that was valid JSON but not an object, which a proxy or gateway can
produce. The factories are total: any shape the parser cannot read
degrades to the generic exception rather than to an SDK error of our own.

* docs: record where the implementation diverged from the task list

Six tasks landed differently from how they were written. Annotate each
with what shipped and why, in the style T020 and T021 already use, so the
plan stays usable by whoever picks up the later issues.

The one that would have bitten: T009's optional `message` parameter is
deferred to its first caller rather than added unused, so T035 and US6
now say to add it there instead of assuming it already exists.

* fix: raise AuthenticationError when a file upload is rejected

The multipart upload path called raise_for_status() bare, so a 401 or
403 on a file upload surfaced as a raw httpx.HTTPStatusError while every
other request raised AuthenticationError. FR-015 asks for the same class
on every failure the SDK observes as 401 or 403, and an upload is no
exception. The silent token refresh already covered this path, since
_post_multipart carries the relogin decorator; only the error type was
inconsistent.

Also narrows the DOC501 suppression from the global ignore list to the
three modules that raise from the factories, so the rule keeps working
across the rest of the SDK, and corrects the fixture README: an integer
extensions.code is a pre-catalogue shape on either transport, not a REST
marker, which the graphql_integer_code fixture already demonstrated.

* refactor: suppress the DOC501 false positives at the six sites, not by file

Replaces the per-file DOC501 ignores with a noqa on each docstring that
actually triggers one, leaving no config-level suppression at all. Ruff
anchors the diagnostic on the docstring rather than the raise, so the
directive goes after the closing quotes; nothing on the def or raise line
has any effect.

Narrowing it immediately paid for itself: the per-file ignore had been
hiding a real omission, in that both multipart methods re-raise
httpx.HTTPStatusError for a non-401/403 status and neither docstring said
so. Both now document it.

RUF100 is enabled repo-wide, so these directives report themselves as
unused if ruff ever resolves a factory call to its return type, which a
per-file ignore would never have done.

* fix: keep the reason and status on an unreadable authentication response

The message join overwrote the HTTP-status fallback unconditionally, so a 401 whose body is the REST API's bare {"detail": ...} lost both the server's stated reason and the status, surfacing only the generic default message. Each fallback now applies only where the one before it yielded nothing: the server's messages, then detail, then the plain status.

The body is also read through response.json() rather than utils.decode_json, whose one addition is to raise JsonDecodeError -- which this factory caught and discarded, having been built to absorb exactly that case. It cost a deferred import inside the function body, since utils imports this package, so dropping it leaves the exceptions package depending on nothing else in the SDK.

token_expired_in exposes the refresh decision the client had been reaching in for through three private helpers.

* fix: report a GraphQL failure infrahubctl cannot read as an envelope

Filtering exc.errors to dicts left every renderer with a payload it drops entirely: print_graphql_errors emitted nothing, and infrahubctl run and validate graphql reported "0 error(s)" with no diagnostics, each still exiting non-zero. The isinstance(error, str) branch that used to carry the --branch hint became unreachable at the same time.

print_graphql_errors takes an optional fallback so the existing signature keeps working, and the two command renderers now share print_graphql_query_errors, which keys the hint on the server's message and degrades to the exception's message rather than printing a count of nothing.

* fix: tolerate any body on a file 404

The 404 branch sat directly below the hardened 401/403 one and still called response.json() bare, so an HTML error page from an intermediary escaped as json.JSONDecodeError and a JSON array as an AttributeError on .get() -- the same crash class the branch above it exists to prevent.

Narrowing the identifier to a str made the type checker report a violation that had been there all along, so NodeNotFoundError.identifier is widened to Mapping[str, list[str]] | str. The SDK already raised it with a plain string to name a missing file; nothing changes at runtime.

* refactor: route the refresh decision through the factory, not its privates

client.py imported _catalogue_code, _extensions_of and _server_messages across a module boundary, which undercut the factory's claim that envelope parsing lives in one place. It now calls the public token_expired_in instead.

The retry that decision drives is also skipped for a client authenticating with an API token: login(refresh=True) returns without touching the auth header unless there is a username and password behind it, so the retry replayed the token the server had just rejected and earned a second 401 for nothing.

The layering test asserted only that imports inside the exceptions package point downward, which was half the property -- every other module is free to raise, so a dependency in that direction is a cycle waiting to be found, and the deferred import that works around one is invisible to an intra-package check. It now also fails on any import reaching elsewhere in the SDK, in either spelling.

* docs: reconcile the task list and contracts with what the fixes landed

T040 and T041 were scheduled for the next pull request, but both cover regressions this one introduces, so they are pulled forward with T036 and T078 and the pull-request split is renumbered. T012 prescribed decode_json and T015 prescribed only the intra-package check; both now record why the implementation went further.

The http_status contract claimed the wire status stays available in extensions. It does not: exc.http_status is a copy of extensions["http_status"], so the status the transport observed is not on the exception at all. Both statements of that rule are corrected, and the distinction they were reaching for is attributed to the generated classes that will introduce it.

* fix: catch a dependency on the SDK root in the layering check

The predicate matched only names beginning with "infrahub_sdk.", so "from infrahub_sdk import utils" and "import infrahub_sdk" were not flagged -- both pull in the facade, which imports the client, so the check permitted exactly the dependency it exists to forbid. Both spellings are now cases in the detection test.

* docs: stop three changelog notes claiming more than the fixes cover

The validate subcommand is graphql-query, not graphql. The 404 fix is download-only: an upload goes through the GraphQL multipart path, never handle_error_response, so a 404 there still raises httpx.HTTPStatusError. And _get_streaming carries no relogin decorator, so streaming downloads never replayed an expired-token 401 and cost nothing to begin with.

* docs: pin the http_status rule to the condition the server applies

Both statements of the rule hand-waved at the server replacing a declared 500 "when it has a more accurate one". The substitution has an exact trigger -- the catalogue resolving nothing more specific than 500 -- so the envelope carries the catalogue's status in every other case, and that is what a reader needs to know before branching on it.

The decode_json rationale also described the exception type as its only addition, understating it: JsonDecodeError carries the response URL and body, and this factory discards both deliberately, since the status and the server's reason are what the caller needs on that path. The issue 2 task count now names its range rather than leaving the reader to guess which tasks it counts.

* refactor: declare the exceptions package's public surface explicitly

The package had no __all__, so "import *" fell back to every public name in the namespace -- which includes the base and factory submodules, since importing them makes them attributes of the parent. Callers were handed two names that are an artefact of the layout and that shadow those names in their own scope. The facade now declares __all__, taken from base so there is one list rather than two, and the wildcard is exactly the exception classes: what an end user catches.

token_expired_in also comes out of the factory module's __all__. Nothing star-imports that module, so the list is documentary, and it had mirrored the names the facade re-exports; advertising a third that does not leave misdescribed the surface. The name stays unprefixed and the client keeps importing it -- public to the SDK, not published API.

The two raise-time factories are no longer in the wildcard either, and a test asserts they are still importable by name. Both are new in this pull request, so nothing released can be wildcard-importing them.

* refactor: write out the exceptions the package exports instead of star-importing

A wildcard hides what the package exports, which is the one thing this file exists to state, so the class list is written out and reading __init__.py now answers the question. The cost is two hand-written lists, held in step by three tests: the facade's __all__ against base.__all__, every declared name against what the import block actually binds, and base.__all__ against the classes base defines.

That last one closes a gap nothing else covered: a class omitted from base.__all__ is absent everywhere downstream, so the snapshot still matched and no test noticed. Each guard was checked against a deliberately drifted copy of the lists to confirm it fails rather than merely passing today.

* docs: name the shape that actually raised a TypeError

The fragment blamed a body carrying no "errors" key. That case joined an empty list of messages, which AuthenticationError turns into its generic default -- an authentication error that silently dropped the status, not a TypeError. The TypeError came from an errors array whose entries have no message key, where joining a list containing None raises. Both are now stated as they happened.
* feat: unify and re-root the not-found classes under GraphQLError

NodeNotFoundError, BranchNotFoundError and SchemaNotFoundError now descend
from GraphQLError and declare the catalogue code they represent, so one class
covers a lookup miss however it arose and their envelope state is set by the
constructor that owns it. NodeInvalidError inherits the re-rooting.

A failure the server reported with a catalogue code now carries a message
naming that code and the governing error's message in place of the query text.
An uncatalogued failure keeps today's message byte for byte, and the complete
error list, the query and the variables stay readable on the exception.

infrahubctl gains a branch above its class ladder keyed on the code, so a
catalogued failure is reported by its code rather than mislabelled as an
authentication failure or rendered as a bare error list. The lookup-miss branch
moves above GraphQLError, which the re-rooting would otherwise shadow.

Two broadenings are deliberate and carry changelog fragments: except
GraphQLError additionally catches client-side lookup misses, and a catalogued
message changes shape for anyone matching on its text.

* fix: keep the failed operation's path in catalogued CLI output

The catalogued branch rendered the server's error list only when there was
more than one error, so a single-error failure lost the path naming the
operation that failed -- which infrahubctl printed before this branch existed.
The coded line has nowhere to put a path, so where the server sent errors they
now render the detail and the code line just names the failure, which keeps the
path without printing the governing message twice.

print_graphql_errors falls back to an error's message instead of dumping the
whole entry as a dict when it carries no path, so an authentication envelope
reaching it reads as the server's words rather than as decoded JSON.

* fix: keep uncatalogued CLI rendering byte-identical

Falling back to an error's message when it carries no path was applied to the
shared renderer, so it also changed uncatalogued failures -- and a GraphQL
validation error is exactly that shape, carrying `locations` and no `path`,
which meant the coordinates pointing at the offending part of the query were
dropped from the output.

The message-only fallback belongs to the catalogued branch alone, where the
code has already named the failure, so it moves to its own renderer and the
shared one goes back to printing the raw entry.

* fix: distinguish a described failure from a merely coded one

The server codes every error it reports, falling back to UNDEFINED_ERROR
where its own catalogue has no entry, so `exc.code is not None` was never the
test for "the server described this". The short message form therefore applied
universally, dropping the query text and every error after the first from what
reaches a log or a traceback, and GraphQL validation errors took the coded CLI
branch where their line and column were discarded under a headline that named
nothing.

code_names_the_failure() is that test now, and both factories and the CLI
ladder key on it. UNDEFINED_ERROR stays readable on exc.code; it simply no
longer shapes the message. The authentication factory also follows the rule
the GraphQL path already documented: only the governing error's message may be
named beside a code, with the rest on exc.errors.

A failure the server reports under an adopted code raises the class that
adopted it, built from the payload through that class's own from_payload, so
NODE_NOT_FOUND raises NodeNotFoundError rather than a generic GraphQLError.
query_groups drops the substring match that stood in for it, which only
matched while the whole error list was embedded in the message, and its sync
counterpart gains the guard it never had. This covers the three adopted codes
only; the generated bindings still own the rest.

Smaller corrections in the same area:

- every message infrahubctl prints is escaped, so a branch name in brackets is
  no longer eaten as console markup
- GraphQLError keeps a deliberately empty message instead of replacing it with
  the query placeholder
- NodeInvalidError clears CODE rather than inheriting NODE_NOT_FOUND, since a
  wrong-kind result is not a lookup miss
- the two CLI error renderers become one function with a flag, keyed on a path
  that is present and not null
- the JSON importer reads an error's message with .get, so an entry carrying
  none cannot raise out of the continue-on-error path
- the ruff per-file F403 ignore for the exceptions facade goes, since the
  facade writes its imports out rather than star-importing

CODE is declared ClassVar[str | None] so a subclass can clear it, which makes
the adopted declarations annotated assignments, so the generator contract now
names both AST forms for its adoption walk.

* refactor: re-export only the names that have a caller

UNDEFINED_ERROR_CODE and the three payload protocols were added to the
exceptions facade so that a downstream annotation would not have to reach into
a private submodule, but nothing in the repository reads any of them. The
facade is a documented stability promise, so exporting a name with no caller
commits us to supporting it in exchange for nothing. The protocols stay
importable from `.base` for anything that needs them, and their shapes are
already kept honest by the factory's `from_payload` call sites type-checking
against them.

The three that remain keep the `X as X` spelling, which is not redundancy: it
is what marks a name as re-exported rather than merely imported, and is the
form ruff's F401 accepts for an import nothing in this file uses. The module
docstring now says so, along with the rule that avoids the next round of this:
a name in the facade earns its place by having a caller rather than by being
plausibly useful one day.

* fix: name a code only when the governing error said something

Review follow-up on the catalogue envelope handling.

_named_by_code returned a bare code when the governing error carried no
message, which is a worse headline than what either call site would otherwise
have produced and drops the reasons living in the later errors. On the
authentication path the `or message` fallback meant to catch that could never
fire, since the bare code is truthy, so the joined reasons were lost. Both
factories now name the code only where the governing error actually said
something, which also leaves an adopted class its own sentence rather than
replacing it with a code.

delete_unused keeps a fallback for servers older than the catalogue. Those
report a cascade-deleted member as a generic failure with the reason only in
the message, so keying purely on NodeNotFoundError turned a graceful skip into
an aborted group update against every server before 1.10.10. The legacy check
is guarded on there being no code, so a coded failure reaching it is a
different failure and is never swallowed on the strength of its wording.

The JSON importer's execute_batches bound the (node, result) pair its batch
yields to a single name, so every check of the result looked at a tuple, which
no task can ever produce. A failed task was counted as a success: nothing
printed, --continue-on-error had nothing to report, and the exception object
was returned among the imported results. Unpacking the pair makes the error
branch reachable for the first time, which is also what lets it be tested.

Also: the ctl fallback branch escapes its traceback, so bracketed text in an
unknown exception is not eaten as console markup; the broadenings in the spec
and the hierarchy contract are no longer counted, since adopting a further code
adds one; and the changelog no longer implies dispatch applies to payloads the
SDK deliberately leaves generic.

* docs: name the command the importer fix actually affects

The changelog fragment for the execute_batches fix named `infrahubctl transfer
import`, which is not a command. `load` is registered at the top level in
cli_commands.py, so the command a reader has to run is `infrahubctl load`, and
the fragment is renamed to match. Also restores a dropped article in the
adopted-codes fragment.

* revert: take the importer batch-unpacking fix out of this branch

execute_batches never unpacks the (node, result) pair its batch yields, so its
error branch is unreachable and a failed import task is counted as a success.
Fixing that here made the branch reachable for the first time, which turned six
test_export_import integration tests red: update_optional_relationships gets an
HTTP 500 from the server, and those tests had been passing only because the
importer swallowed it.

That is a real fault on both sides and neither belongs to the error catalogue,
so both go to their own issue rather than holding this branch red behind a
server bug. The importer is back to its previous behaviour, along with the
tests and changelog fragment that described the fix.

The defensive `.get` on an error entry's message stays. It guards code that
cannot currently run, so it is untested by design until the unpacking is fixed,
and it costs nothing to leave correct in the meantime.

* docs: cut the comments back to what the code cannot say

Review feedback on one four-line comment, applied to the three others in this
branch that had the same problem. Each kept restating the line beneath it and
describing behaviour that belongs to the caller, leaving the one genuinely
non-obvious fact buried at the end.

What survives is that fact alone: why GraphQLError tests `is not None` rather
than the `or` every sibling class uses, and why an adopted class needs its
envelope attached after the fact. The rest was either evident from the code or
already documented where it belongs.
The transient-retry suite pinned the legacy `An error occurred while
executing the GraphQL Query` text on envelopes whose governing error
carries a catalogue code, which now name the failure by that code
instead. The expected message moves onto the parametrized case so the
uncatalogued one keeps asserting the legacy form.

`ty` 0.0.74 no longer reads the mypy ignore comments on two of the new
test files, so their violations move to a scoped override.
@ogenstad
ogenstad force-pushed the pog-error-catalogue-IFC-3034 branch from e047546 to 2af53e7 Compare September 17, 2026 07:48
@github-actions github-actions Bot added the type/documentation Improvements or additions to documentation label Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.39078% with 23 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/query_groups.py 30.76% 9 Missing ⚠️
infrahub_sdk/ctl/utils.py 86.84% 3 Missing and 2 partials ⚠️
infrahub_sdk/ctl/cli_commands.py 20.00% 4 Missing ⚠️
infrahub_sdk/exceptions/factory.py 98.29% 1 Missing and 1 partial ⚠️
infrahub_sdk/transfer/importer/json.py 0.00% 2 Missing ⚠️
infrahub_sdk/ctl/validate.py 50.00% 1 Missing ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1266      +/-   ##
====================================================
+ Coverage             86.28%   86.65%   +0.37%     
====================================================
  Files                   149      152       +3     
  Lines                 14541    14700     +159     
  Branches               1994     1981      -13     
====================================================
+ Hits                  12547    12739     +192     
+ Misses                 1431     1391      -40     
- Partials                563      570       +7     
Flag Coverage Δ
integration-tests 42.29% <14.62%> (-1.28%) ⬇️
python-3.10 60.71% <48.49%> (-0.84%) ⬇️
python-3.11 60.71% <48.49%> (-0.84%) ⬇️
python-3.12 60.71% <48.49%> (-0.83%) ⬇️
python-3.13 60.71% <48.49%> (-0.84%) ⬇️
python-3.14 60.71% <48.49%> (-0.84%) ⬇️
python-filler-3.12 23.02% <46.49%> (+1.26%) ⬆️

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

Files with missing lines Coverage Δ
infrahub_sdk/client.py 85.31% <100.00%> (+1.24%) ⬆️
infrahub_sdk/exceptions/__init__.py 100.00% <100.00%> (ø)
infrahub_sdk/exceptions/base.py 94.71% <100.00%> (ø)
infrahub_sdk/exceptions/catalogue.py 100.00% <100.00%> (ø)
infrahub_sdk/file_handler.py 87.56% <100.00%> (ø)
infrahub_sdk/graph_traversal/query.py 100.00% <100.00%> (ø)
infrahub_sdk/object_store.py 85.24% <100.00%> (+13.81%) ⬆️
infrahub_sdk/ctl/validate.py 54.68% <50.00%> (+5.39%) ⬆️
infrahub_sdk/exceptions/factory.py 98.29% <98.29%> (ø)
infrahub_sdk/transfer/importer/json.py 76.02% <0.00%> (ø)
... and 3 more

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ogenstad ogenstad changed the title docs: add the error catalogue specification, plan, and task breakdown feat: typed exceptions for the server's error catalogue Sep 28, 2026
* feat: add the generated error catalogue bindings

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.

* feat: re-export the generated exception classes from the package

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.

* feat: raise a typed exception per catalogue code

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.

* fix: log every fallback, and pin the status the class supplies

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.

* fix: keep a GraphQL-path exception intact across a round trip

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.

* refactor: ask the exception whether it wrote its own message

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.

* fix: let an undescribed uniqueness failure reach the skip again

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.

* feat: carry the failed request on every server-reported error

`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.

* docs: correct what the topic page and the spec set claim

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.

* docs: retire the last two records of a lint exemption never added

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.

* test: hold the generated module to declaring what it defines

`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.

* docs: name all four pytest markers this feature registers

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`.
The entries for the re-rooting, the adopted codes and the typed per-code
classes all land in one release, and three passages appeared in more than one
of them: that identifying a failure no longer means matching words in a
message, that one class now covers a lookup miss however it arose, and what a
server predating a code still raises. Each now sits in the single entry that
owns it.

The adopted-codes entry keeps only what is true of those three classes alone -
that they are also raised by the SDK itself, so a ladder handling one of them
sees both kinds arrive and `exc.code` tells them apart. They stay in separate
sections deliberately: nine new classes are an addition, while what an existing
`except NodeNotFoundError` catches is a change.

The typed per-code entry gains the declared-status fallback, which was
user-visible and mentioned nowhere.

@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.

39 issues found across 82 files

You’re at about 99% 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.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/fixtures/error_catalogue/auth_two_messages.json">

<violation number="1" location="tests/fixtures/error_catalogue/auth_two_messages.json:4">
P3: This constructed envelope is named as a captured `auth_*` response, contrary to the fixture policy. Move it under `malformed_*` and update its references, or replace it with a captured response so tests do not imply the server emits this shape.</violation>
</file>

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

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/spec.md:258">
P2: Specify which status `http_status` exposes for each transport. The spec distinguishes catalogue status from wire status but leaves REST and pre-catalogue responses without a defined value, so consumers cannot rely on this part of the `ApiError` contract.</violation>

<violation number="2" location="dev/specs/ifc-3034-error-catalogue/spec.md:399">
P3: FR-022 requires "a server-reported catalogued error's message" to name the code and quote the server's message with no query text, but UNDEFINED_ERROR is a catalogue code (it carries UndefinedErrorData and an UndefinedError class in the generated bindings, declared http_status 500) whose message is deliberately left byte-identical to today's, query text included, with the code readable but not shaping the message — as the spec's own "UNDEFINED_ERROR is a code, not the absence of one" edge case and the PR description acknowledge. A literal implementer of FR-022 would reverse the shipped behavior (see the asserting test in infrahub_sdk/exceptions/base.py `code_names_the_failure` and tests/unit/sdk/test_exceptions.py:394). Scope FR-022 to described codes: exclude UNDEFINED_ERROR explicitly.</violation>
</file>

<file name="tests/integration/test_infrahub_client.py">

<violation number="1" location="tests/integration/test_infrahub_client.py:225">
P2: This skips on any missing or `UNDEFINED_ERROR` code, not only when the server predates the catalogue, so a code-parsing regression on a catalogue-capable server makes the test pass as skipped. Gate the skip on a positively identified pre-catalogue server; otherwise fail.</violation>
</file>

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

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/data-model.md:158">
P3: `CODE_TO_EXCEPTION` is not what the factory reads: every dispatch goes through `exception_from_payload`, which resolves via `_CODE_TO_BUILDER` (catalogue.py), and `CODE_TO_EXCEPTION` itself is never consulted by factory.py. It also omits the three 401/403 codes by design, so "covering every catalogue code" overstates the map. Point the sentence at `exception_from_payload` or drop the lookup claim.</violation>

<violation number="2" location="dev/specs/ifc-3034-error-catalogue/data-model.md:165">
P2: Pre-catalogue `/graphql` responses can also carry an integer `extensions.code`, which resolves to `None`; calling GraphQL “the catalogue envelope” obscures this supported input. Document the integer-code fallback here.

(Based on your team's feedback about GraphQL code shape. .)</violation>
</file>

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

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/research.md:26">
P3: R2's raise-site survey is inconsistent: base 02a0ba840's `_execute_graphql` calls `_generate_custom_graphql_types`, so it is already covered; only `generate` and `validate_generated` both invoke every generator, while the pytest unit test has no `error_catalogue` trigger. The doc also lists 1360/2348 for `_execute_graphql` and 1429/2417 for the file-upload variants, but at this PR's base those raises sit at 1472/2513 (async/sync `_execute_graphql`) and 1556/2597 (async/sync file-upload), and at head they are `graphql_error_from_response` at 1494/1585/2536/2627. Re-anchor the citations by method symbol instead of line number.</violation>

<violation number="2" location="dev/specs/ifc-3034-error-catalogue/research.md:511">
P3: R7 attributes integer `code` only to the REST envelope, implying catalogue-code reads are safe on the GraphQL transport. Pre-catalogue GraphQL envelopes can carry a non-string `code` too (the fallback's `code=None` path applies there), so the "read only when it is a `str`" guarantee should be stated for the GraphQL transport as well.</violation>

<violation number="3" location="dev/specs/ifc-3034-error-catalogue/research.md:648">
P2: Key this branch on `code_names_the_failure(exc.code)`, not non-`None`: `UNDEFINED_ERROR` is readable but does not describe the failure, so it must retain the legacy rendering and error details.</violation>
</file>

<file name="changelog/+graphql-error-survives-serialisation.fixed.md">

<violation number="1" location="changelog/+graphql-error-survives-serialisation.fixed.md:3">
P3: The quoted `TypeError: __init__() takes 1 positional argument but 2 were given` does not describe what the current per-code classes raise. They declare `def __init__(self, *, node_kind: str, fields: list[str], ...)` (catalogue.py, UniquenessViolationError), so reconstructing one with `UniquenessViolationError(<message>)` raises `TypeError: ... missing 2 required keyword-only arguments: 'node_kind' and 'fields'` — only one positional is ever passed, so "2 were given" cannot occur. Quote the accurate message (or drop the literal quote and keep "raised a TypeError"), since the changelog otherwise describes this behaviour precisely.</violation>

<violation number="2" location="changelog/+graphql-error-survives-serialisation.fixed.md:7">
P3: The default pickle round-trip does not preserve `str()` for `BranchNotFoundError` or `SchemaNotFoundError`: reconstruction treats the old message as `identifier`, generating different `args`, which `str()` returns. Narrow this claim to `NodeNotFoundError` or document the changed output.</violation>
</file>

<file name="tests/unit/sdk/test_exceptions_layering.py">

<violation number="1" location="tests/unit/sdk/test_exceptions_layering.py:61">
P2: Imports of re-exported classes through the package facade bypass the layer check, so `base.py` could import a facade symbol without this test detecting an upward dependency or import cycle. Record non-submodule names from these imports as `__init__` targets so the layer ordering rejects them.</violation>
</file>

<file name="changelog/+api-token-no-relogin-retry.fixed.md">

<violation number="1" location="changelog/+api-token-no-relogin-retry.fixed.md:1">
P3: `_get_streaming` retries transient stream-initiation failures, so this unqualified claim is inaccurate. Say streaming downloads do not use the expired-token re-login retry instead.</violation>
</file>

<file name="tests/fixtures/error_catalogue/graphql_unknown_code.json">

<violation number="1" location="tests/fixtures/error_catalogue/graphql_unknown_code.json:6">
P3: This is a hand-authored future-server envelope, but `graphql_*` fixtures are documented as captured responses, making its provenance misleading. Move it to a clearly synthetic cross-version fixture name/location and document that exception.</violation>
</file>

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

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/plan.md:84">
P3: Scale/Scope counts "11 `AuthenticationError` raise sites", but this revision ships 13 `raise authentication_error_from_response` statements: 6 in `client.py`, 6 in `object_store.py`, and 1 in `file_handler.py`. The GraphQL count of 4 matches, so only the authentication number is stale. Update it or the plan stays out of sync with the code it describes.</violation>

<violation number="2" location="dev/specs/ifc-3034-error-catalogue/plan.md:137">
P3: This entry claims a `GraphQLError(str)` misuse was corrected, but `analyzer.py` uses `graphql.GraphQLError` and is unchanged in this PR. Remove this source entry; no SDK exception correction landed there.</violation>
</file>

<file name="changelog/+typed-per-code-exceptions.added.md">

<violation number="1" location="changelog/+typed-per-code-exceptions.added.md:16">
P2: `http_status` does not always fall back to the catalogue status: adopted not-found classes declare none. Qualify this to exceptions that define a fallback.</violation>

<violation number="2" location="changelog/+typed-per-code-exceptions.added.md:20">
P2: Pre-catalogue GraphQL envelopes can carry numeric `extensions.code`, which the SDK exposes as `exc.code is None`; this sentence incorrectly promises a readable code. Distinguish unknown string codes from legacy numeric or missing codes.

(Based on your team's feedback about GraphQL error-code shapes.)</violation>

<violation number="3" location="changelog/+typed-per-code-exceptions.added.md:22">
P3: The docs reference `docs/python-sdk/topics/error_handling` does not resolve to any file in this repo: the page added in this PR lives at `docs/docs/python-sdk/topics/error_handling.mdx`. Point the sentence at the real path (or a docs.infrahub.app link) so a reader can open it.</violation>
</file>

<file name="changelog/+catalogued-error-messages.changed.md">

<violation number="1" location="changelog/+catalogued-error-messages.changed.md:1">
P3: "On both transports ... the one that determines the exception raised" is not true for the authentication transport: `authentication_error_from_response` (infrahub_sdk/exceptions/factory.py) always raises `AuthenticationError`, whatever code the first error carries — the class is fixed by the HTTP status, and the code only names the message. Only the GraphQL path resolves the raised class from the first error's code. Reword so the sentence does not promise code-based dispatch on REST.</violation>

<violation number="2" location="changelog/+catalogued-error-messages.changed.md:3">
P2: This fallback description applies the GraphQL query-and-error-list format to every failure, but an auth failure without extensions keeps its auth-specific message (joined messages, REST detail, or HTTP status) and has no query attached. Limit the query/full-list claim to GraphQL failures and state separately that auth fallbacks remain auth-specific.</violation>
</file>

<file name="tests/unit/sdk/test_relogin_headers.py">

<violation number="1" location="tests/unit/sdk/test_relogin_headers.py:115">
P2: The relogin retry can loop forever when the refreshed token is immediately rejected again: `handle_relogin`/`handle_relogin_sync` in infrahub_sdk/client.py:209-235 retry unconditionally on every 401 that looks token-expired, and the tests here mock the retried GraphQL call with `is_reusable=True`, so each of the three refresh cases ends with a fresh 401 whose refresh attempt then hits the already-consumed `/api/auth/refresh` mock. The extra refresh POST falls to pytest-httpx's unregistered-request behavior (HTTP 400/runtime error), so the second cycle either surfaces `AuthenticationError` with an "HTTP 400" message (failing the `match="Token has expired"` / `match="Expired Signature"` assertions) or errors out entirely instead of raising the expected message. An infinite retry loop on a permanently-rejected token also means production 401s never settle until an unrelated subsequent refresh call (e.g. from login) fails. The implementation should stop retrying once a refresh has been performed and rejected, and the tests should register either a refresh response per cycle or a permanently-failing refresh mock so the number of GraphQL attempts is deterministic.</violation>
</file>

<file name="infrahub_sdk/transfer/importer/json.py">

<violation number="1" location="infrahub_sdk/transfer/importer/json.py:199">
P2: This condition treats every `GraphQLError` whose entries were filtered out as a local lookup miss. For malformed server envelopes, `str(result)` includes the query, so `continue_on_error` can now print inline query literals; distinguish local lookup exceptions before stringifying the exception.</violation>
</file>

<file name="infrahub_sdk/ctl/utils.py">

<violation number="1" location="infrahub_sdk/ctl/utils.py:217">
P2: The `query` and `validate` commands hide the stable code for catalogued failures because this renderer prints only the error count and server details. Print `exc.code` when `code_names_the_failure(exc.code)` is true.</violation>
</file>

<file name="docs/docs/python-sdk/topics/error_handling.mdx">

<violation number="1" location="docs/docs/python-sdk/topics/error_handling.mdx:23">
P2: The three lookup exceptions do not expose all payload fields under their catalogue names: for example, `branch_name` becomes `exc.identifier`, not `exc.branch_name`. Document these mappings so callers do not access attributes that do not exist.</violation>

<violation number="2" location="docs/docs/python-sdk/topics/error_handling.mdx:44">
P2: The three lookup misses are raised locally but inherit `GraphQLError` and `ApiError`, so this claim is false and `except ApiError` also catches failures without a server response. Qualify this statement and the server-only description of `ApiError` in the hierarchy.</violation>
</file>

<file name="tests/fixtures/error_catalogue/graphql_multiple_errors.json">

<violation number="1" location="tests/fixtures/error_catalogue/graphql_multiple_errors.json:4">
P3: This fixture uses the `graphql_*` prefix, which the README in this same directory reserves for captured server responses: "kept in the shape the server sends rather than hand-shaped to suit the parser. A fixture authored against the parser can only prove the parser agrees with itself." The messages here are synthetic ("first failure", "second failure", "third failure") and no entry carries `locations` or `path` — every other `graphql_*`/`codes/` envelope includes at least `locations`, and real GraphQL responses always do. Since this file exists only to drive the parser/CLI ordering tests and fails the README's capture contract, a maintainer scanning the directory for trustworthy server shapes can be misled. Either shape it like the existing envelopes (realistic messages plus `locations`/`path`) or document it as a constructed scenario so the prefix keeps its meaning.</violation>
</file>

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

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/quickstart.md:173">
P2: This negative check will not fail for a stale `infrahub_sdk/exceptions/catalogue.py` on the documented Infrahub checkout. Use a validator that checks the generated exception binding, or state that this scenario requires the separate generator integration to be present.</violation>
</file>

<file name="infrahub_sdk/exceptions/factory.py">

<violation number="1" location="infrahub_sdk/exceptions/factory.py:205">
P2: Unknown codes and known codes with invalid payloads lose the generic fallback message and are shown as `CODE: server message`. Restrict `_named_by_code` to successfully catalogued exceptions, leaving the generic fallback message unchanged.</violation>
</file>

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

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md:16">
P2: `ApiError` does not catch status-only server rejections: `URLNotFoundError` and `RateLimitError` inherit directly from `Error`. Qualify this row to failures carrying a parsed error envelope.</violation>

<violation number="2" location="dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md:93">
P2: Parsed authentication responses populate `code`, `http_status`, `extensions`, and `errors`; only `query` and `variables` remain unset. Document that these fields can be populated instead of describing every `AuthenticationError` as empty.</violation>

<violation number="3" location="dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md:118">
P3: Unknown string codes on the authentication transport fall back to `AuthenticationError`, but `authentication_error_from_response` logs only unresolved `None` codes. Limit this guarantee to GraphQL fallbacks or add the missing log.</violation>
</file>

<file name="changelog/+error-catalogue-envelope.changed.md">

<violation number="1" location="changelog/+error-catalogue-envelope.changed.md:3">
P3: The final sentence of this paragraph inaccurately describes the old behavior for a JSON body with no `errors` key: that only applied to the client request path. The old `object_store.py` and `file_handler.py` code read `errors = response.get("errors")` without a default and then iterated it, so that shape raised `TypeError` ('NoneType' object is not iterable), not `AuthenticationError` — unlike the old client code, which used `response.get("errors", [])` and so landed in `AuthenticationError("")` with the generic default message. Since the changelog elsewhere distinguishes per-site behavior (JsonDecodeError vs json.JSONDecodeError) and tells callers what to catch at each site, this sentence should attribute the no-`errors`-key outcome to the client request path only, or state the object-store/file-handler TypeError.</violation>
</file>

<file name="changelog/+api-error-carries-the-request.added.md">

<violation number="1" location="changelog/+api-error-carries-the-request.added.md:10">
P3: `ApiError` is introduced in this PR, so these fields were not previously declared on it. Describe them as declared alongside `query` and `variables` on the new base.</violation>

<violation number="2" location="changelog/+api-error-carries-the-request.added.md:12">
P3: The GraphQL transport has `query` and `variables` in scope when it handles a 401; the auth-error factory simply receives only the response. Say the factory does not attach request details rather than claiming the transport has neither to hand.</violation>
</file>

<file name="tests/unit/sdk/test_exceptions.py">

<violation number="1" location="tests/unit/sdk/test_exceptions.py:275">
P3: The per-code envelopes in `TestAnAdoptedCodeRaisesItsOwnClass` and `TestMessages` are inlined as dict literals even though the repository already carries canonical raw envelopes for the same codes under `tests/fixtures/error_catalogue/codes/` (node_not_found.json, branch_not_found.json, schema_not_found.json, uniqueness_violation.json), consumed via the same `load_envelope` helper this file defines. The two sources have already drifted: `codes/branch_not_found.json` declares `http_status: 400` while the inline branch envelope here says 404, and the inline NODE_NOT_FOUND message wording differs from the fixture. Load these from the fixtures directory instead so the dispatched-class tests exercise the same envelopes the `codes/` fixtures document.</violation>
</file>

<file name="infrahub_sdk/exceptions/catalogue.py">

<violation number="1" location="infrahub_sdk/exceptions/catalogue.py:239">
P3: Every generated `from_payload` forwards only the payload fields and leaves `errors`, `query`, `variables`, and `message` at their defaults, so an exception built through it (a documented public classmethod, and reachable via the exported `exception_from_payload`) carries `errors=[]`, `query=None` and the placeholder message "An error occurred while executing the GraphQL Query None, []". The GraphQL factory masks this only when a governing message exists; a direct caller of `from_payload`/`exception_from_payload` gets the placeholder and empty envelope attributes that `ApiError` is meant to expose. Where a class has no sentence of its own (`_has_no_sentence_of_its_own`), have `from_payload` accept and forward `errors`, `query`, and `variables` so the message reflects the envelope it was built from.</violation>

<violation number="2" location="infrahub_sdk/exceptions/catalogue.py:530">
P3: `CODE_TO_EXCEPTION`, `CODE_TO_DATA_MODEL`, and `_CODE_TO_BUILDER` enumerate the catalogue codes in three separate hand-kept tables, and nothing in this PR or its tests asserts the keys agree. `test_the_cases_cover_every_code_the_bindings_carry` only checks `CODE_TO_DATA_MODEL` against the test table, and `test_exceptions_public_names.py` only checks the export surface. A code added to one map but not another silently takes the generic fallback or resolves to a class that the public maps don't describe, with only a `LOGGER.debug` in the factory to signal it. Since the drift tests for the generator land separately (#10678), add an assertion here (e.g. `set(CODE_TO_EXCEPTION) == set(_CODE_TO_BUILDER)` and `_CODE_TO_BUILDER ⊆ CODE_TO_DATA_MODEL`) so a regeneration mismatch surfaces as a test failure, not an unrecognized code at runtime.</violation>
</file>

<file name="dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md">

<violation number="1" location="dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md:90">
P3: The parenthetical in E7's suggestion claims `analyzer.py:42` already produces `errors` as a string, but at both the merge base and HEAD, `infrahub_sdk/analyzer.py:42` is `return False, [GraphQLError("Schema is not provided")]` — a list of `graphql.GraphQLError` objects, never a string. The claim misanchors the malformed-`errors` example in this critique document, which otherwise takes care to cite real code.</violation>
</file>

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

Re-trigger cubic

Comment on lines +258 to +259
REST envelope's integer `code` MUST NOT be surfaced through it; the HTTP status is already available
separately.

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.

P2: Specify which status http_status exposes for each transport. The spec distinguishes catalogue status from wire status but leaves REST and pre-catalogue responses without a defined value, so consumers cannot rely on this part of the ApiError contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/spec.md, line 258:

<comment>Specify which status `http_status` exposes for each transport. The spec distinguishes catalogue status from wire status but leaves REST and pre-catalogue responses without a defined value, so consumers cannot rely on this part of the `ApiError` contract.</comment>

<file context>
@@ -0,0 +1,501 @@
+  bindings.
+- **FR-003**: The code attribute MUST always exist, holding either a catalogue code string or `None`.
+  Reading it MUST NOT raise, so a consumer can test it without first testing which class it holds. The
+  REST envelope's integer `code` MUST NOT be surfaced through it; the HTTP status is already available
+  separately.
+- **FR-004**: Payload parsing MUST tolerate unknown fields, which is the inverse of the server's
</file context>
Suggested change
REST envelope's integer `code` MUST NOT be surfaced through it; the HTTP status is already available
separately.
The REST envelope's integer `code` MUST NOT be surfaced through it; `http_status` MUST use the observed
HTTP status for REST and `extensions.http_status` for GraphQL when present, otherwise the observed HTTP status.


# A server that did not describe the failure is one whose catalogue has no entry for it yet,
# which is the only tolerable reason to stop here.
if not code_names_the_failure(exc_info.value.code):

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.

P2: This skips on any missing or UNDEFINED_ERROR code, not only when the server predates the catalogue, so a code-parsing regression on a catalogue-capable server makes the test pass as skipped. Gate the skip on a positively identified pre-catalogue server; otherwise fail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/integration/test_infrahub_client.py, line 225:

<comment>This skips on any missing or `UNDEFINED_ERROR` code, not only when the server predates the catalogue, so a code-parsing regression on a catalogue-capable server makes the test pass as skipped. Gate the skip on a positively identified pre-catalogue server; otherwise fail.</comment>

<file context>
@@ -200,6 +207,45 @@ async def test_query_unexisting_branch(self, client: InfrahubClient) -> None:
+
+        # A server that did not describe the failure is one whose catalogue has no entry for it yet,
+        # which is the only tolerable reason to stop here.
+        if not code_names_the_failure(exc_info.value.code):
+            pytest.skip("this server's catalogue has no entry for a uniqueness violation")
+
</file context>

Comment on lines +165 to +166
**GraphQL** (`/graphql`) — the catalogue envelope. Data errors arrive as HTTP 200 with an `errors`
array; auth failures arrive as a real 401/403.

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.

P2: Pre-catalogue /graphql responses can also carry an integer extensions.code, which resolves to None; calling GraphQL “the catalogue envelope” obscures this supported input. Document the integer-code fallback here.

(Based on your team's feedback about GraphQL code shape. .)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/data-model.md, line 165:

<comment>Pre-catalogue `/graphql` responses can also carry an integer `extensions.code`, which resolves to `None`; calling GraphQL “the catalogue envelope” obscures this supported input. Document the integer-code fallback here.

(Based on your team's feedback about GraphQL code shape. .)</comment>

<file context>
@@ -0,0 +1,189 @@
+
+Both shapes are read; only one is a catalogue envelope.
+
+**GraphQL** (`/graphql`) — the catalogue envelope. Data errors arrive as HTTP 200 with an `errors`
+array; auth failures arrive as a real 401/403.
+
</file context>
Suggested change
**GraphQL** (`/graphql`) — the catalogue envelope. Data errors arrive as HTTP 200 with an `errors`
array; auth failures arrive as a real 401/403.
**GraphQL** (`/graphql`) — current servers send catalogue envelopes, but pre-catalogue servers may
send an integer `extensions.code`, which resolves to `exc.code is None`. Data errors arrive as HTTP 200
with an `errors` array; auth failures arrive as a real 401/403.


**Decision**:

- `handle_exception` gains a branch *above* the class-based ladder, keyed on `exc.code is not None` —

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.

P2: Key this branch on code_names_the_failure(exc.code), not non-None: UNDEFINED_ERROR is readable but does not describe the failure, so it must retain the legacy rendering and error details.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/research.md, line 648:

<comment>Key this branch on `code_names_the_failure(exc.code)`, not non-`None`: `UNDEFINED_ERROR` is readable but does not describe the failure, so it must retain the legacy rendering and error details.</comment>

<file context>
@@ -0,0 +1,819 @@
+
+**Decision**:
+
+- `handle_exception` gains a branch *above* the class-based ladder, keyed on `exc.code is not None` —
+  any server-reported error carrying a catalogue code — which renders the code and the server's
+  message, plus the GraphQL path where server errors exist. An error with no code falls through to
</file context>

elif (node.level == 1 and not node.module) or node.module == PACKAGE:
# `from . import factory`, or the same written out in full. The names here are a mix
# of submodules and of the classes the facade re-exports, so only the former count.
targets.extend((alias.name, node.lineno) for alias in node.names if alias.name in module_names)

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.

P2: Imports of re-exported classes through the package facade bypass the layer check, so base.py could import a facade symbol without this test detecting an upward dependency or import cycle. Record non-submodule names from these imports as __init__ targets so the layer ordering rejects them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/unit/sdk/test_exceptions_layering.py, line 61:

<comment>Imports of re-exported classes through the package facade bypass the layer check, so `base.py` could import a facade symbol without this test detecting an upward dependency or import cycle. Record non-submodule names from these imports as `__init__` targets so the layer ordering rejects them.</comment>

<file context>
@@ -0,0 +1,189 @@
+            elif (node.level == 1 and not node.module) or node.module == PACKAGE:
+                # `from . import factory`, or the same written out in full. The names here are a mix
+                # of submodules and of the classes the facade re-exports, so only the former count.
+                targets.extend((alias.name, node.lineno) for alias in node.names if alias.name in module_names)
+            elif node.level == 0 and node.module:
+                # `from infrahub_sdk.exceptions.factory import x`
</file context>
Suggested change
targets.extend((alias.name, node.lineno) for alias in node.names if alias.name in module_names)
targets.extend((alias.name if alias.name in module_names else "__init__", node.lineno) for alias in node.names)

super().__init__(errors=errors or [], query=query, variables=variables, message=message)

@classmethod
def from_payload(cls, payload: AttributeConstraintViolationData) -> Self:

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.

P3: Every generated from_payload forwards only the payload fields and leaves errors, query, variables, and message at their defaults, so an exception built through it (a documented public classmethod, and reachable via the exported exception_from_payload) carries errors=[], query=None and the placeholder message "An error occurred while executing the GraphQL Query None, []". The GraphQL factory masks this only when a governing message exists; a direct caller of from_payload/exception_from_payload gets the placeholder and empty envelope attributes that ApiError is meant to expose. Where a class has no sentence of its own (_has_no_sentence_of_its_own), have from_payload accept and forward errors, query, and variables so the message reflects the envelope it was built from.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/exceptions/catalogue.py, line 239:

<comment>Every generated `from_payload` forwards only the payload fields and leaves `errors`, `query`, `variables`, and `message` at their defaults, so an exception built through it (a documented public classmethod, and reachable via the exported `exception_from_payload`) carries `errors=[]`, `query=None` and the placeholder message "An error occurred while executing the GraphQL Query None, []". The GraphQL factory masks this only when a governing message exists; a direct caller of `from_payload`/`exception_from_payload` gets the placeholder and empty envelope attributes that `ApiError` is meant to expose. Where a class has no sentence of its own (`_has_no_sentence_of_its_own`), have `from_payload` accept and forward `errors`, `query`, and `variables` so the message reflects the envelope it was built from.</comment>

<file context>
@@ -0,0 +1,640 @@
+        super().__init__(errors=errors or [], query=query, variables=variables, message=message)
+
+    @classmethod
+    def from_payload(cls, payload: AttributeConstraintViolationData) -> Self:
+        """Build the exception from the validated payload of a server-reported failure."""
+        return cls(
</file context>


# Codes that map to a dedicated exception class. Authentication and permission codes are absent: the
# SDK raises a generic class for those and carries the code on the instance.
CODE_TO_EXCEPTION: dict[str, type[GraphQLError]] = {

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.

P3: CODE_TO_EXCEPTION, CODE_TO_DATA_MODEL, and _CODE_TO_BUILDER enumerate the catalogue codes in three separate hand-kept tables, and nothing in this PR or its tests asserts the keys agree. test_the_cases_cover_every_code_the_bindings_carry only checks CODE_TO_DATA_MODEL against the test table, and test_exceptions_public_names.py only checks the export surface. A code added to one map but not another silently takes the generic fallback or resolves to a class that the public maps don't describe, with only a LOGGER.debug in the factory to signal it. Since the drift tests for the generator land separately (#10678), add an assertion here (e.g. set(CODE_TO_EXCEPTION) == set(_CODE_TO_BUILDER) and _CODE_TO_BUILDER ⊆ CODE_TO_DATA_MODEL) so a regeneration mismatch surfaces as a test failure, not an unrecognized code at runtime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/exceptions/catalogue.py, line 530:

<comment>`CODE_TO_EXCEPTION`, `CODE_TO_DATA_MODEL`, and `_CODE_TO_BUILDER` enumerate the catalogue codes in three separate hand-kept tables, and nothing in this PR or its tests asserts the keys agree. `test_the_cases_cover_every_code_the_bindings_carry` only checks `CODE_TO_DATA_MODEL` against the test table, and `test_exceptions_public_names.py` only checks the export surface. A code added to one map but not another silently takes the generic fallback or resolves to a class that the public maps don't describe, with only a `LOGGER.debug` in the factory to signal it. Since the drift tests for the generator land separately (#10678), add an assertion here (e.g. `set(CODE_TO_EXCEPTION) == set(_CODE_TO_BUILDER)` and `_CODE_TO_BUILDER ⊆ CODE_TO_DATA_MODEL`) so a regeneration mismatch surfaces as a test failure, not an unrecognized code at runtime.</comment>

<file context>
@@ -0,0 +1,640 @@
+
+# Codes that map to a dedicated exception class. Authentication and permission codes are absent: the
+# SDK raises a generic class for those and carries the code on the instance.
+CODE_TO_EXCEPTION: dict[str, type[GraphQLError]] = {
+    "ATTRIBUTE_CONSTRAINT_VIOLATION": AttributeConstraintViolationError,
+    "ATTRIBUTE_INVALID_TYPE": AttributeInvalidTypeError,
</file context>

Comment thread dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md Outdated

| ID | Severity | Finding | Suggestion |
|----|----------|---------|------------|
| E7 | 💡 | The factory sits on the failure path of every client method, which makes it the highest-blast-radius code in the change: an unexpected exception inside it replaces a legitimate server error with an SDK `TypeError`, and the original failure is lost. The plan guards payload validation but not the resolution logic around it. | Make the factory total: wrap resolution in a `try/except Exception` that falls back to constructing today's generic error, so a factory bug degrades to current behaviour instead of masking the server's error. Test it by feeding the factory a deliberately malformed envelope (`errors` as a string — which `analyzer.py:42` already produces, and `extensions` as a list). |

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.

P3: The parenthetical in E7's suggestion claims analyzer.py:42 already produces errors as a string, but at both the merge base and HEAD, infrahub_sdk/analyzer.py:42 is return False, [GraphQLError("Schema is not provided")] — a list of graphql.GraphQLError objects, never a string. The claim misanchors the malformed-errors example in this critique document, which otherwise takes care to cite real code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md, line 90:

<comment>The parenthetical in E7's suggestion claims `analyzer.py:42` already produces `errors` as a string, but at both the merge base and HEAD, `infrahub_sdk/analyzer.py:42` is `return False, [GraphQLError("Schema is not provided")]` — a list of `graphql.GraphQLError` objects, never a string. The claim misanchors the malformed-`errors` example in this critique document, which otherwise takes care to cite real code.</comment>

<file context>
@@ -0,0 +1,630 @@
+
+| ID | Severity | Finding | Suggestion |
+|----|----------|---------|------------|
+| E7 | 💡 | The factory sits on the failure path of every client method, which makes it the highest-blast-radius code in the change: an unexpected exception inside it replaces a legitimate server error with an SDK `TypeError`, and the original failure is lost. The plan guards payload validation but not the resolution logic around it. | Make the factory total: wrap resolution in a `try/except Exception` that falls back to constructing today's generic error, so a factory bug degrades to current behaviour instead of masking the server's error. Test it by feeding the factory a deliberately malformed envelope (`errors` as a string — which `analyzer.py:42` already produces, and `extensions` as a list). |
+| E8 | 💡 | The wire `extensions.http_status` can legitimately differ from the code's declared status: `api/exception_handlers.py:26-27` overwrites a catalogue 500 with the actual FastAPI status, so an `UNDEFINED_ERROR` can arrive declaring 500 while carrying 422. The plan asserts `exc.http_status` is the catalogue value (matching US1 AS3) without noting the observable divergence. | Keep `exc.http_status` as the catalogue value, and state in the contract that the wire value remains available via `exc.extensions["http_status"]` and may differ for `UNDEFINED_ERROR`. One sentence prevents a confusing bug report. |
+
</file context>
Suggested change
| E7 | 💡 | The factory sits on the failure path of every client method, which makes it the highest-blast-radius code in the change: an unexpected exception inside it replaces a legitimate server error with an SDK `TypeError`, and the original failure is lost. The plan guards payload validation but not the resolution logic around it. | Make the factory total: wrap resolution in a `try/except Exception` that falls back to constructing today's generic error, so a factory bug degrades to current behaviour instead of masking the server's error. Test it by feeding the factory a deliberately malformed envelope (`errors` as a string — which `analyzer.py:42` already produces, and `extensions` as a list). |
Test it by feeding the factory a deliberately malformed envelope (`errors` as a string, `extensions` as a list).


#### Messages

- **FR-022**: A server-reported catalogued error's message MUST name the code and the server's message,

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.

P3: FR-022 requires "a server-reported catalogued error's message" to name the code and quote the server's message with no query text, but UNDEFINED_ERROR is a catalogue code (it carries UndefinedErrorData and an UndefinedError class in the generated bindings, declared http_status 500) whose message is deliberately left byte-identical to today's, query text included, with the code readable but not shaping the message — as the spec's own "UNDEFINED_ERROR is a code, not the absence of one" edge case and the PR description acknowledge. A literal implementer of FR-022 would reverse the shipped behavior (see the asserting test in infrahub_sdk/exceptions/base.py code_names_the_failure and tests/unit/sdk/test_exceptions.py:394). Scope FR-022 to described codes: exclude UNDEFINED_ERROR explicitly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/ifc-3034-error-catalogue/spec.md, line 399:

<comment>FR-022 requires "a server-reported catalogued error's message" to name the code and quote the server's message with no query text, but UNDEFINED_ERROR is a catalogue code (it carries UndefinedErrorData and an UndefinedError class in the generated bindings, declared http_status 500) whose message is deliberately left byte-identical to today's, query text included, with the code readable but not shaping the message — as the spec's own "UNDEFINED_ERROR is a code, not the absence of one" edge case and the PR description acknowledge. A literal implementer of FR-022 would reverse the shipped behavior (see the asserting test in infrahub_sdk/exceptions/base.py `code_names_the_failure` and tests/unit/sdk/test_exceptions.py:394). Scope FR-022 to described codes: exclude UNDEFINED_ERROR explicitly.</comment>

<file context>
@@ -0,0 +1,501 @@
+
+#### Messages
+
+- **FR-022**: A server-reported catalogued error's message MUST name the code and the server's message,
+  and MUST NOT embed the query text. Where one of the unified classes is raised with no catalogue code
+  behind it — a client-side lookup miss, or the REST 404 the file handler turns into a
</file context>
Suggested change
- **FR-022**: A server-reported catalogued error's message MUST name the code and the server's message,
**FR-022**: A server-reported error carrying a *described* catalogue code — a code other than `UNDEFINED_ERROR`, which names no failure — MUST have a message that names the code and the server's message, and MUST NOT embed the query text.

Re-rooting the not-found classes under `GraphQLError` put them inside the
`except GraphQLError` that the transform path uses, where the renderer prints
the server error list a lookup miss does not have and the message it does have
goes unshown. They now propagate to the shared handler, which has a branch for
them and orders it ahead of `GraphQLError` for this reason.

That clause also aborted rather than exited. `Abort` is an ordinary exception
with no branch in the shared handler, so it reached the tail that prints
`Error:` and dumps a traceback, after the command had already rendered the real
failure. Exiting leaves the rendered message as the last thing the user sees.

Four records corrected alongside: the data model counted twelve generated
classes where nine are generated and three adopted; the generator contract
credited `__all__` with sparing the facade a hand-written list it in fact
keeps; the typed-errors entry promised a declared-status fallback the three
lookup-miss classes do not have; and an inline envelope gave BRANCH_NOT_FOUND a
404 where its own fixture and the catalogue both say 400.
@ogenstad
ogenstad marked this pull request as ready for review September 30, 2026 13:27
@ogenstad
ogenstad requested a review from a team as a code owner September 30, 2026 13:27
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.

1 participant