diff --git a/changelog/+adopted-codes-raise-their-own-class.changed.md b/changelog/+adopted-codes-raise-their-own-class.changed.md new file mode 100644 index 000000000..080add630 --- /dev/null +++ b/changelog/+adopted-codes-raise-their-own-class.changed.md @@ -0,0 +1,12 @@ +A failure the server reports as `NODE_NOT_FOUND`, `BRANCH_NOT_FOUND`, or `SCHEMA_NOT_FOUND` now raises `NodeNotFoundError`, `BranchNotFoundError`, or `SchemaNotFoundError` respectively, built from the payload the server sent, instead of a generic `GraphQLError`. One class therefore covers a lookup miss however it arose, and telling one apart from any other GraphQL failure no longer means matching words in a message: + +```python +try: + await client.delete(kind="NetworkDevice", id=device_id) +except NodeNotFoundError: + ... # already gone +``` + +This applies where the envelope carries the payload fields that the code's class needs. A server predating the error catalogue, or one whose payload the SDK cannot read, still raises `GraphQLError` as it does today, so keep any existing fallback until you no longer talk to such a server. + +Each of the three classes is caught by `except GraphQLError` exactly as the generic one is, so no clause stops catching what it catches today. A ladder that handles a specific class differently will now see server-reported failures arrive there as well as client-side ones; `exc.code is not None` distinguishes the two. diff --git a/changelog/+api-token-no-relogin-retry.fixed.md b/changelog/+api-token-no-relogin-retry.fixed.md new file mode 100644 index 000000000..3eb5a4f47 --- /dev/null +++ b/changelog/+api-token-no-relogin-retry.fixed.md @@ -0,0 +1 @@ +A client authenticating with an API token no longer replays a request after a 401 that reports an expired token. Only a client configured with a username and password can obtain a new token, so on the request paths that retry automatically the retry sent the same rejected token a second time, doubling the cost of the failure. Streaming downloads never retried and are unaffected. diff --git a/changelog/+catalogued-error-messages.changed.md b/changelog/+catalogued-error-messages.changed.md new file mode 100644 index 000000000..57b14b8b3 --- /dev/null +++ b/changelog/+catalogued-error-messages.changed.md @@ -0,0 +1,5 @@ +A failure the server's error catalogue describes now carries a message naming that code and the server's own words, in place of the query text. `GraphQLError` previously rendered as `An error occurred while executing the GraphQL Query , ` and now reads `UNIQUENESS_VIOLATION: Node of kind TestPerson already has name 'John'`; `AuthenticationError` reads `AUTHENTICATION_REQUIRED: `. On both transports the code names the first error only, which is the one that determines the exception raised; the complete list stays on `exc.errors`, and the query and variables stay on `exc.query` and `exc.variables`. + +A failure the catalogue does **not** describe keeps today's message exactly, query text and full error list included, as does one of the lookup-miss classes raised without a server behind it. That covers a server predating the catalogue, an error carrying no `extensions`, and, since a current server codes every error it reports, anything the server coded `UNDEFINED_ERROR`. `exc.code` is readable in every case, so it is not the test for which message form you are holding: `code_names_the_failure(exc.code)`, importable from `infrahub_sdk.exceptions`, is. + +In `infrahubctl`, a described failure is now reported by its code and message rather than prefixed with `Authentication failure:` or rendered as a bare error list. An undescribed one renders exactly as before, so a GraphQL validation error still shows the line and column it failed on. Code matching on any of these strings should branch on `exc.code` instead. diff --git a/changelog/+ctl-error-markup-escaping.fixed.md b/changelog/+ctl-error-markup-escaping.fixed.md new file mode 100644 index 000000000..b4ca83b3a --- /dev/null +++ b/changelog/+ctl-error-markup-escaping.fixed.md @@ -0,0 +1 @@ +`infrahubctl` no longer deletes bracketed text from an error message. A message such as `The requested branch was not found on the server [main]` was printed without the branch name, because rich read the brackets as a style tag. Everything the shared error handler prints is now escaped, including the traceback the fallback branch emits. Affects branch, schema, and node lookup misses, authentication failures, HTTP transport failures, and the fallback handler. diff --git a/changelog/+ctl-graphql-error-output.fixed.md b/changelog/+ctl-graphql-error-output.fixed.md new file mode 100644 index 000000000..5dc1f1239 --- /dev/null +++ b/changelog/+ctl-graphql-error-output.fixed.md @@ -0,0 +1 @@ +`infrahubctl` no longer exits non-zero with no output when a GraphQL failure arrives in a shape the SDK cannot read as an error envelope. `infrahubctl run`, `infrahubctl validate graphql-query`, and every command wrapped by the shared error handler now fall back to printing the exception's message, which keeps the server's payload verbatim. diff --git a/changelog/+error-catalogue-envelope.changed.md b/changelog/+error-catalogue-envelope.changed.md new file mode 100644 index 000000000..abcfbbb20 --- /dev/null +++ b/changelog/+error-catalogue-envelope.changed.md @@ -0,0 +1,5 @@ +Authentication failures now surface the server's error envelope. `AuthenticationError` and `GraphQLError` share a new `ApiError` base carrying `code`, `http_status`, `extensions`, and `errors`, so a caller can branch on the server's catalogue code instead of matching on message text. + +A 401 or 403 whose body the SDK cannot read as an error envelope now raises `AuthenticationError` carrying the best reason available: the REST API's bare `detail` string where the body has one, and otherwise the plain status. Previously the same responses raised `JsonDecodeError` (or, from the object store and file handler, a raw `json.JSONDecodeError`) when the body was not JSON, and `TypeError` when the body carried an `errors` array whose entries had no `message`. A body that was JSON but carried no `errors` key raised `AuthenticationError` with its generic default message, dropping the status the server sent. Code that catches those types around `object_store`, `file_handler`, or a client request should catch `AuthenticationError` instead. + +`from infrahub_sdk.exceptions import *` now yields exactly the exception classes. It previously also carried whatever the module imported for its own annotations, such as `Mapping` and `Any`. Every exception class keeps its name and its import path. diff --git a/changelog/+except-graphql-error-broadened.changed.md b/changelog/+except-graphql-error-broadened.changed.md new file mode 100644 index 000000000..ef7847af8 --- /dev/null +++ b/changelog/+except-graphql-error-broadened.changed.md @@ -0,0 +1,5 @@ +`NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError` now descend from `GraphQLError`, so that one class covers a lookup miss however it arose: reported by the server, decided by the SDK, or turned from a REST 404. + +An `except GraphQLError` clause therefore also catches lookup misses that involved no GraphQL request at all. Code that relied on those escaping such a clause should catch the specific class ahead of it, as an ordered `except` ladder already must. Each class keeps its name, its constructor, and the message it produces when no server reported the failure, and `errors`, `query`, and `variables` are now readable on every one of them rather than missing on a client-side raise. + +`NodeInvalidError` inherits the re-rooting but not the adopted code: it means a node of the wrong kind rather than a lookup miss, so `NodeInvalidError.CODE` is `None` where `NodeNotFoundError.CODE` is `NODE_NOT_FOUND`. diff --git a/changelog/+file-handler-404-body.fixed.md b/changelog/+file-handler-404-body.fixed.md new file mode 100644 index 000000000..bd287623a --- /dev/null +++ b/changelog/+file-handler-404-body.fixed.md @@ -0,0 +1 @@ +A file download answered with HTTP 404 now raises `NodeNotFoundError` whatever the response body contains. Previously a body that was not a JSON object - an HTML error page from an intermediary, an empty body, or a JSON array - escaped as a raw `json.JSONDecodeError` or `AttributeError` instead. diff --git a/changelog/+node-not-found-identifier-widened.changed.md b/changelog/+node-not-found-identifier-widened.changed.md new file mode 100644 index 000000000..9883b99c0 --- /dev/null +++ b/changelog/+node-not-found-identifier-widened.changed.md @@ -0,0 +1 @@ +`NodeNotFoundError.identifier` is now annotated `Mapping[str, list[str]] | str`. The SDK already raised it with a plain string to name a missing file, so this documents behaviour that was always there; no runtime behaviour changes and no existing caller needs updating. diff --git a/changelog/+relogin-non-object-body.fixed.md b/changelog/+relogin-non-object-body.fixed.md new file mode 100644 index 000000000..5342beee8 --- /dev/null +++ b/changelog/+relogin-non-object-body.fixed.md @@ -0,0 +1 @@ +Fixed an `AttributeError` escaping the client when a 401 response carried a body that was valid JSON but not an object, such as the bare array or string a proxy or gateway may return. The silent token refresh now treats any body it cannot read as an envelope as carrying no refresh signal, and the request surfaces `AuthenticationError` as it does for every other unreadable 401. diff --git a/dev/specs/ifc-3034-error-catalogue/checklists/requirements.md b/dev/specs/ifc-3034-error-catalogue/checklists/requirements.md new file mode 100644 index 000000000..d2178078c --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/checklists/requirements.md @@ -0,0 +1,60 @@ +# Specification Quality Checklist: Error Catalogue in the Python SDK + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-21 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +Two checklist items were resolved by scoping rather than by rewriting, and the reasoning is recorded +here so the plan phase does not relitigate it: + +- **"No implementation details" / "written for non-technical stakeholders"** — for a library, the + exception hierarchy *is* the user-facing product, so class names, catalogue codes, and the + transport split are domain vocabulary rather than implementation leakage. The spec names those and + deliberately withholds module layout, file names, generator implementation, and test mechanics. + Recorded as an explicit assumption in the spec rather than left implicit. +- **"Success criteria are technology-agnostic"** — SC-001 through SC-008 are stated as outcomes a + consumer or reviewer can verify (a failure is handleable without reading a message; no string + matching remains; a stale artefact fails validation) rather than as internal mechanics. They do + reference exceptions and catalogue codes, which is unavoidable and correct for this feature. + +Two items were originally deferred to the plan and have since been pulled back into the spec, both +prompted by automated review of the pull request: + +- **The `identifier` contract on the unified `NodeNotFoundError`.** Deferring the whole question was + wrong: *which* attributes a consumer can read is observable API surface and belongs here, even + though the mechanism does not. FR-016 now pins the contract — every construction shape in use today + keeps working, the server-reported kind and identifier are reachable, one documented accessor works + for both cases, and any type widening is called out in release notes. Surveying the code for this + also turned up that the attribute is *already* heterogeneous: the file handler passes a plain string + where the declared type is a mapping. +- **Multi-error precedence.** FR-013 originally required only that a rule exist, which is untestable + until the rule does. It now specifies that the first error in the response governs, with the + complete list retained, and records why first-*recognised* was rejected: it would make the raised + type depend on binding freshness rather than on the response. diff --git a/dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md b/dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md new file mode 100644 index 000000000..ef7ff6f27 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md @@ -0,0 +1,177 @@ +# Contract: The exception hierarchy + +The SDK's public interface here is the set of names a consumer can import from +`infrahub_sdk.exceptions`, catch, and read attributes off. This is what the change promises. + +`infrahub_sdk.exceptions` is the supported import path for every exception the SDK raises, generated or +hand-written. A consumer never needs to know which module inside it defines a given class, and the +modules beneath it are internal. Every name importable from `infrahub_sdk.exceptions` before this +change is still importable from it afterwards, pinned by a test against a committed snapshot rather +than asserted. + +## Catching + +| Intent | Clause | +|--------|--------| +| Anything the server rejected, on either transport | `except ApiError` | +| Any GraphQL-path failure | `except GraphQLError` | +| Any failure the SDK observed as HTTP 401 or 403 | `except AuthenticationError` | +| One specific catalogued failure | `except UniquenessViolationError` (and so on per code) | +| One of the three authentication codes, whichever way it arrived | `except ApiError` then test `exc.code` | +| Anything the SDK raises | `except Error` | + +The hierarchy is a plain tree: `GraphQLError` and `AuthenticationError` are siblings under `ApiError`, +and no class has more than one parent. + +**The three authentication codes are the one asymmetry.** `AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, +and `PERMISSION_DENIED` have no class of their own, because they are the only codes that reach the SDK +on two different transports, and each transport already has a class an existing clause depends on: + +| Arrival | Class raised | `exc.code` | +|---------|--------------|------------| +| A real 401 or 403, when the failure escapes before query execution | `AuthenticationError`, as today | the catalogue code | +| Inside an HTTP 200 `errors` array, when a resolver raised it | `GraphQLError`, as today | the catalogue code | + +**Any of the three can arrive either way**, so the arrival path is a property of how the server +happened to fail, not of the code. Distinguish them by code, and catch `ApiError` unless you genuinely +want one arrival path only: + +Catch the code however it arrived — the form to reach for: + +```python +except ApiError as exc: + if exc.code == "TOKEN_EXPIRED": + ... +``` + +Or, to handle only the pre-execution arrivals from the table above, accepting that a resolver-raised +failure carrying the same code raises `GraphQLError` and escapes this clause: + +```python +except AuthenticationError as exc: + ... +``` + +These are alternatives, not a sequence. `AuthenticationError` descends from `ApiError`, so an +`except ApiError` clause placed first makes any later `except AuthenticationError` unreachable. + +So `except AuthenticationError` is not the clause that spans both arrival paths for *any* of the three +codes — `except ApiError` is. That is not a coverage loss: such a response raises `GraphQLError` today +too, so no existing clause stops catching anything it catches now. + +Every clause that worked before the change still catches what it caught before (FR-018). The +broadenings below are deliberate. They are not numbered, because adopting a further code adds one. + +- `except GraphQLError` now also catches node, branch, and schema lookup misses that involved no + GraphQL request at all — both the client-side ones and the REST 404 the file handler turns into a + `NodeNotFoundError` — because those classes are re-rooted under it. +- Code that catches a generic error to inspect its message will now sometimes receive a subclass whose + message names the code, or the same class carrying a described failure's message instead of the query + text. +- A failure the server reports under an adopted code, with a payload carrying the fields that code's + class needs, now raises that class rather than `GraphQLError`. A server-reported `NODE_NOT_FOUND` + reaches an `except NodeNotFoundError` clause that only saw client-side lookup misses before. The + specific class and `GraphQLError` are both caught by `except GraphQLError`, so no clause stops + catching what it catches now; a ladder that handles the specific class differently sees the + server-reported ones arrive there too, which is the point of adopting the code. + +## Reading a caught error + +Available on every `ApiError`: + +| Attribute | Contract | +|-----------|----------| +| `code` | The catalogue code string, or `None`. Never an integer. `None` means the SDK resolved no catalogue code — a pre-catalogue server, a REST failure, an error with no `extensions`, or an integer `code` on the wire. An unrecognised string code from a newer server is still readable here. | +| `http_status` | The status the governing error's `extensions` declares, or `None` when it declared none. This is metadata about the failure, not the status the transport observed — a catalogued data error arrives as HTTP 200, and the observed status is not carried on the exception at all. Once the generated classes land, a class's own declared status fills this in for a code whose envelope omitted it. The envelope's value is the catalogue's except where the catalogue could not resolve a status more specific than 500, in which case the server substitutes the HTTP status it is about to return — so a generated class's declared 500 and the envelope's value can differ. | +| the payload's fields | Not on the base. Each catalogued class carries its payload's fields as directly typed attributes — `UniquenessViolationError.node_kind` is a `str`, `.fields` a `list[str]` — typed exactly as the catalogue declares them, so a required field is never optional and needs no guard. The three exceptions are `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError`, whose attributes are optional because those classes are also raised with no catalogue code behind them — a client-side lookup miss, or the REST 404 that has a response but no code; guard on `exc.code is not None` there. The raw payload dict remains in `extensions["data"]` for anything forwarding it verbatim. | +| `extensions` | The raw `extensions` mapping of the governing error, or `None`. | +| `errors` | The complete server error list, unreordered — empty for a client-side raise. | +| `query`, `variables` | The GraphQL query and variables where there was one, otherwise `None`. | + +`errors`, `query`, and `variables` are readable on every `ApiError`, not only on those built from a +server response. A purely client-side `NodeNotFoundError` has an empty `errors` and `None` for the rest, +so code that catches `GraphQLError` and inspects them never has to guard for a missing attribute. + +`UNDEFINED_ERROR` is readable on `code` like any other: it means the server explicitly reported a gap +in its own catalogue, and it is not the same as an error carrying no `extensions`. It is the one code +that never shapes the message, because it describes nothing; see [Messages](#messages). + +A current server codes **every** error it reports, falling back to `UNDEFINED_ERROR` where its +catalogue has no entry, so `exc.code is not None` is not the test for "the server described this". +`code_names_the_failure(exc.code)` is, and it is importable from `infrahub_sdk.exceptions`. + +## Cross-version behaviour + +Any SDK version talks to any server version. Parsing never raises. + +| Situation | Behaviour | +|-----------|-----------| +| A code the SDK has a class for | That class is raised, built from the envelope's payload through its own `from_payload`. Until the generated bindings land, that is the three adopted codes (`NODE_NOT_FOUND`, `BRANCH_NOT_FOUND`, `SCHEMA_NOT_FOUND`) and every other code raises the generic class for the branch. | +| A code the SDK has never heard of | The generic class for the branch is raised — `GraphQLError` for data failures, `AuthenticationError` for 401/403 — with `code` set to the string the server sent. | +| A known code whose payload gained a field | The unknown field is ignored; behaviour is unchanged. | +| A server predating the catalogue, or an error with no `extensions` | Today's behaviour exactly; `code` is `None`. | +| An integer `code` on `/graphql` from a pre-catalogue server | Not surfaced as a catalogue code; `code` is `None`. | +| A payload that violates the catalogue's own contract | The generic class for the branch, with the code still readable. The specific class's attributes are typed as the catalogue declares them, so there is nothing to populate a required one with. | + +Every fallback above is logged at debug level with the code involved, so an SDK meeting a newer server +is diagnosable in the field rather than only in tests. + +Regenerating bindings buys typed handling of newly catalogued codes. It never changes which exception +a byte-identical response produces for a code the SDK already knows, because the first error in the +response governs unconditionally — not the first *recognised* one. + +## Multiple errors in one response + +The first error in the response determines the class raised. The complete list is retained on the +exception, unreordered, and nothing is discarded. If the first error carries no code and a later one +does, the generic class for the branch is raised. + +## Messages + +A **server-reported** failure the catalogue describes has a message naming the code and the governing +error's message, and contains no query text. A failure the catalogue does not describe - one with no +`extensions`, an integer `code`, or the code `UNDEFINED_ERROR` - keeps a message byte-identical to +today's, query text and full error list included. The query is available as an attribute in both cases. + +`UNDEFINED_ERROR` sits on the second side of that line deliberately. Since the server codes every +error it reports, treating it as described would apply the short message to every failure the +catalogue has no entry for, replacing the query text and the later errors with a code that says only +that the server had nothing to say. It stays readable on `exc.code`. + +Both transports follow the same rule about **which** message may be named beside a code: the governing +error's, which is the first one. Joining the rest would file them under a code that is not theirs; the +complete list stays on `exc.errors`. + +The qualifier matters because three catalogued classes can also be raised with **no catalogue code +behind them**: `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError`. That covers a +client-side lookup miss, which has no server response at all, and the REST 404 the file handler turns +into a `NodeNotFoundError`, which has a response and a message but no catalogue code, since REST carries +the legacy envelope. Both keep the message they produce today, because there is no code to name. As +everywhere else, `exc.code is not None` is the test for which case you are holding. + +Where the catalogue provides them, the server's message names the failing action and resource kind, so +that detail now appears in logs and CLI output in place of the query text that used to be there. + +## Parity + +The async and sync clients raise the same type with the same attributes for the same failure, for +every catalogued code. + +## Stability + +`infrahub_sdk.exceptions` is treated as public and is the one import path a consumer needs. That is a +stronger promise than the constitution's tiering strictly requires — only `Config`, `InfrahubClient`, +and `InfrahubClientSync` are exported at top level — and it is made deliberately, because +`infrahubctl`, the Ansible collection, and external consumers already import from it directly. + +Concretely: + +- No name is removed or renamed, and no constructor loses a signature it has today. +- Every name importable from `infrahub_sdk.exceptions` before this change remains importable from it, + which a test pins against a committed snapshot. Restructuring the module into a package must not be + observable from the outside. +- Modules beneath `infrahub_sdk.exceptions` are internal. Importing `…exceptions.catalogue` or + `…exceptions.payloads` directly is not supported, and their layout may change. +- One annotation widens: `NodeNotFoundError.identifier` becomes `Mapping[str, list[str]] | str`. It is + called out in a changelog fragment because external consumers read these attributes even though + nothing in this repository does. diff --git a/dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md b/dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md new file mode 100644 index 000000000..8e01749bf --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md @@ -0,0 +1,149 @@ +# Contract: Generating the SDK's error bindings + +One artefact crosses from the Infrahub repository into the SDK. This is the contract between the two +sides. The SDK holds no copy of the catalogue schema (FR-010). + +## The artefact + +- **Source**: `schema/error-catalogue.json` in the Infrahub repository. +- **Output**: `infrahub_sdk/exceptions/catalogue.py` in the SDK submodule. +- **Owner**: Infrahub. The SDK never regenerates it, exactly as it never regenerates `protocols.py` + or its schema models. +- **Committed**: yes. Generation happens in a pull request, not at install time, so catalogue drift is + visible in the diff. + +The file is generated **in full**. There is never a hand-edited region inside a generated file, nor a +generated region inside a hand-written one. + +## What the generated module contains + +It opens with a header marking it generated and not to be edited, naming the source artefact, recording +the catalogue's `infrahub_catalogue_version`, and giving the regeneration command (FR-009) — the same +marking style as the repository's other generated files. It declares `__all__`, which is what lets the +package façade re-export it without a hand-maintained list. + +The body holds three things: + +- One pydantic payload model per catalogue code, including codes with an empty payload, adopted codes, + and the 401/403 codes that get no class. These validate the envelope and supply the promoted + attributes' types. +- One exception class per catalogue code that is neither adopted by the SDK nor declares 401/403, each + promoting its payload's fields to directly typed attributes and exposing a `from_payload` + classmethod, and each with exactly one parent. +- `CODE_TO_EXCEPTION`, mapping every code that has a class to it — generated classes for most, + imported adopted classes for the rest. The 401/403 codes are deliberately absent, so the factory's + lookup misses and the transport rule raises the generic class carrying the code. + +It imports only `infrahub_sdk.exceptions.base`, which imports nothing from inside the package. That +keeps the package's import graph one-way with no cycle: `base` → `catalogue` → `factory` → the façade. + +Codes are emitted in sorted order so that reordering the catalogue's JSON does not churn the diff. + +## Derivation rules + +No hand-maintained per-code table exists on either side. Everything is derived from the catalogue +entry: + +| Output | Derived from | +|--------|--------------| +| Exception class name | The code's parts capitalised and joined, with `Error` appended only if it does not already end in `Error`. `UNDEFINED_ERROR` → `UndefinedError`. | +| Payload model name | `data_schema.title`, verbatim. | +| Whether a class is emitted | `http_status in {401, 403}` → no class, payload model only. Every other code → a class. | +| Base class | `GraphQLError`, always exactly one parent. | +| `http_status` class attribute | The catalogue's declared `http_status`. | +| Docstring | The catalogue's `description` and `stability`. | +| Promoted attribute names | The payload field names, verbatim. | +| Field and attribute types | The JSON Schema mapping in [data-model.md](../data-model.md); a required field is non-optional, a nullable one carries its declared default. | + +## Adoption + +Some catalogue codes are represented by a class the SDK already ships. Those classes declare the code +they represent with a `CODE` class attribute, typed `ClassVar[str | None]` so a subclass of an adopted +class can clear it. The generator parses `infrahub_sdk/exceptions/base.py` with `ast`, collects every +class whose body assigns a `CODE` string - **both `ast.Assign` and `ast.AnnAssign`, since the +declarations are annotated** - and for those codes emits an import and a map entry instead of a class +definition. A `CODE` assigned anything but a string constant, which is how a subclass disclaims an +inherited code, is not an adoption. The payload model is still generated. + +An adopted class supplies its own `from_payload`, since its attribute names are its existing ones +rather than the catalogue's. + +Adopting a further code later is a one-line change in the SDK's hand-written module with no generator +edit. Discovery is by parsing rather than importing, so the generator stays a pure text transform and +generation never depends on the SDK checkout being importable. The same walk collects every class name +defined in `base.py`, which is what the collision check below needs. Parsing also sidesteps a trap an +attribute walk would hit: a subclass of an adopted class would otherwise appear to claim the same code. +`NodeInvalidError` additionally clears `CODE` in its own body, so the two mechanisms agree and nothing +reading `exc.CODE` at runtime sees a wrong-kind result labelled as a lookup miss. + +## Failing loudly + +Generation aborts, rather than emitting a guess, when: + +- a catalogue entry has no integer `http_status`; +- a catalogue entry has no non-empty `data_schema.title`; +- a `data_schema` uses a construct outside the supported vocabulary, in which case the offending + fragment appears in the error; +- `codes` is empty or the root is not an object; +- a derived class name collides with a class already defined in `base.py` that has not declared that + code as adopted. + +The first four are the assertions the frontend generator already makes, for the same reason. The last +is specific to Python's import semantics: the SDK's façade re-exports `base` and then `catalogue`, so an +undeclared collision would let the generated class silently take the name and change what an existing +`except` clause catches. The SDK already defines `ValidationError`, `RateLimitError`, +`InvalidResponseError`, `FileNotValidError`, and `ResourceNotDefinedError` — every one of them the name +a plausible future code would derive — so this is a live hazard rather than a theoretical one. Failing +generation forces the choice (adopt the code, or rename) into the pull request that adds the code. + +## Validation + +No new command. `uv run invoke backend.generate` renders the artefact alongside the schema models and +the protocols — the other two things Infrahub generates into the submodule — and +`uv run invoke backend.validate-generated` verifies it with +`git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py`. The diff must run inside the +submodule, because from the superproject `git diff` only sees the submodule pointer, which is the same +reason the existing schema-model and protocol checks are written that way. + +The `backend` namespace owns this because it names the *producer*: Infrahub generates, the SDK receives. +Nothing outside it generates the SDK's bindings — in particular not +`frontend.regenerate-error-bindings`, which belongs to a sibling consumer of the same catalogue. + +In CI this needs no new step and no new path filter: `backend-validate-generated` already runs +`backend.validate-generated` and already checks out the submodule. The one edit is that job's trigger, +which gains `error_catalogue == 'true'` so a hand-edit of `schema/error-catalogue.json` alone cannot +slip past. + +Everything else is already covered, and by design rather than by luck: + +| Change | What CI sees | Filter that catches it | +|--------|--------------|------------------------| +| The committed bindings in the submodule | The `python_sdk` gitlink moving — never the inner path | `sdk_files`, commented in the repo as "Catch updates to the submodule commit", which feeds `backend_all` | +| The generator template | `backend/templates/generate_sdk_errors.j2` | `backend_files` (`backend/**`), which feeds `backend_all` | +| The catalogue JSON | `schema/error-catalogue.json` | `error_catalogue_files`, which already lists it | + +Note the first row: a path filter on `python_sdk/infrahub_sdk/exceptions/catalogue.py` would never match +anything. From the superproject a submodule is a single gitlink entry, so a change to a file inside it +appears only as a change to `python_sdk` — the same limitation that forces the `git diff` above to run +inside the submodule. + +**Across the catalogue's four derived artefacts, CI is what keeps the set in step.** The JSON, the +frontend bindings, the docs page, and the SDK bindings are each generated by their own owner; the +`error_catalogue` file filter gates all of their checks, so a catalogue change that skips any one of +them fails the pull request that made it. That is where the drift protection lives, not in any single +regenerate-everything command. + +Submodule availability is not a factor in that placement. Infrahub declares +`infrahub-sdk = { path = "python_sdk", editable = true }`, so `uv sync` fails without the submodule and +every Python job there already requires `submodules: true`. Any job that needs the submodule declares +it; the check goes where it belongs and the checkout follows. + +A catalogue change that skips regeneration therefore fails the pull request that made it (FR-026). +There is no release-time gate on either side; pull-request-time validation is the mechanism, matching +how the existing generated artefacts are treated (FR-027). + +## What regeneration does and does not buy + +Regenerating adds typed handling for newly catalogued codes. It never changes which exception a +byte-identical response produces for a code the SDK already knows, and correctness never depends on +having regenerated — an SDK with stale bindings falls back rather than failing. diff --git a/dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md b/dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md new file mode 100644 index 000000000..b17386fd5 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md @@ -0,0 +1,630 @@ +# Critique Report: Error Catalogue in the Python SDK + +**Date**: 2026-08-24 +**Feature**: [spec.md](../spec.md) +**Plan**: [plan.md](../plan.md) +**Verdict**: ⚠️ PROCEED WITH UPDATES + +--- + +## Executive Summary + +The spec is unusually strong for a library change: it names the user-facing contract (classes, codes, +attributes) while withholding mechanism, it enumerates hazards found by surveying real code rather than +hypotheticals, and FR-013's rationale for first-error precedence is the kind of reasoning that prevents +a whole class of later bugs. The plan is grounded — every decision cites the file it was derived from — +and the revised exceptions-package layering is a genuine solution to the cycle rather than a mitigation +of it. + +Three findings block task generation, all of them in the same family: the plan's treatment of what +happens when a class is re-rooted or resolved. **E1** is a latent `AttributeError` — the three adopted +classes become `GraphQLError` subclasses without ever setting `errors`, `query`, or `variables`, so any +consumer (or the CLI) reading `exc.errors` off a client-side lookup miss crashes; the plan's stated +mitigation assumes an empty list where the attribute is actually absent. **E2** is a verified conflict +between FR-008 and FR-018: Infrahub's `/graphql` app returns HTTP 200 for resolver-raised errors and its +formatter maps permission failures to `PERMISSION_DENIED`, so an auth-branch code really does arrive on +the data path — and deriving its parent solely from the declared status would make `except GraphQLError` +stop catching a response it catches today. **E3** is a contract inconsistency: the plan lets a malformed +payload downgrade the *raised type*, which contradicts FR-013's own reasoning that the type must be a +pure function of the response, and the server's payload builder has a reachable fallback that emits an +empty payload for codes whose schema declares required fields. + +None of the three requires rethinking the approach; each is a local correction to the plan, and two also +want a one-line clarification in the spec. + +--- + +## Product Lens Findings 🎯 + +### Problem Validation + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P1 | 💡 | Problem validation is well-evidenced (a spike, the backend catalogue, the frontend's generated bindings, and the SDK's own `"Expired Signature"` string match). Nothing to challenge on need. The gap is *sequencing*: US5 "bindings that cannot silently drift" is labelled P2, but the per-code classes US1 (P1) delivers are produced by the generator US5 builds, so US5 is a hard prerequisite for the P1 story. | Make the cross-repo dependency explicit in `tasks.md`: the Infrahub generator and its first hand-verified run come before the SDK-side typed-raising work can be demonstrated. Priority labels describe value, not order — say so, so the task ordering is not derived from them. | + +### User Value Assessment + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P2 | 💡 | The plan's new `topics/error_handling.mdx` is to describe "the catalogue codes it covers". Restating 15 codes by hand in the SDK repo creates a second source of truth that rots the first time Infrahub adds one, and nothing validates it — a direct Principle VII risk. Infrahub already generates `docs/docs/reference/error-catalogue.mdx` from the same artefact. | Scope the SDK page to what is genuinely SDK-specific: the hierarchy, how to catch by branch versus by code, the cross-version guarantees, and the two accepted broadenings. Link to Infrahub's generated catalogue reference for the code list instead of duplicating it. | +| P3 | 💡 | FR-016 requires the `identifier` widening to be "called out in the change's release notes", and the plan asserts this in the Constitution Check without naming a mechanism. The repository uses towncrier (`[tool.towncrier]`, `directory = "changelog"`, `orphan_prefix = "+"`), so a release note is a file, not a promise. | Add a changelog fragment task: one entry for the typed errors, one for the `identifier` widening, one for the `except GraphQLError` broadening. Naming the fragment in the plan makes FR-016 verifiable instead of aspirational. | + +### Alternative Approaches + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P4 | 💡 | The plan rejects "generate all of `exceptions.py`" for good reasons, and the layered package is the right call. One alternative is not recorded: doing nothing on the generation side and hand-writing 15 classes once. Worth a sentence, because a reader will ask — 15 codes is small enough that the generator's value is drift protection (US5), not typing effort. | Add the alternative to research.md R2 with that framing, so the generator's justification reads as "drift protection" rather than "avoiding 15 classes of typing". | + +### Edge Cases & UX + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P5 | 💡 | The plan adds no observability to the fallback paths. Every cross-version case — unknown code, absent envelope, invalid payload — is silent, which is exactly the signal a maintainer wants from the field when an SDK meets a newer server. The plan mentions a debug log for payload-validation failure only. | Log at debug on every fallback, including the code that failed to resolve. Cheap, and it makes SC-004's guarantees observable in production rather than only in tests. | +| P6 | 🤔 | E2's fix changes CLI output for a resolver-raised permission failure: today it renders through `print_graphql_errors`, and after the change the earlier `AuthenticationError` branch claims it and prints "Authentication failure: …". That is arguably better, but it is a user-visible change not currently anticipated by the spec. | Confirm the new rendering is wanted, then pin it with a test. If it is not wanted, the ladder needs a third position rather than the current two. | + +### Success Measurement + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P7 | 🤔 | SC-008 promises "a developer can catch every server-reported error, on either transport, with one `except` clause", and that clause is `except ApiError`. But `ApiError` is reachable only by deep import, which the constitution's tiering places explicitly *outside* the guaranteed-stability tier. The spec's central promise is therefore backed by a surface the constitution says may change in a minor release. | Decide whether `ApiError` (and possibly `Error`) joins `infrahub_sdk/__init__.py`'s `__all__`. This permanently enlarges the guaranteed surface, so it is a maintainer call, not a plan call — but leaving it unanswered means SC-008 promises more stability than the constitution grants. | + +--- + +## Engineering Lens Findings 🔬 + +### Architecture Soundness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E1 | 🎯 | **Re-rooting the three adopted classes under `GraphQLError` leaves `errors`, `query`, and `variables` unset.** `NodeNotFoundError.__init__` (and `BranchNotFoundError`, `SchemaNotFoundError`) never call `GraphQLError.__init__`, so on a client-side raise those attributes do not exist at all. Any consumer doing `except GraphQLError as exc: … exc.errors` gets `AttributeError`, and the CLI's `print_graphql_errors(errors=exc.errors)` raises *before* reaching the plan's "degrade when the list is empty" fix. The plan's mitigation is built on the wrong premise: the list is not empty, the attribute is absent. | Give `ApiError` safe class-level defaults for `errors` (empty tuple or `None`, not a mutable list), `query`, and `variables`, so every descendant has them regardless of which constructor ran. Keep the renderer's degradation as well, and add a test that reads `exc.errors`, `exc.query`, and `exc.variables` off a purely client-side `NodeNotFoundError`. | +| E2 | 🎯 | **Auth-branch codes really do arrive on the data path, so deriving the parent solely from the declared status breaks FR-018.** Verified in Infrahub: `backend/infrahub/graphql/app.py:298-300` returns `status_code=200` for every executed query, and `graphql/error_formatter.py` maps resolver-raised failures to `PERMISSION_DENIED` / `AUTHENTICATION_REQUIRED` / `TOKEN_EXPIRED` inside that 200 response's `errors` array. Today such a response raises `GraphQLError`. Under FR-008 as written it would raise a class descending only from `AuthenticationError`, so an existing `except GraphQLError` around `execute_graphql` silently stops catching it — an FR-018 and SC-003 violation. The spec's edge case ("auth failures come back as real 401/403 responses handled by a separate code path") holds only for failures that escape *before* execution, which `api/exception_handlers.py:52-55` states explicitly. | Give the 401/403 codes both parents: `class PermissionDeniedError(GraphQLError, AuthenticationError)`. The diamond closes cleanly on `ApiError`, the MRO gives `GraphQLError.__init__` (correct — these only ever arise on the GraphQL transport, since REST failures raise plain `AuthenticationError` per FR-015), and both `except AuthenticationError` (US3 AS1/AS2) and `except GraphQLError` (FR-018) are satisfied. Amend FR-008 from "descend from the authentication branch" to "*additionally* descend from the authentication branch". | +| E3 | 🎯 | **The payload-validation fallback downgrades the raised type, contradicting FR-013's own rationale.** The plan falls back to the generic branch class when a payload fails validation, to buy the guarantee that `exc.data` is never `None` on a specific class. But FR-013 argues the raised type must be a pure function of the response so it never depends on anything else — and this makes it depend on payload validity. It also defeats the P1 story in the case that matters: US1 AS2 asserts `.delete()` of a missing node raises `NodeNotFoundError`, which would become a bare `GraphQLError` if the payload were malformed. This is not theoretical: `error_formatter.py:59-60` defaults `payload = UndefinedErrorData()` and only overwrites it when an `isinstance` guard matches, so the server can emit `data: {}` under a code whose schema declares `node_kind` and `identifier` required. | Invert the choice: resolve the class from the code, always, and let `data` be `Model | None` with the generated annotation `Optional`. Log at debug when validation fails. The cost is a `None` check that in practice never fires; the benefit is that the type a consumer catches is exactly what FR-013 promises. Note in the contract that a `None` `data` means the server violated its own emission contract. | +| E4 | 💡 | FR-013 requires the exception to retain the complete error list, but `AuthenticationError.__init__(message)` has nowhere to put it, and FR-015 freezes that constructor. On the auth branch the list would currently be dropped. | Fold this into E1's fix: `errors` lives on `ApiError` with a safe default, and the auth factory populates it. One change satisfies both findings. | +| E5 | 💡 | The `ast`-based adoption discovery is the right call, but the documented import-based fallback has a trap: `NodeInvalidError` *inherits* `CODE = "NODE_NOT_FOUND"` from `NodeNotFoundError`, so an attribute walk would see two classes claiming the same code and let dict ordering pick the winner. | If the fallback is ever used, filter to classes whose own `__dict__` carries `CODE`. Worth one sentence in research.md R4 so the trap is documented where the fallback is offered. | +| E6 | 💡 | The layering test as described parses module-level and `TYPE_CHECKING` imports. A function-body import (`def f(): from .factory import …`) is the classic way a cycle gets reintroduced once the obvious route is closed. | Walk every `Import`/`ImportFrom` node in the module, not just top-level ones. Same effort, closes the remaining hole. | + +### Failure Mode Analysis + +| 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. | + +### Security & Privacy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E9 | 💡 | No new attack surface: the change parses a response the SDK already parses, adds no dependency, and reaches no new data. The one hygiene point is that a catalogued error's message now names the code and the server's message, and `PermissionDeniedData` carries `action` and `resource_kind` — which will show up in logs and CLI output where the previous message was a wall of query text. | Nothing to change. Worth one line in the docs page noting that error messages now surface the failing action and resource kind, so anyone shipping SDK logs to a third party knows what changed. | + +### Performance & Scalability + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E10 | 💡 | Cost is one dict lookup plus one pydantic validation per failed request, on a path that already decoded JSON. Not a concern. The only scaling question is generated-module size, and 15 codes is nothing. | None. The plan's "Performance Goals: None" is the correct answer, not an omission. | + +### Testing Strategy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E11 | 💡 | SC-006 reads "async and sync clients raise the same type with the same attributes for the same failure, across all catalogued codes", but the plan's approach — exhaustive at the factory level, parametrized at the client level — does not say which layer covers "all codes". Read literally, SC-006 asks for 15 codes × 2 clients through the client layer. | State the split explicitly: factory-level tests cover all codes exhaustively; client-level parity tests cover a representative set that exercises both branches, both transports, and the file-upload variant. That satisfies SC-006's intent and keeps the suite fast, per the constitution's unit-test speed requirement. | +| E12 | 💡 | The plan does not name a test for the two accepted broadenings. They are deliberate behaviour changes, and the constitution requires each to be pinned by a test asserting the new behaviour. | Add explicit tests: `except GraphQLError` catches a client-side `NodeNotFoundError`; a catalogued failure's message differs from the generic one while the uncatalogued message is byte-identical. | + +### Operational Readiness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E13 | 💡 | Rollback for a library is a revert, and the plan needs no deployment strategy — correct. But the cross-repo failure mode has no story: if Infrahub regenerates bindings into the submodule and the SDK's `sdk_ref` docs are generated from the `exceptions` package, the SDK's own `docs-validate` fails on the next SDK pull request, for a change made in another repository. `exceptions.py` is not documented in `sdk_ref` today, so adding it to `packages_to_document` creates this coupling from nothing. | Flip R1's consequence: put `"exceptions"` in `packages_to_ignore` and cover the hierarchy with the hand-written topic page, which is what FR-028 actually asks for. Preserves today's docs behaviour exactly and removes a cross-repo trap. | + +### Dependencies & Integration + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E14 | 💡 | No new runtime dependency, and the generator reuses Infrahub's existing Jinja2 and ruff pipeline — sound. The unverified assumption is the type-checking one: the plan asserts that annotating `data` concretely on a subclass of an `Any`-typed base attribute is accepted by *both* mypy and `ty`. mypy accepts it; `ty` is newer and its variance handling on attribute overrides is not something the plan has evidence for. | Spike it before the generator template is finalised: one hand-written class, both checkers. If `ty` objects, the fallback is a covariant read-only property per generated class, which the template can emit just as easily. Cheap insurance against discovering it across 15 generated classes. | +| E15 | 🤔 | The bindings cross a submodule boundary, so the SDK commit that adds `catalogue.py` and the Infrahub commit that generates it must land together. The plan says the first generation is hand-verified once, but not how the two pull requests are paired thereafter, nor what happens when Infrahub's CI regenerates against an SDK branch that is not yet merged. | Confirm the intended workflow with the maintainer: does the Infrahub pull request carry a submodule pointer bump to an SDK branch, and is that branch merged first? This is existing practice for `protocols.py`, so the answer likely exists — it just is not written down here. | + +--- + +## Cross-Lens Insights 🔗 + +| ID | Finding | Product Impact | Engineering Impact | Suggestion | +|----|---------|---------------|-------------------|------------| +| X1 | Auth codes on HTTP 200 (E2) | A consumer's working `except GraphQLError` silently stops catching permission failures — the exact class of breakage the feature exists to prevent | FR-008's derivation rule conflicts with FR-018's no-clause-loses-coverage guarantee | Dual base `(GraphQLError, AuthenticationError)` for 401/403 codes, and amend FR-008's wording to "additionally descend" | +| X2 | Payload validity decides the raised type (E3) | US1 AS2's promise ("`.delete()` on a missing node raises `NodeNotFoundError`") becomes conditional on the server's payload being well-formed, and the server has a reachable path that emits an empty payload | Contradicts FR-013's stated rationale that the raised type is a pure function of the response | Resolve the class from the code unconditionally; make `data` `Model \| None` and document that `None` means the server broke its own contract | +| X3 | Documenting the `exceptions` package in `sdk_ref` (E13, P2) | A hand-maintained code list and a generated API page both rot, and readers cannot tell which is authoritative | Creates a cross-repo CI coupling where an Infrahub-side regeneration fails an SDK pull request | Ignore the package for API-doc generation; one hand-written topic page for hierarchy and guarantees, linking to Infrahub's generated catalogue reference for codes | + +--- + +## Findings Summary + +| Metric | Count | +|--------|-------| +| 🎯 Must-Address | 3 | +| 💡 Recommendations | 16 | +| 🤔 Questions | 3 | +| Product findings | 7 | +| Engineering findings | 15 | +| Cross-lens findings | 3 | + +--- + +## Consolidated Findings Table + +| ID | Lens | Severity | Category | Finding | Suggestion | +|----|------|----------|----------|---------|------------| +| E1 | Engineering | 🎯 | Architecture | Re-rooted adopted classes never set `errors`/`query`/`variables`; consumers and the CLI hit `AttributeError`, not an empty list | Safe class-level defaults on `ApiError`; test attribute access on a client-side raise | +| E2 | Engineering | 🎯 | Architecture | Auth codes arrive inside HTTP 200 GraphQL responses, so status-derived parents make `except GraphQLError` lose coverage | Dual base `(GraphQLError, AuthenticationError)`; amend FR-008 to "additionally descend" | +| E3 | Engineering | 🎯 | Architecture | A malformed payload downgrades the raised type, contradicting FR-013 and US1 | Resolve class from code unconditionally; `data: Model \| None` | +| E4 | Engineering | 💡 | Architecture | FR-013's error-list retention is unmet on the auth branch | Put `errors` on `ApiError`; auth factory populates it | +| E5 | Engineering | 💡 | Architecture | Import-based adoption fallback double-counts `NodeInvalidError`'s inherited `CODE` | Filter on the class's own `__dict__` | +| E6 | Engineering | 💡 | Architecture | Layering test misses function-body imports | Walk all import nodes, not just module-level | +| E7 | Engineering | 💡 | Failure modes | A factory bug replaces the server's error with an SDK `TypeError` | Make the factory total, falling back to today's construction | +| E8 | Engineering | 💡 | Failure modes | Wire `http_status` can differ from the declared one for `UNDEFINED_ERROR` | Document that `exc.extensions["http_status"]` holds the wire value | +| E9 | Engineering | 💡 | Security | Messages now surface action and resource kind into logs | One documentation line; no code change | +| E10 | Engineering | 💡 | Performance | No bottleneck; "Performance Goals: None" is correct | None | +| E11 | Engineering | 💡 | Testing | SC-006's "all codes" is not mapped to a test layer | State the factory-exhaustive / client-representative split | +| E12 | Engineering | 💡 | Testing | The two accepted broadenings have no named test | Add tests pinning both | +| E13 | Engineering | 💡 | Operations | Documenting the `exceptions` package couples SDK `docs-validate` to Infrahub regeneration | Use `packages_to_ignore`; rely on the topic page | +| E14 | Engineering | 💡 | Dependencies | `ty`'s acceptance of the narrowed `data` annotation is unverified | Spike one class against mypy and `ty` first | +| E15 | Engineering | 🤔 | Integration | Cross-repo pull-request pairing for the submodule artefact is unwritten | Confirm the `protocols.py` workflow and record it | +| P1 | Product | 💡 | Problem validation | US5 (P2) is a hard prerequisite for US1 (P1) | Make the dependency explicit in `tasks.md`; priorities are value, not order | +| P2 | Product | 💡 | User value | A hand-written code list in SDK docs becomes a second source of truth | Describe hierarchy and guarantees; link to Infrahub's generated reference | +| P3 | Product | 💡 | User value | FR-016's release-note requirement has no mechanism | Add towncrier fragments for the typed errors and both broadenings | +| P4 | Product | 💡 | Alternatives | "Hand-write 15 classes" is not recorded as a rejected alternative | Add it, framed as drift protection versus typing effort | +| P5 | Product | 💡 | Edge cases | Fallback paths are silent in the field | Debug-log every fallback with the unresolved code | +| P6 | Product | 🤔 | Edge cases | CLI output changes for resolver-raised permission failures | Confirm the new rendering, then pin it with a test | +| P7 | Product | 🤔 | Success measurement | SC-008's promise rests on a class outside the guaranteed-stability tier | Decide whether `ApiError` joins the top-level `__all__` | +| X1 | Cross-lens | 🎯 | Scope × Risk | See E2 | See E2 | +| X2 | Cross-lens | 🎯 | Scope × Risk | See E3 | See E3 | +| X3 | Cross-lens | 💡 | Docs × CI | See E13 and P2 | Ignore the package for API docs; one hand-written topic page | + +--- + +## Recommended Actions + +### 🎯 Must-Address (Before Proceeding) + +1. **E1**: In `data-model.md` and `plan.md`, give `ApiError` safe class-level defaults for `errors`, + `query`, and `variables`, and correct the CLI mitigation in research.md R11 — the hazard is a missing + attribute, not an empty list. Add the attribute-access test to the plan's test list. +2. **E2**: Amend spec FR-008 to "401 and 403 codes *additionally* descend from the authentication + branch", and update `data-model.md`, `research.md` R5, and + `contracts/exception-hierarchy.md` to the dual base. Record the verification (Infrahub + `graphql/app.py:298-300` returns 200; `graphql/error_formatter.py` maps resolver-raised auth + failures) so the reasoning is not lost. Also correct the spec's edge case, which currently claims + auth failures only ever arrive as real 401/403. +3. **E3**: Reverse the payload-validation decision in research.md R6, `data-model.md`, and + `contracts/exception-hierarchy.md`: the code resolves the class unconditionally, `data` is + `Model | None`, and a `None` means the server violated its own emission contract. + +### 💡 Recommendations (Strongly Suggested) + +1. **E4 + E13 + E7**: fold `errors` onto `ApiError`; flip the docs package to `packages_to_ignore`; + make the factory total. +2. **E5, E6, E8, E11, E12, E14, P4, P5**: one- to three-line corrections to the artefacts named in each + row above. +3. **P1, P2, P3**: sequencing note in `tasks.md`, docs page scoped to hierarchy and guarantees with a + link out for codes, and towncrier fragments named as tasks. + +### 🤔 Questions (Need Stakeholder Input) + +1. **P7**: Should `ApiError` (and `Error`) be exported from `infrahub_sdk/__init__.py` so SC-008's + promise sits in the guaranteed-stability tier? This permanently enlarges the guaranteed surface. +2. **P6**: Is the changed CLI rendering for a resolver-raised permission failure ("Authentication + failure: …" instead of the GraphQL error list) the wanted outcome? +3. **E15**: How are the paired Infrahub and SDK pull requests sequenced for a submodule artefact? The + `protocols.py` precedent presumably answers this. + +--- + +## Resolution + +Applied on 2026-08-25: all three must-address items and all sixteen recommendations. + +| Finding | Where it landed | +|---------|-----------------| +| E1 | `ApiError` gains class-level defaults for `errors`, `query`, `variables` — data-model.md, research.md R11 (which also corrects the wrong empty-versus-missing diagnosis), plan.md constraints | +| E2 | FR-008 amended to "additionally descend"; the HTTP 200 edge case corrected in spec.md; dual base recorded in research.md R5, data-model.md, both contracts | +| E3 | Reversed in research.md R6; FR-004 gained the rule; `data` is `Model \| None` throughout the contracts and data model | +| E4 | `errors` moved onto `ApiError` (same change as E1) | +| E5 | research.md R4 and the generator contract note the inherited-`CODE` trap | +| E6 | research.md R1 and quickstart scenario 3b: the layering test walks every import node | +| E7 | research.md R7: the factory is total; quickstart scenario 2 drives malformed envelopes | +| E8 | Wire-versus-declared `http_status` documented in spec.md, research.md R5, data-model.md, the hierarchy contract | +| E9 | FR-028 and research.md R13: messages now surface action and resource kind | +| E11, E12 | research.md R12 gained the per-layer coverage table including the broadening tests | +| E13 | research.md R1 flipped to `packages_to_ignore`, with the cross-repo reason; plan.md Principle VII row | +| E14 | New research.md R15: spike the narrowed annotation against mypy and `ty` first | +| E15, P6 | New research.md R17 and a plan.md "Open questions" section | +| P1 | New research.md R16 and a plan.md "Sequencing" section | +| P2 | research.md R13: the page links to the published catalogue instead of restating codes | +| P3 | New research.md R14: towncrier fragments in `changelog/` | +| P4 | research.md R2 gained the no-generator alternative | +| P5 | research.md R7: every fallback logs at debug | +| P7 | Answered by the maintainer, and the answer reframed the question — see below | + +### Second pass + +A follow-up review of the *applied* artefacts found three more blocker-class items, two of them created +by the fixes above, all now applied: + +| Finding | Fix | +|---------|-----| +| An undeclared name collision would silently shadow a hand-written class. `VALIDATION_ERROR`, `RATE_LIMIT`, `INVALID_RESPONSE`, `FILE_NOT_VALID`, and `RESOURCE_NOT_DEFINED` all derive names the SDK already defines, and the façade's ordered star-imports would let the generated class win | FR-006 gained the rule; generation aborts on an undeclared collision (research.md R3, generator contract) | +| The dual base (E2) gives auth classes `GraphQLError.__init__`, whose first positional parameter is `errors` — so passing the joined message positionally would assign it to `errors`, reproducing `analyzer.py`'s corruption by construction | Factories construct with keywords only, asserted by a test (research.md R7); the spec's constructor-misuse edge case now names the hazard | +| Every acceptance criterion was provable against fixtures authored alongside the parser, with no test against a real server — and the constitution puts server-dependent behaviour in the integration tier, which already exists here | Two real failures driven through testcontainers on both clients (research.md R12, plan Principle V, quickstart scenario 6b) | + +Smaller items from the same pass: the tuple default for `errors` (E1) would fall into +`print_graphql_errors`' non-list branch, which also lacks a `return`; the auth factory must use +`decode_json` so a non-JSON 401 body does not raise in place of the authentication error; there are two +pre-existing malformed `GraphQLError` construction sites, not one; and the accepted broadening is +slightly wider than stated, since the file handler's REST 404 also becomes a `GraphQLError`. + +### Third pass — the payload access design + +E3 and E14 both circled a payload *object* on the exception without questioning whether it should exist. +It should not, and removing it retired both findings along with several of the complications the earlier +passes introduced. + +US1 asks for the detail "as typed attributes", never for a payload object. So the payload's fields are +promoted to directly typed attributes on the exception (`exc.node_kind`, `exc.fields`), and the pydantic +model reverts to being the validation mechanism. Consequences: + +- `ApiError.data: Any` is gone, and with it the only invented `Any` in the design. There is nothing on + the base to narrow, so no variance problem, no property pair, no generic hierarchy, and no + suppression anywhere (E14's spike survives in reduced form as R15). +- The generated module drops from two files to one. The `payloads.py` / `catalogue.py` split existed + only so `base.py` could name a generated model type for the adopted classes; with promotion it names + none, `base.py` imports nothing from inside the package, and the package is four modules rather than + five. +- The access pattern is now uniform. The adopted classes already promoted their payload fields onto + `node_type` and `identifier`, so `.data` for the other twelve codes was an inconsistency the SDK's + users would have had to learn. +- E3 is reverted, with a better argument than the one that made it: a required catalogue field is a + non-optional attribute, so an invalid payload has nothing to populate it with and must fall back to + the generic class. The FR-013 reasoning used to reject that originally was overreaching — FR-013's + concern is that the raised type must not depend on *binding freshness*, and payload validity is a + property of the response, not of the SDK's bindings. + +### Fourth pass — automated PR review + +Eight findings on the committed artefacts. Six accepted as filed, one accepted with a corrected +diagnosis, one rejected. + +| Finding | Verdict | +|---------|---------| +| R6 and R7 disagreed on whether `code` stays readable when a recognised code's payload fails validation | **Valid and consequential.** R7 had folded that case into its `code is None` list, which would have made a payload-invalid catalogued error render as an uncatalogued one — R11's CLI branch and R9's server-reported test both key on `exc.code is not None`. R7 now separates the two questions: which class is raised, and what `code` reports. | +| FR-012's fallback could be read as routing by the code's declared status, which would send an unrecognised 401/403 code arriving in a 200 body to the authentication branch and out of `except GraphQLError` | **Valid as a wording defect**, though not as a reading of the intent — "the branch it is already on" meant the transport. But the wrong reading breaks FR-018 silently, so FR-012 now says transport explicitly and states why declared status cannot work: for an unrecognised code the SDK holds no binding and so does not know the declared status at all. | +| The hierarchy contract claimed the dual base's "both clauses catch it today" for the real-401/403 path, where `except GraphQLError` does not catch it today | **Valid**, and it surfaced an unrecorded consequence: the authentication path now resolves catalogue codes, so `except GraphQLError` will begin catching a real 401/403 whose code the bindings recognise. That is a third accepted broadening, now listed in both the contract and the spec. | +| `errors` documented as `list` on `GraphQLError` while an adopted class would expose the base's empty *tuple* | **Valid**, and the right fix is the one already preferred on design grounds: the adopted classes call `super().__init__(errors=[], …)` explicitly instead of leaning on a class-level default. The tuple/list divergence disappears rather than being documented. | +| `exc.extensions["http_status"]` recommended while `extensions` may be `None` | Valid. The contract now says to guard on `extensions` first. | +| Plan said "~12" `AuthenticationError` raise sites; the code has 11 (4 + 6 + 1), as research.md already stated | Valid. Corrected to 11. | +| The Resolution section read third pass before second pass | Valid. Reordered. | +| A pure-docs change should target a different release branch | **Rejected as actionable.** The stated remedy contradicts itself ("belong on stable; target develop instead"), the guideline it cites is not present in this repository, and `develop` and `infrahub-develop` are different branches here. Left as a question for the maintainer, who owns the branch model. | + +### Fifth pass — automated PR review of the fourth pass + +Three findings, all valid, two of them defects the fourth pass introduced. Applied. + +| Finding | Verdict | +|---------|---------| +| The third broadening was stated as "a real 401/403 carrying a catalogue code", which is overbroad — the dual base only applies to codes the bindings recognise, and an unrecognised code on a real 401/403 falls back to the generic `AuthenticationError`, which is not a `GraphQLError` | Valid. Both the contract and the spec now qualify the broadening to recognised codes and say explicitly what happens to the rest. | +| The data-model rationale claimed the adopted classes' explicit `super().__init__` call is what prevents an `AttributeError` — but the fourth pass *kept* `ApiError`'s class-level defaults, so the attributes exist regardless. The explicit call is about `errors` being a *list* | Valid, and self-inflicted: two fixes were applied and the old justification was left attached to the new mechanism. The two mechanisms are now described separately — defaults guarantee existence, the explicit call guarantees the type. | +| FR-012's new rationale was internally contradictory (it said the declared status is unknowable for an unrecognised code, then used an unrecognised code as the example of declared-status routing) and misattributed the coverage guarantee to FR-008's dual inheritance, which shapes per-code classes and not the generic class a fallback raises | Valid on both counts. The example is now a recognised 401/403 code whose payload fails to validate, and the coverage is attributed to FR-012's transport rule, with a note that the dual base does not help there. The same two errors were present in research.md R7 and are fixed there too. | + +### Sixth pass — automated PR review of the fifth pass + +One finding, valid: the qualified broadening ("a real 401/403 whose code the bindings recognise") is +still only true when the payload *also* validates, since a recognised code with an invalid payload falls +back to the generic `AuthenticationError` per FR-012. + +Rather than add a third conditional to a sentence that has now been narrowed twice, both the spec and +the contract restate the broadening as the mechanism that produces it: `except GraphQLError` catches a +real 401/403 exactly when the SDK raises a per-code class for it, because only those classes carry both +parents. That covers the recognised-code and valid-payload conditions without enumerating them, so it +cannot be narrowed again by a further condition on reaching a per-code class. + +### Seventh pass — maintainer review + +One finding, on task placement: `frontend.regenerate-error-bindings` should not generate the SDK's +bindings, because the frontend namespace should generate frontend artefacts only. + +Valid, and it exposed that the original decision was worse than stated — that task *already* calls +`backend.export_error_catalogue` and `docs.generate_error_catalogue`, so it is a catalogue orchestrator +carrying a frontend label, and adding a fourth cross-namespace call would have deepened an existing +wart rather than introducing a new one. + +The first attempt at a fix over-corrected: it invented a `generate` / `validate` pair in `tasks/sdk.py`, +reasoning from `tasks/schema.py`'s per-artefact pattern. The maintainer pointed out that the pair +already exists — `backend.generate` calls `_generate_schemas` and `_generate_protocols`, both of which +write into `python_sdk/`, and `backend.validate-generated` verifies them. A second entry point for the +same category of work was unnecessary. + +What the round actually settled is the principle: the namespace names the **producer**, not the +artefact's location. `backend` generating into `python_sdk/` is the point of the task rather than a +violation of it, and the frontend is a sibling *consumer* of the same catalogue — which is precisely why +hooking regeneration there was wrong. The error bindings join `backend.generate` and +`backend.validate-generated` as a third Infrahub-generated, SDK-destined artefact. No new command, and +no new CI step. + +Tidying the pre-existing frontend orchestrator is left as separate work. + +### Eighth pass — automated PR review + +One finding, valid: the planned `error_catalogue_files` entry for the submodule artefact path could +never match. From the superproject a submodule is a single gitlink, so a change to a file inside it +appears only as a change to `python_sdk`. + +This was a self-contradiction two paragraphs wide — the same document states that limitation as the +reason the `git diff` must run *inside* the submodule, and then claims a path filter on the inner path +would work. It is the second finding of that exact shape (the first being the `AttributeError` +justification left attached to a mechanism that no longer produced it), which suggests the habit worth +breaking: derive a claim about a mechanism from the one place the mechanism is stated, rather than +restating it independently a few paragraphs later. + +Checking the repository shrank the change further than the finding proposed. Nothing needs adding to +any filter: `sdk_files` is already `"python_sdk"`, commented "Catch updates to the submodule commit" +and included in `backend_all`; the generator template is covered by `backend_files` (`backend/**`); and +`error_catalogue_files` already lists the catalogue JSON. The only CI edit left is adding +`error_catalogue == 'true'` to the job's trigger, so that a hand-edit of the catalogue JSON alone +cannot slip past. + +### Ninth pass — automated PR review of the squashed branch + +Ten findings, seven valid, three rejected. Unlike the previous three rounds these were not corrections +of the round before: most had been present since the original plan commit and survived six incremental +reviews untouched. Reviewing the full diff at once found unreviewed ground rather than re-litigating +settled ground. + +Rejected, with the underlying gap fixed anyway: + +| Finding | Verdict | +|---------|---------| +| `UNIQUENESS_VIOLATION` does not exist in the catalogue | Wrong for the branch this pairs with. It is present on `opsmill/infrahub@develop`; the commit the reviewer read is on the stable line, where it is absent. | +| The catalogue holds 14 codes, not 15 (11 non-auth, not 12) | Same cause. `develop` holds 15 (12 + 3); `stable` holds 14 (11 + 3). | +| The `"Expired Signature"` grep should expect two sites, not one | R10 already puts both the code check and the legacy fallback inside one shared helper, so one occurrence is correct — down from two today. | + +The two catalogue findings were wrong but productive: nothing in the artefacts said *which* ref the +counts and examples came from, which is why a reviewer reading another branch reached a different +answer, and a human would have hit the same thing. The survey now pins `develop` explicitly and records +that US1 scenario 1 is only demonstrable against a catalogue containing `UNIQUENESS_VIOLATION`. The +grep finding got a clarifying clause in R10 and the quickstart for the same reason. + +Valid and applied: + +| Finding | Fix | +|---------|-----| +| The plan's Constraints section still said the raised class never depends on payload validity or transport — 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. Reworded to "a function of the response alone… never of binding freshness". | +| `exc.code is not None` cannot distinguish server-reported from client-side on the adopted classes, because nothing documented set `code` on them | The `code` table row now states that the factory sets it per-instance for adopted classes — a class attribute cannot work, since the same class must report `None` on a client-side raise. | +| FR-002's "the base GraphQL error itself" is ambiguous now that FR-001 introduces a shared base above both branches | Names the FR-001 base explicitly, and says not the per-code classes. | +| FR-003 and FR-012 said `code` is "absent"; two acceptance scenarios promise `None`. Different observable contracts | Standardised on always-exists-and-may-be-`None`, which is what the scenarios and the CLI branch assume. | +| 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 to server-reported failures in both FR-022 and the contract, with the client-side case keeping today's message. | +| data-model.md's intro said "nothing here is typed `Any`" two paragraphs above an `extensions: dict[str, Any]` row | Narrowed to "no payload attribute", with the raw-JSON exception stated. | +| A local absolute developer path in plan.md | Replaced with the repository slug. | + +### Tenth pass — automated PR review + +Two findings, both valid, both about the accuracy of a justification rather than the design: + +| Finding | Fix | +|---------|-----| +| The trend paragraph claimed all seven ninth-pass defects "had survived since the original plan commit", while the section it summarises says "most" — and its own example, the Constraints contradiction, only became a contradiction at the E3 reversal in the third pass | Qualified to "most", and the example dated to the third pass. | +| The new Messages qualifier said the three unified classes are raised "with no server response behind them", but `file_handler.py:168` 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: **no catalogue code** behind them. That covers both the client-side miss and the REST 404, whose response carries the legacy envelope and so no code. FR-022 corrected the same way. | + +This review was submitted four minutes after the commit it reviews and went unread for four days, +because the previous round was reported as finished without checking whether a new review had landed +against the fix. Worth noting alongside the eighth-pass observation about full-diff passes: the review +loop needs a check for *new* findings after each push, not only before one. + +The second finding is the third instance of one failure mode — restating a mechanism a few paragraphs +from where it is stated correctly, and getting the restatement wrong. The first was the `AttributeError` +justification, the second the submodule path filter. In all three the correct statement was already in +the same file. + +### Eleventh pass — maintainer review: the diamond is gone + +The maintainer questioned the dual base on SOLID and maintainability grounds, noting that the goal is +for a consumer to catch one generic SDK error rather than enumerate types. That reframing dissolved the +requirement the diamond existed to serve. + +**What the MI was for.** US3's scenarios specified distinct types for the three authentication codes, +and FR-018 required `except GraphQLError` to keep catching a resolver-raised `PERMISSION_DENIED` in an +HTTP 200. One class per code cannot satisfy both without inheriting from both branches. But those +scenarios were written *before* the HTTP 200 behaviour was verified — the spec's own acceptance criteria +forced the MI, and they were amendable. + +**What replaced it.** The 401/403 codes get no class. The transport rule already in FR-012 raises the +generic class the transport dictates — `AuthenticationError` for a real 401/403, `GraphQLError` for a +200 `errors` array — and `exc.code` carries the identity. Every existing `except` clause keeps working, +no exception escapes anyone, the hierarchy is a tree, the generator has one shape, and FR-008 changes +from "which parents" to "which codes get classes": one rule, no special cases in the factory. + +**What the diamond had already cost**, all found in review rather than by design: the positional-argument +corruption, where method resolution handed those classes `GraphQLError.__init__` and its first parameter +is `errors`; the cooperative `super().__init__` reaching `AuthenticationError.__init__` on the GraphQL +path, where its default message escaped substitution only by accident; two generated class shapes; and a +maintainer misreading the hierarchy on first contact. All to distinguish three codes whose payloads are +empty or entirely nullable and usually unset. + +**What it costs.** Those three codes have no typed payload attributes, reachable only through +`exc.extensions["data"]` if the catalogue later gives them substantive fields — at which point the +trade can be re-weighed against a concrete benefit. US3's scenarios, SC-001, FR-005, and FR-008 were +amended; the third broadening from the ninth pass disappears, since a real 401/403 once again produces +`AuthenticationError` and nothing else. + +Also rejected on the way: registering the generated classes as virtual subclasses of +`AuthenticationError` via `ABCMeta`. CPython matches `except` clauses with real subtype checks and +ignores `__subclasshook__`. + +### Twelfth pass — maintainer review: R9 settled, and reframed + +The maintainer asked whether the SDK could simply populate future catalogue fields itself on a +client-side raise, or whether the reverse is the problem — required fields the SDK sets that the server +never sends. + +The second, and it is already true 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"`. +Whether the SDK could fill a *new* field depends entirely on the field — a `branch_name` addition could +be filled by three sites and not by the four store lookups, and anything genuinely server-side by none. + +Two things came out of it: + +- **A policy, now pinned** in FR-016, R9, the data model, and the hierarchy contract: promoted + attributes on the three unified classes are optional even where the catalogue declares the field + required, because a required attribute would be a promise a class raised from two provenances cannot + keep. With an explicit tripwire — an adopted code gaining a field that is both required and + semantically server-only — for revisiting. +- **A correction to this document's own framing.** Earlier passes flagged R9 as the design's weak point + and implied it might want reopening. Working the question properly showed the opposite: a split is + 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. Unification is the lesser evil and is now defended rather than merely + accepted. + +The maintainer also set the direction of travel: separate classes for the SDK's own failures, arrived at +through the constitution's deprecation path rather than inside this change. R9 records the sequence, and +the spec records it as an assumption, so the next change has somewhere to start from. + +### Thirteenth pass — automated PR review of the two design changes + +Five findings across the two commits, all valid at least in part. Unlike the prose rounds, two of these +were real contradictions introduced by removing the diamond. + +| Finding | Verdict | +|---------|---------| +| 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 | **Valid, and the important one.** Two spec documents contradicting each other on whether the class has subclasses. Both corrected. | +| 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 in a 200 raises `GraphQLError` | **Valid on the table and the example**, which described the diamond's capability. Corrected, with `except ApiError` named as the clause that spans both arrival paths. **Rejected on the FR-018 part**: the finding measured coverage against the unshipped diamond, but `except AuthenticationError` does not catch such a response today either, so nothing is lost against the pre-feature baseline. | +| R5 said "twelve generated classes" where nine are generated and three are adopted | Valid. The diagram beneath it was already right. | +| The new optional-attribute row justified itself as "raised without a server response", but the REST 404 has a response and merely no catalogue code | Valid — and the same error already fixed in the Messages section during the tenth pass, reintroduced verbatim in a new location. | +| The `NodeNotFoundError` construction-site counts were wrong: nine sites, not seven, with `node_type` supplied by five rather than three | Valid. Re-derived from source: eight `raise` statements plus one deferred construction at `store.py:184`; `identifier` is a mapping at eight and a plain string at one. | + +Two patterns worth naming. The "no server response" error is now the **fourth** instance of restating a +mechanism inaccurately away from where it is stated correctly — and the first where the same mistake was +made again *after* being fixed elsewhere, which means fixing an instance does not inoculate the +document. And this is the **second** time a count cited from memory of an earlier grep was wrong (the +first being "~12 `AuthenticationError` raise sites"). Counts belong re-derived at the moment of writing, +not recalled. + +### Fourteenth pass — automated PR review + +One finding, valid. The worked examples in the hierarchy contract commented that `TOKEN_EXPIRED` and +`AUTHENTICATION_REQUIRED` arrive as a real 401/403 while `PERMISSION_DENIED` is the resolver-raised one. +R5 says otherwise, having verified it: the formatter maps *any* error escaping a resolver onto a +catalogue code, `AuthorizationError` included, so all three codes 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. + +Fixed by having the examples **reference** the arrival table rather than restate which code goes where, +and by leading with `except ApiError` as the form to reach for. US3 scenario 5 generalised from "a +permission failure" to "any of the three authentication codes" for the same reason. + +**This is the fifth instance of one failure mode, and it settles the mitigation.** Restating a mechanism +away from its authoritative statement has now produced: the `AttributeError` justification, the +submodule path filter, the "no server response" phrasing, that same phrasing again after being fixed, +and now the arrival mapping. Fixing instances has not reduced the rate. The rule these artefacts need is +structural rather than editorial: for the handful of load-bearing mechanisms — which transport produces +which class, what `exc.code` holds, which codes get classes — state it in exactly one place and point at +it from everywhere else. Prose that paraphrases a mechanism is a defect waiting to be found, however +correct it is when written. + +### Fifteenth pass — automated PR review, and both spikes executed + +One finding, valid: the two catch forms were shown in a single code block, and since +`AuthenticationError` descends from `ApiError`, anyone copying the block as one handler sequence would +have got a dead final clause. Split into alternatives with the shadowing stated. + +Both outstanding spikes then ran, and both passed: + +- **The `ast` discovery walk** (R3, R4) classified every catalogue code against the real + `exceptions.py`: nine to generate, three with no class, three colliding. It confirms the corrected + counts from the thirteenth pass independently, fires the collision check on the four hypothetical + names, and is correctly blind to an inherited `CODE`. +- **The generated class shape** (R15) is clean under both `mypy` 1.11.2 and `ty` 0.0.14 with zero + suppressions, and the types a consumer sees were asserted by typed assignment rather than inspected. + +The discovery walk also surfaced something 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 +rather than merely for it producing the right output. Recorded in R4 and in the plan, because the +natural instinct is to build the generator first. + +### Sixteenth pass — one review finding, plus a systematic contradiction sweep + +The review found that R4's new spike paragraph enumerated 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 was 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. + +The maintainer then asked for a check across the rest, so the load-bearing mechanisms were swept +against their authoritative statements rather than re-read as prose. Ten were checked: how many classes +exist per code, which transport produces which class, what `exc.code` holds, the payload-validation +fallback, the package layering, generation and validation placement, the broadening count, promoted +attribute optionality, the raise-site counts, and the message rule. + +| Mechanism | Result | +|-----------|--------| +| Package layering | **Contradiction.** R1's decision still said "a package of five modules" above a four-row layer table — stale from when the payload models had their own module. | +| Classes per code | **Contradiction.** 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. | +| Message rule | **Contradiction.** The quickstart's 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. | +| Transport → class | Consistent, including a check for the *inverted* mapping, which appears nowhere. | +| `exc.code` contract | Consistent across FR-003, FR-012, R7, and the cross-version table. | +| Payload-validation fallback | Consistent across FR-004, FR-012, R6, and the data model. | +| Generation placement | Consistent — `backend.generate` and `backend.validate-generated` everywhere, with `frontend.regenerate-error-bindings` appearing only as the rejected alternative. | +| Broadening count | Consistent: two, in all three places. | +| Promoted optionality | Consistent: required on generated classes, optional on the three adopted ones, scoped correctly in each place. | +| Raise-site counts | Consistent: nine `NodeNotFoundError` construction sites, eleven `AuthenticationError` raise sites, four `GraphQLError` raise sites. | + +Three of ten mechanisms carried a contradiction, and all three were **stale text describing a +superseded design** rather than disagreements about the current one — the residue of the diamond +removal and the module split. That is the failure mode the "state once, reference" rule addresses, and +the sweep is the way to find the instances the rule was adopted too late to prevent. Worth repeating +after any change that alters a mechanism, rather than trusting an incremental review to notice. + +### Reading of the trend + +Findings are shrinking — three in the fifth pass, one in the sixth, none above P3 since the fourth — +and are now about the accuracy of rationale prose rather than about the design, while most were +introduced by the previous round's edits. That is the signature of edit churn rather than of remaining +design risk: each round is mostly correcting the last. The design has been stable across three rounds; +what keeps moving is the explanation of it. + +Two durable lessons. + +From the sixth pass: a claim stated as an enumeration of conditions invites narrowing forever, while +the same claim stated as the mechanism that produces it cannot be narrowed. Where a later reviewer +finds another condition to add, restate rather than qualify. + +From the seventh: the automated passes converged on prose while a structural question — which namespace +owns a task — sat untouched through six rounds, and a human found it immediately. The shrinking-findings +trend above was real but it measured the wrong thing: it tracked how much the reviewer had left to say +about the text, not how much design risk remained. Declaring the documents finished on that basis was +premature. Consistency review saturates; judgement review does not, and the two are not +interchangeable. + +From the eleventh, and the sharpest of the three: the reviewer that found the largest simplification in +eleven rounds was the one asking *why* a construct existed rather than whether it was described +correctly. Ten automated passes verified the diamond's internal consistency without once questioning +whether it should exist — and it existed only because an acceptance scenario written before a key fact +was verified had specified a type where a code attribute would do. Automated review checks a design +against itself; only a human checks it against its purpose. + +From the ninth: the saturation was an artefact of *incremental* review. Squashing the branch made the +reviewer read the whole diff again, and it immediately found seven real defects, most of them present +since the original plan commit — including a Constraints section that had contradicted the spec since +the E3 reversal in the third pass. Rounds three through eight each reviewed only the delta, so nothing ever re-examined the parts +that were not being edited. The lesson for the next long-lived design branch: a full-diff pass is worth +forcing periodically, not just at the end, and low incremental finding counts say nothing about the +parts the diff does not touch. + +**P7 as answered**: no top-level export change. `infrahub_sdk.exceptions` is the supported import path +for every exception, generated or hand-written; a consumer never needs to know which module defines +what; and no name importable from it today may stop being importable from it. That is now FR-005's +second sentence, a rewritten stability assumption in the spec, the "Stability" section of the hierarchy +contract, and `tests/unit/sdk/test_exceptions_public_names.py` — a snapshot test, so the guarantee is +checked rather than asserted. Modules beneath the package are explicitly internal. + +--- + +**Severity Legend**: + +- 🎯 **Must-Address**: Blocks proceeding to implementation +- 💡 **Recommendation**: Strongly suggested improvement +- 🤔 **Question**: Needs stakeholder input to resolve diff --git a/dev/specs/ifc-3034-error-catalogue/data-model.md b/dev/specs/ifc-3034-error-catalogue/data-model.md new file mode 100644 index 000000000..46273afd3 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/data-model.md @@ -0,0 +1,189 @@ +# Data Model: Error Catalogue in the Python SDK + +The entities here are exception classes and pydantic models. Field lists are the observable contract; +see [contracts/exception-hierarchy.md](./contracts/exception-hierarchy.md) for what a consumer may +rely on and [contracts/generator-contract.md](./contracts/generator-contract.md) for how the generated +half is produced. + +Which module holds what is [research.md](./research.md) R1. In short: the hand-written hierarchy sits +in `base.py`, which imports nothing from inside the package; the payload models and per-code exception +classes are generated into `catalogue.py`; `factory.py` sits above both. Imports only ever point +downward. + +The payload of a catalogued error is read as **typed attributes on the exception**, not as a payload +object. No class in this design exposes a `data` attribute, and no payload attribute is typed `Any` — +see [research.md](./research.md) R6. `Any` appears only in the raw decoded JSON that `extensions` and +`errors` hold, where the value types genuinely are unknown at the type level. + +## Hierarchy + +```text +Error (existing root, unchanged) +├── ApiError NEW — "the server reported an error" +│ ├── GraphQLError re-rooted under ApiError +│ │ ├── NodeNotFoundError re-rooted, unified, adopts NODE_NOT_FOUND +│ │ │ └── NodeInvalidError inherits the re-rooting +│ │ ├── BranchNotFoundError re-rooted, unified, adopts BRANCH_NOT_FOUND +│ │ ├── SchemaNotFoundError re-rooted, unified, adopts SCHEMA_NOT_FOUND +│ │ └── +│ └── AuthenticationError re-rooted under ApiError, name and constructor unchanged +└── … every other existing exception, untouched + +# GraphQLError and AuthenticationError are siblings; neither inherits from the other, +# and no class in the package has more than one parent. +# +# The three 401/403 codes get no class of their own. They arrive on whichever generic +# class their transport produces — AuthenticationError for a real 401/403, GraphQLError +# for a resolver-raised failure inside a 200 response — and carry their identity in +# `code`: "AUTHENTICATION_REQUIRED", "TOKEN_EXPIRED", "PERMISSION_DENIED". +``` + +## ApiError + +The base for "the server reported an error", carrying the parsed envelope (FR-001). + +| Attribute | Type | Notes | +|-----------|------|-------| +| `code` | `str \| None` | A catalogue code string, or `None`. Never an integer, so the REST envelope's integer `code` cannot be mistaken for a catalogue code (FR-003). Generated classes carry it as a class attribute, since they are only ever raised from a response. The factory sets it per-instance in the two cases a class attribute cannot cover: an unrecognised code on a generic class, and **an adopted class**, whose `code` must stay `None` on a client-side raise and be set when the same class carries a server-reported failure. | +| `http_status` | `int \| None` | The status the envelope declares, not the one observed on the wire, which the exception does not carry. `None` when the governing error declared none. Generated classes will carry the catalogue's declared status as a class attribute, covering a code whose envelope omitted it. The envelope's value is the catalogue's in all but one case: where the catalogue could not resolve a status more specific than 500, the server substitutes the HTTP status it is about to return, so a generated class's declared 500 and the envelope's value can differ. | +| `extensions` | `dict[str, Any] \| None` | The raw `extensions` mapping of the governing error, so nothing the SDK does not model is lost. `Any` here is the honest type of decoded JSON, not an escape hatch: the mapping's value types genuinely are not known at the type level. | +| `errors` | `Sequence[dict[str, Any]]` (empty tuple by default) | The complete, unreordered server error list (FR-013). Lives here rather than only on `GraphQLError` because the authentication branch must retain it too and FR-015 freezes `AuthenticationError`'s constructor. The default is an immutable empty tuple and is only a floor for a directly constructed `AuthenticationError`; anything built from a response, and every adopted class, is constructed with a list. | +| `query` | `str \| None` | Class-level default `None`. | +| `variables` | `dict \| None` | Class-level default `None`. | + +`ApiError` adds no required constructor arguments. Its subclasses keep the constructors they have +today, and the factory sets these attributes after construction. + +Two mechanisms sit behind these attributes, and they answer different questions. The class-level +defaults guarantee the attributes *exist* on any `ApiError`, including an `AuthenticationError` +constructed directly through the constructor FR-015 freezes — so `except GraphQLError as exc: +exc.errors` can never raise `AttributeError`. The three adopted classes additionally call +`GraphQLError.__init__` explicitly so their envelope state is set by the constructor that owns it and +`errors` is a *list*, matching the type documented on `GraphQLError` rather than the base's tuple +default. + +## GraphQLError + +| Attribute | Type | Change | +|-----------|------|--------| +| `errors` | `list[dict[str, Any]]` | Unchanged. Complete and unreordered; the first element governs the raised class (FR-013). Every subclass reaches this constructor — the adopted three call it with `errors=[]` — so it is a list on every `GraphQLError`, never the base's tuple default. | +| `query` | `str \| None` | Unchanged, still populated for catalogued failures (FR-024). | +| `variables` | `dict \| None` | Unchanged. | +| `message` | `str` | Constructor gains an optional `message`. Omitted → today's string, byte-identical. Supplied by the factory for a catalogued failure → names the code and the server's message, with no query text (FR-022, FR-023). | + +## AuthenticationError + +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 +code, and the catalogue code on the GraphQL path — `"TOKEN_EXPIRED"`, `"AUTHENTICATION_REQUIRED"`, or +`"PERMISSION_DENIED"`. + +`GraphQLError` likewise carries a `code` when a resolver-raised authentication or permission failure +arrives inside an HTTP 200 response. So both generic classes can be catalogued or uncatalogued, and +`exc.code` is the only thing that distinguishes those cases. + +## Adopted classes + +Three hand-written classes declare the catalogue code they represent, which is how the generator +knows not to define them: + +| Class | `CODE` | Payload field promoted onto | Type change | +|-------|--------|----------------------------|-------------| +| `NodeNotFoundError` | `NODE_NOT_FOUND` | `node_kind` → `node_type`, `identifier` → `identifier` | `identifier` widens to `Mapping[str, list[str]] \| str` | +| `BranchNotFoundError` | `BRANCH_NOT_FOUND` | `branch_name` → `identifier` | none | +| `SchemaNotFoundError` | `SCHEMA_NOT_FOUND` | `kind` → `identifier` | none | + +Their `from_payload` is hand-written, because the target attribute names already exist and are not the +catalogue's. Every construction shape in use today keeps working: the filter mappings passed from +`infrahub_sdk/store.py` and `infrahub_sdk/client.py`, and the plain string passed from +`infrahub_sdk/file_handler.py` that the current annotation wrongly excludes. `exc.code is not None` +distinguishes a server-reported raise from a client-side one. + +Unlike the generated classes, **every attribute on these three is optional**, even where the catalogue +declares the underlying field required. Neither provenance populates the full set — the catalogue +supplies no `branch_name`, and four of the nine construction sites supply no node kind — so a +required attribute would be a promise the class cannot keep. See [research.md](./research.md) R9, which +also records the tripwire for splitting these classes and why that is a later change. + +## Generated exception classes + +Generated into `catalogue.py`, one per catalogue code that is not adopted. Each declares: + +| Member | Value | +|--------|-------| +| `CODE` | The catalogue code string. | +| `code` | Class attribute equal to `CODE`. | +| `http_status` | The catalogue-declared status. | +| `DATA_MODEL` | The payload model class, used to validate the envelope. | +| Promoted attributes | One per payload field, typed as the catalogue declares it — required fields non-optional, nullable fields carrying their declared default. Assigned in `__init__`, so they always exist. | +| `from_payload` | Classmethod taking a validated payload plus the envelope, returning the constructed exception. The factory's only construction path. | +| docstring | The catalogue's `description`, plus its stability level. | + +The declared status decides whether a code gets a class at all, not which parent it takes: a 401/403 +code gets none, and everything else descends from `GraphQLError` (FR-008). Twelve generated classes +today, each with exactly one parent. + +## Generated payload models + +Generated into `catalogue.py` alongside the classes, one per catalogue code, including codes with an +empty payload and including the adopted codes. Named from the catalogue's `data_schema.title` verbatim +(FR-007), so SDK and frontend binding names agree. `model_config = ConfigDict(extra="ignore")` makes an +unknown field from a newer server a no-op (FR-004). Required fields stay required; nullable fields carry +the catalogue's declared default. + +These models are the validation mechanism and the source of the promoted attributes' types. They are +importable — useful for building a test fixture — but a consumer never reads one off an exception. + +A payload that fails validation falls back to the generic class for the branch, with the code still +readable. The server has a reachable path that emits an empty payload under a code whose schema declares +required fields, so this is a real state rather than a hypothetical one. + +Field types, mapped from the catalogue's JSON Schema vocabulary: + +| JSON Schema | Python | +|-------------|--------| +| `{"type": "string"}` | `str` | +| `{"type": "string", "format": "date-time"}` | `datetime` | +| `{"type": "integer"}` / `{"type": "number"}` | `int` / `float` | +| `{"type": "boolean"}` | `bool` | +| `{"type": "array", "items": T}` | `list[T]` | +| `{"anyOf": [T, {"type": "null"}]}` | `T \| None` | + +Anything outside that vocabulary fails generation loudly rather than emitting a guess. + +## Resolution map + +`CODE_TO_EXCEPTION: dict[str, type[ApiError]]` in `catalogue.py`, covering every catalogue +code: generated classes for most, imported adopted classes for the three. It is the only lookup the +factory performs, and a miss is the entire fallback story (FR-012). + +## The two envelopes + +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. + +```json +{ + "errors": [ + { + "message": "…", + "extensions": {"code": "UNIQUENESS_VIOLATION", "http_status": 422, + "data": {"node_kind": "TestPerson", "fields": ["name"]}} + } + ] +} +``` + +**REST** (`/api/…`) — the legacy envelope, where `extensions.code` is an *integer* mirroring the HTTP +status. No catalogue code, no `data`. Parsed for its messages only; `exc.code` stays `None`. + +```json +{"errors": [{"message": "…", "extensions": {"code": 401}}]} +``` + +## State transitions + +None. Exceptions are constructed, raised, and read. diff --git a/dev/specs/ifc-3034-error-catalogue/plan.md b/dev/specs/ifc-3034-error-catalogue/plan.md new file mode 100644 index 000000000..76af08d22 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/plan.md @@ -0,0 +1,227 @@ +# Implementation Plan: Error Catalogue in the Python SDK + +**Branch**: `pog-error-catalogue-IFC-3034` | **Date**: 2026-08-24 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `dev/specs/ifc-3034-error-catalogue/spec.md` + +## Summary + +Make 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 approach has four parts: + +1. **A parsed envelope on the base classes.** A new `ApiError` base carries `code`, `http_status`, the + raw `extensions`, and the server error list; `GraphQLError` and `AuthenticationError` both descend + from it. The envelope is read by one raise-time factory shared by every existing raise site, so the + code is readable against any server version even with no generated bindings at all. Because the + catalogue is GraphQL-only, every generated class descends from `GraphQLError` and from nothing else. + The three 401/403 codes get no class: they are the only codes that arrive on two transports, so each + is raised as the generic class its transport already produces and identified by `exc.code`. The + hierarchy stays a tree. + + The payload is **not** an attribute. Each catalogued class promotes its payload's fields to directly + typed attributes — `exc.node_kind`, `exc.fields` — typed exactly as the catalogue declares them. + That is what US1 asks for, it matches how the three adopted classes already work, and it means no + class needs a loosely typed payload attribute for subclasses to narrow. Nothing in this design is + typed `Any` beyond the raw decoded JSON in `extensions` and `errors`. +2. **Generated per-code bindings.** Infrahub renders one exception class and one pydantic payload + model per catalogue code into a single module in the SDK submodule, next to the schema models and + protocols it already generates there, and its existing generated-artefact validation gains a check + for it. The module is generated in full and imports only the hand-written base, which is what keeps + the package's import graph one-way. +3. **Reconciling the three names that already exist.** `NodeNotFoundError`, + `BranchNotFoundError`, and `SchemaNotFoundError` are adopted by the generator rather than + duplicated: the hand-written classes declare the code they represent, the generator sees that + and imports them instead of defining them. +4. **Removing the string matching.** The silent-refresh decision reads the code, falling back to + the legacy message check only for servers that predate the catalogue. + +The catalogue holds 15 codes today: 12 get a class under the GraphQL branch — nine generated, three +adopted from the SDK's existing names — and the three declaring 401 or 403 get none, carrying their +identity in `exc.code` instead. + +## Technical Context + +**Language/Version**: Python 3.10-3.13 (SDK); the generator runs under the Infrahub repository's +Python environment + +**Primary Dependencies**: pydantic >= 2.0 (payload models), httpx (transport), typer + rich (CLI); +Jinja2 and invoke on the Infrahub side for generation. No new runtime dependency. + +**Storage**: N/A + +**Testing**: pytest with `asyncio_mode = "auto"`, `pytest-httpx` for transport-level mocking; +response-envelope fixtures under `tests/fixtures/`; both client variants exercised through the +`BothClients` fixture in `tests/unit/sdk/conftest.py` + +**Target Platform**: Library consumed by `infrahubctl`, the Infrahub Ansible collection, and +external Python applications + +**Project Type**: Library plus its CLI, spanning two repositories (bindings are generated in +Infrahub, consumed here) + +**Performance Goals**: None. The factory runs once per failed request; parsing is a dict lookup plus +one pydantic validation. + +**Constraints**: + +- Parsing MUST NOT raise for any envelope shape, including an unknown code, an unknown payload + field, an absent `extensions`, or a pre-catalogue integer `code`. +- Every existing exception name, constructor signature, and `except` clause keeps working. +- The SDK holds no copy of the catalogue schema; the generated bindings are the only artefact that + crosses the repository boundary. +- No circular imports. The exceptions package is strictly layered, and generated files are generated + in full — never a hand-edited region inside a generated file, and never a generated region inside a + hand-written one. +- `infrahub_sdk.exceptions` is the one supported import path for every exception, and no name + importable from it today may stop being importable from it. The restructuring must be invisible from + outside the package. +- The raised class is a function of the response alone — the first error's code, its payload's validity, + and the transport the SDK observed — and never of binding freshness. Regenerating bindings must not + change which exception a byte-identical response produces for a code the SDK already knows. + +**Scale/Scope**: 15 catalogue codes; 11 `AuthenticationError` raise sites and 4 `GraphQLError` +raise sites collapse onto two factories; one generated module; one CLI ladder reordering; one new +documentation topic page. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Assessment | +|-----------|------------| +| I. Async/Sync Dual API Parity | **Pass.** No new public client method, so `test_method_count` and `test_validate_method_signature` are untouched. The behaviour change lands on the async and sync paths of `_execute_graphql`, `_execute_graphql_with_file`, and the relogin wrappers, which are separate implementations; both are covered by parametrized tests over `["standard", "sync"]`. | +| II. Backward Compatibility & Public API Stability | **Pass, with two documented broadenings.** Nothing is removed or renamed, so no deprecation path is required. `except GraphQLError` additionally catches client-side node/branch/schema lookup misses, and `NodeNotFoundError.identifier` widens to admit the plain string the file handler already passes. Both get towncrier fragments per FR-016. `infrahub_sdk.exceptions` is treated as public and is the one supported import path; a snapshot test pins that no name importable from it disappears, so restructuring it into a package is invisible from outside. | +| III. Layered Architecture | **Pass.** All envelope parsing, resolution, and message construction lives in `infrahub_sdk/`. The CLI change is confined to presentation: reordering its `isinstance` ladder and degrading the GraphQL renderer when there are no server errors to render. | +| IV. Type Safety & Typed Errors | **Pass, with no suppressions.** This principle is the feature. Generated payload models are pydantic v2; every failure mode gets a specific subclass under `Error`; each catalogued class carries its payload's fields as attributes typed exactly as the catalogue declares them. `Any` appears only where the value genuinely is unknown at the type level — the raw decoded JSON in `extensions` and `errors`, the latter already annotated that way today. No `# type: ignore` is anticipated anywhere; if the R15 spike shows one is needed, that is a signal the shape is wrong rather than a licence to add it. | +| V. Test-First Development | **Pass.** Tests ship in the same change: envelope fixtures per code, cross-version fallback cases, ladder assertions, relogin cases, and both-client parity. Deliberate behaviour changes (the message change, the re-rooting) are pinned by tests that assert the new behaviour rather than being worked around. Because every fixture is authored alongside the parser that reads it, the mocked suite alone could pass against an envelope shape the server never sends — so two real catalogued failures are also driven through testcontainers, which is where the constitution puts behaviour that depends on real server responses. | +| VI. Format & Lint Before Commit | **Pass, with one justified silencing.** `uv run invoke format lint-code` and `lint-docs`; the generated modules are `ruff format`ed by the generator, as the other generated artefacts are. The façade re-exports the generated classes with `from .catalogue import *`, which trips `F403` under `select = ["ALL"]`. A wildcard is the only re-export form that keeps the export surface automatic as codes are added *and* stays visible to mypy and `ty`; the alternative is a hand-maintained list edited every time the catalogue grows. Recorded as a commented `per-file-ignores` entry, mirroring the existing entry for `infrahub_sdk/schema/generated/*.py`. | +| VII. Documentation Accuracy | **Pass.** A new hand-written topic page describes the hierarchy and the cross-version guarantees, linking to Infrahub's published catalogue for the code list rather than restating it. Converting `exceptions.py` into a package requires categorising it in `tasks.py::get_modules_to_document`; it goes in `packages_to_ignore`, which preserves today's `sdk_ref` output exactly and avoids coupling the SDK's `docs-validate` to an Infrahub-side regeneration. | + +**Result**: no violations. Complexity Tracking is empty. + +## Project Structure + +### Documentation (this feature) + +```text +dev/specs/ifc-3034-error-catalogue/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ ├── exception-hierarchy.md # The consumer-facing contract +│ └── generator-contract.md # The Infrahub-to-SDK generation contract +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 output (/speckit-tasks, not created here) +``` + +### Source Code + +This repository (`infrahub-sdk-python`): + +```text +infrahub_sdk/ +├── exceptions/ # exceptions.py becomes a package, layered strictly one-way +│ ├── base.py # layer 0: today's exceptions.py + ApiError + adopted-code markers +│ ├── catalogue.py # GENERATED in full — layer 1: payload models, per-code classes, map +│ ├── factory.py # layer 2: raise-time resolution from a response envelope +│ └── __init__.py # layer 3: façade, re-exports base + catalogue, defines __all__ +├── client.py # Raise sites and the relogin wrappers call the factories +├── object_store.py # REST auth raise sites call the auth factory +├── file_handler.py # REST auth raise site calls the auth factory +├── analyzer.py # Pre-existing GraphQLError(str) misuse, corrected +└── ctl/ + └── utils.py # Ladder reordering and renderer degradation + +tests/ +├── fixtures/ +│ └── error_catalogue/ # Response-envelope fixtures per code and per cross-version case +└── unit/ + ├── sdk/ + │ ├── test_exceptions.py # Hierarchy, single parents, naming, adoption, messages + │ ├── test_exceptions_layering.py # Asserts the import graph stays one-way + │ ├── test_exceptions_public_names.py # No name importable from the package may disappear + │ ├── test_error_catalogue.py # Factory: resolution, precedence, fallbacks, totality + │ ├── test_relogin_headers.py # Extended with the typed refresh decision + │ └── test_client.py # Both-client raise-path assertions + └── ctl/ + └── test_utils.py # Ladder behaviour and no-server-errors rendering + +tests/integration/ +├── test_infrahub_client.py # Real catalogued failures, async +└── test_infrahub_client_sync.py # The same two failures, sync + +docs/docs/python-sdk/topics/ +└── error_handling.mdx # New topic page (sidebar globs this directory) + +changelog/ # towncrier fragments: typed errors, identifier widening, broadening +pyproject.toml # per-file-ignore for the façade's re-export star imports +tasks.py # Add `exceptions` to packages_to_ignore for API-doc generation +``` + +The `opsmill/infrahub` repository (requirements FR-025 to FR-027): + +```text +backend/templates/ +└── generate_sdk_errors.j2 # New template: payload models, exception classes, resolution map +tasks/ +└── backend.py # Renderer called from `generate`; diff added to validate_generated +.github/ +└── workflows/ci.yml # backend-validate-generated gains the catalogue trigger +``` + +The existing path filters need no change: a submodule artefact cannot be filtered on from the +superproject, and `sdk_files`, `backend_files`, and `error_catalogue_files` already cover the three +things that can change. See [research.md](./research.md) R2. + +**Structure Decision**: `infrahub_sdk/exceptions.py` becomes the `infrahub_sdk/exceptions/` package, +with a strictly one-way import graph and no cycle anywhere in it: + +```text +base.py (written) → imports nothing from inside the package +catalogue.py (generated) → imports base +factory.py (written) → imports base and catalogue +__init__.py (written) → imports all of the above +``` + +Each module may import only from a strictly lower layer, and no module ever imports the package +façade — internal code always names the concrete submodule. `base.py` sitting at the bottom with no +intra-package imports at all is what the payload decision buys: because a payload's fields are promoted +to attributes rather than exposed as an object, no hand-written class needs to name a generated model +type, so the generated module needs no separate models module below `base.py` and no type-only import +to keep the hand-written hierarchy independent of generated code. + +The rule is enforced by `tests/unit/sdk/test_exceptions_layering.py`, which parses each module and +fails on any upward import, so the property cannot decay silently. Every `from .exceptions import X` +in the codebase keeps working unchanged. See [research.md](./research.md) R1 and R6. + +## Sequencing + +Priority labels in the specification describe value, not order. The generator is US5 (P2) but produces +the per-code classes US1 (P1) delivers, so task ordering must follow the dependency: the Infrahub-side +generator and its first hand-verified run come first. FR-002 softens this — the envelope parses onto the +base classes with no bindings at all, so `code`, `http_status`, and the typed relogin decision (US4) are +independently landable — but the typed per-code classes are not. See [research.md](./research.md) R16. + +## Prerequisite inside the SDK + +The generator aborts on a derived name that collides with an existing class which has not declared that +code, so the three `CODE` declarations in `base.py` are a prerequisite for generation succeeding at all +— not merely for it producing the right output. Verified by executing the discovery walk against the +current sources; see [research.md](./research.md) R4. Task ordering must put the adoption declarations +before the generator's first run, against the instinct to build the generator first. + +## Cross-repository landing order + +The SDK change lands first and Infrahub then bumps its submodule pointer to it — the pattern both +repositories already follow, and the only order under which Infrahub's content-level validation can +pass. See [research.md](./research.md) R17, which notes the one confirmation still worth getting. + +## Complexity Tracking + +No Constitution Check violations, so nothing to justify here. diff --git a/dev/specs/ifc-3034-error-catalogue/quickstart.md b/dev/specs/ifc-3034-error-catalogue/quickstart.md new file mode 100644 index 000000000..c0552b2b9 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/quickstart.md @@ -0,0 +1,202 @@ +# 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 +success criterion it maps to. Details of the promised behaviour live in +[contracts/exception-hierarchy.md](./contracts/exception-hierarchy.md); the generation half lives in +[contracts/generator-contract.md](./contracts/generator-contract.md). + +## Prerequisites + +```bash +uv sync --all-groups --all-extras +``` + +Two checkouts are involved. Scenarios 1 to 6 run here; scenario 7 runs from an Infrahub checkout with +the SDK as its `python_sdk` submodule. + +## Scenario 1 — Typed errors and their payloads + +Proves that every catalogue code is reachable as its own type with its payload's fields as typed +attributes, and that a developer never has to read a message (SC-001). + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py -v +``` + +Expected: one passing case per catalogue code. Each asserts the raised class and concrete attribute +values read directly off the exception — `exc.node_kind` and `exc.fields` for `UNIQUENESS_VIOLATION`, +`exc.node_type` and `exc.identifier` for `NODE_NOT_FOUND` — and asserts `exc.code` and +`exc.http_status` match the catalogue entry. No test reads a payload object, because there isn't one. + +## Scenario 2 — Cross-version tolerance + +Proves that nothing raises during parsing on any server version, old or new (SC-004). + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py -k crossversion -v +``` + +Expected: passing cases for an unknown code (generic class for the branch, `code` readable as a +string), an unknown payload field (ignored), an absent `extensions` (`code is None`, today's +behaviour), a pre-catalogue integer `code` on `/graphql` (`code is None`), and a payload that +violates the catalogue contract (the generic class for the branch, with `exc.code` still readable). No +case raises during parsing. + +Then prove the factory cannot make things worse than they were, by feeding it malformed envelopes — +`errors` as a bare string, `extensions` as a list, `code` as a nested object: + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py -k malformed -v +``` + +Expected: each degrades to today's generic exception. A bug in resolution can never replace the +server's error with an SDK `TypeError`. + +## Scenario 3 — The hierarchy and the existing clauses + +Proves that no `except` clause loses coverage, that the CLI ladder is not shadowed, and that one +clause catches every server-reported error on either transport (SC-003, SC-008). + +```bash +uv run pytest tests/unit/sdk/test_exceptions.py tests/unit/sdk/test_exceptions_public_names.py \ + tests/unit/ctl/test_utils.py -v +uv run pytest tests/unit/ -q +``` + +Expected: a real 401 carrying `TOKEN_EXPIRED` is caught by `except AuthenticationError` with +`exc.code == "TOKEN_EXPIRED"`, and a `PERMISSION_DENIED` inside an HTTP 200 is caught by +`except GraphQLError` with `exc.code == "PERMISSION_DENIED"`; no class in the package has more than one +parent; `NodeInvalidError` is an instance of `GraphQLError`; `ApiError` catches both a +GraphQL and an authentication failure; every name importable from `infrahub_sdk.exceptions` before the +change is still importable from it; reading `exc.errors`, `exc.query`, and `exc.variables` off a purely +client-side `NodeNotFoundError` returns empty/`None` rather than raising `AttributeError`; driving +`handle_exception` with `NodeNotFoundError` produces the not-found rendering rather than the GraphQL +error rendering; a catalogued failure renders as its code plus the server's message rather than as +"Authentication failure" or a bare error list; rendering an exception with no server errors behind it +prints the message. The full unit suite is green, with any deliberately changed assertion updated rather +than skipped. + +## Scenario 3b — The exceptions package has no import cycle + +Proves the package's import graph points strictly downward, so the cycle the layout was designed to +avoid cannot creep back in. + +```bash +uv run pytest tests/unit/sdk/test_exceptions_layering.py -v +uv run python -c "import infrahub_sdk.exceptions" +uv run python -c "import infrahub_sdk.exceptions.base" +``` + +Expected: the layering test passes, reporting any intra-package import that points at its own layer or +higher, and any import reaching another part of the SDK at all — including imports written inside a +function body, which is how a cycle usually returns once the obvious route is closed. Both bare imports +succeed on their own: the package depends on nothing else in the SDK, and `base.py` depends on nothing +else in the package, generated or otherwise. + +## Scenario 4 — Async and sync parity + +Proves both clients raise the same type with the same attributes (SC-006). + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py tests/unit/sdk/test_client.py -k "standard or sync" -v +``` + +Expected: every raise-path case passes for both `client_type` values, asserting the same class and the +same payload attributes. + +## Scenario 5 — No string matching left, and refresh still works + +Proves the silent-refresh decision is typed while a pre-catalogue server still refreshes (SC-002). + +```bash +uv run pytest tests/unit/sdk/test_relogin_headers.py -v +grep -rn "Expired Signature" infrahub_sdk/ +``` + +Expected: a refresh is attempted for a 401 carrying `TOKEN_EXPIRED` and for a 401 carrying the legacy +`"Expired Signature"` message, and not for an unrelated 401. The `grep` returns exactly one site — the +documented pre-catalogue fallback inside the shared decision helper, down from the two occurrences +today, one in each relogin wrapper — and no other message match for a catalogued failure. The GraphQL +schema-validation probing used for server feature detection is out of scope and still present. + +## Scenario 6 — Messages, lint, and docs + +Proves the message change and the repository gates (SC-007). + +```bash +uv run pytest tests/unit/sdk/test_exceptions.py -k message -v +uv run invoke format lint-code +uv run invoke docs-generate && uv run invoke docs-validate +uv run invoke lint-docs +ls changelog/ +``` + +Expected: a *server-reported* catalogued failure's message names the code and the server's message and +contains no query text; an uncatalogued failure's message is byte-identical to today's, as is that of a +unified class raised with no catalogue code behind it. `docs-validate` passes with +no change to `sdk_ref`, since the `exceptions` package is categorised as ignored for API-doc +generation — if it is not categorised at all, `docs-generate` fails with +`Uncategorized packages under infrahub_sdk/`. `changelog/` carries fragments for the typed errors, the +`identifier` widening, and the `except GraphQLError` broadening. + +## Scenario 6b — The envelope shape is what the server actually sends + +Proves the contract against a live server rather than against fixtures written alongside the parser +that reads them. Requires Docker. + +```bash +uv run pytest tests/integration/test_infrahub_client.py tests/integration/test_infrahub_client_sync.py \ + -k "catalogue" -v +``` + +Expected: saving a node that collides on a unique attribute raises `UniquenessViolationError` with the +node kind and colliding fields populated from the real payload; deleting a missing node raises +`NodeNotFoundError` with its kind and identifier. Both pass on the async and sync clients. If these +fail while scenario 1 passes, the unit fixtures encode an envelope the server does not send. + +## Scenario 7 — Generated bindings cannot silently drift + +Proves that a catalogue change omitting regeneration fails validation, and that a regenerated artefact +is byte-identical to a fresh generation (SC-005). Run from the Infrahub checkout. + +```bash +uv run invoke backend.generate +git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py # clean: byte-identical +uv run invoke backend.validate-generated # passes +``` + +Then prove the negative: add a code to the backend catalogue, regenerate the catalogue JSON alone, and +re-run the validator. + +```bash +uv run invoke backend.export-error-catalogue +uv run invoke backend.validate-generated # must fail, naming the stale submodule artefact +``` + +Expected: the second run exits non-zero with a hint pointing at `uv run invoke backend.generate`. +Revert the catalogue change afterwards. + +The generator is a pure text transform over `schema/error-catalogue.json` plus an `ast` parse of the +SDK's `base.py`, so it needs no running Infrahub — only the submodule checked out, which every Python +job in that repository already requires. + +## Manual end-to-end check + +Against a running Infrahub, with the SDK installed: + +```python +from infrahub_sdk import InfrahubClient +from infrahub_sdk.exceptions import ApiError, UniquenessViolationError + +client = InfrahubClient() +node = await client.create(kind="TestPerson", name="Jane") # a name that already exists +try: + await node.save() +except UniquenessViolationError as exc: + print(exc.code, exc.http_status, exc.node_kind, exc.fields) +except ApiError as exc: + print("uncatalogued or unrecognised:", exc.code) +``` + +Expected: the specific class, its catalogue code and status, and the colliding field names read +directly off the exception with no guard and no intermediate object — and no query text in the message. diff --git a/dev/specs/ifc-3034-error-catalogue/research.md b/dev/specs/ifc-3034-error-catalogue/research.md new file mode 100644 index 000000000..7c54faae7 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/research.md @@ -0,0 +1,818 @@ +# Research: Error Catalogue in the Python SDK + +Every decision below was reached against the two checkouts as they stand, not from the specification +alone. The specification carried no `NEEDS CLARIFICATION` markers; what follows resolves the +mechanism questions it deliberately left to the plan. + +## Survey findings the decisions rest on + +Every count and example below is read from `schema/error-catalogue.json` on **`opsmill/infrahub@develop`**, +which is the line this feature pairs with. That matters: `stable` currently carries 14 codes and no +`UNIQUENESS_VIOLATION`, so a reviewer reading the wrong branch will find the numbers off by one and the +headline example missing. The design does not depend on the count — the generator reads whatever the +catalogue holds — but the acceptance scenarios do, and US1 scenario 1 is only demonstrable against a +server whose catalogue includes `UNIQUENESS_VIOLATION`. + +On `develop` the catalogue (`infrahub_catalogue_version: "1"`) holds 15 codes. Twelve declare a non-auth +status (400, 404, 422, 423, 500) and three declare 401/403 (`AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, +`PERMISSION_DENIED`). Two codes declare an empty payload (`AUTHENTICATION_REQUIRED`, +`UNDEFINED_ERROR`). One field carries `format: date-time` (`TOKEN_EXPIRED.expired_at`); the rest are +strings, nullable strings, and one string array (`UNIQUENESS_VIOLATION.fields`). + +Three derived class names collide exactly with hand-written SDK classes: `NODE_NOT_FOUND` → +`NodeNotFoundError`, `BRANCH_NOT_FOUND` → `BranchNotFoundError`, `SCHEMA_NOT_FOUND` → +`SchemaNotFoundError`. + +Raise sites that need to change: `GraphQLError` is raised at `infrahub_sdk/client.py:1360` and +`:2348` (`_execute_graphql`, async and sync) and `:1429` and `:2417` (the file-upload variants). +`AuthenticationError` is raised from the same four GraphQL paths (`:1350`, `:1688`, `:2338`, `:3857`) +and from six REST paths in `infrahub_sdk/object_store.py` plus one in `infrahub_sdk/file_handler.py`, +all with the identical four-line "decode, collect messages, join with ` | `" shape. + +Infrahub already owns two generation paths that write into the submodule +(`tasks/backend.py::_generate_schemas` and `::_generate_protocols`) and validates both with +`git -C python_sdk diff --exit-code` inside `validate_generated`. Separately, +`tasks/frontend.py::regenerate_error_bindings` regenerates the three catalogue-derived artefacts +(the JSON, the frontend TypeScript bindings, the docs page) and `check_error_bindings` diffs them. +The frontend's hand-rolled generator (`frontend/app/scripts/generate-error-bindings.mjs`) is the +closest precedent for what the SDK generator must do. + +Infrahub declares the SDK as `infrahub-sdk = { path = "python_sdk", editable = true }`, so `uv sync` +fails outright without the submodule. Every Python job in that repository therefore already needs +`submodules: true`, and 20 of the 32 checkouts in `ci.yml` set it. + +## R1 — Where the generated bindings live in the SDK, with no circular imports + +**Decision**: convert `infrahub_sdk/exceptions.py` into a package of four modules in a strict, +enforced layer order. No module imports a module at its own level or above, and no module imports the +package façade. + +| Layer | Module | Written or generated | May import | +|-------|--------|----------------------|------------| +| 0 | `base.py` | hand-written | nothing from inside the package | +| 1 | `catalogue.py` | generated, in full | `base` | +| 2 | `factory.py` | hand-written | `base`, `catalogue` | +| 3 | `__init__.py` | hand-written | all of the above | + +**Rationale**: two constraints pull against each other. The generated classes must descend from the +base classes, and `infrahub_sdk.exceptions` must re-export the generated classes (FR-005). Keeping +both in one module makes that a cycle whose only resolution is a wildcard import at the bottom of the +file — fragile, and in exactly the module that must never fail to import. Splitting hand-written from +generated makes the dependency one-way and puts the façade above both. + +Nothing in `base.py` needs anything from the generated module. That falls out of the payload decision +in R6: because a payload's fields are promoted to directly typed attributes on the exception rather +than exposed as a payload object, no hand-written class needs to name a generated model type — not even +the three adopted classes. So the hand-written hierarchy imports and behaves identically whether or not +the generated module is present, with no type-only import required to achieve it. + +**Enforcement**: `tests/unit/sdk/test_exceptions_layering.py` parses each module in the package with +`ast` and asserts every intra-package import points to a strictly lower layer. It walks *every* +`Import` and `ImportFrom` node, not only module-level ones — a function-body import is the classic way +a cycle gets reintroduced once the obvious route is closed — and it counts imports inside +`TYPE_CHECKING` blocks. An upward import fails the pull request that adds it, so the property cannot +decay into the cycle it was designed out of. No new dependency. + +**Consequences to handle**: + +- `tasks.py::get_modules_to_document` auto-discovers packages under `infrahub_sdk/` and raises + `ValueError` for any package that is not explicitly categorised, so `"exceptions"` must be + categorised. It goes in `packages_to_ignore`. Documenting it would generate `sdk_ref` pages from the + *generated* classes, which couples the SDK's own `docs-validate` to an Infrahub-side regeneration: + Infrahub writes new bindings into the submodule, nothing re-runs the SDK's `docs-generate`, and the + next SDK pull request fails on stale docs for a change made in another repository. `exceptions.py` is + not documented in `sdk_ref` today, so ignoring the package preserves current behaviour exactly and + creates no coupling. FR-028 is satisfied by the hand-written topic page, which is the better artefact + for a hierarchy anyway. +- The façade re-exports with `from .base import *` and `from .catalogue import *`, each source module + declaring its own `__all__` (generated for `catalogue.py`). That keeps the export + surface automatic as codes are added and stays visible to mypy and `ty`, at the cost of an `F403` + `per-file-ignores` entry with a comment — the same treatment + `infrahub_sdk/schema/generated/*.py` already gets. `infrahub_sdk.exceptions` is the supported import + path for consumers; the submodules beneath it are internal, and the façade is what makes that true + rather than aspirational. +- First generation is a bootstrap: the SDK pull request lands `catalogue.py` produced by running the + Infrahub generator from the paired branch, verified by hand once, which is what US5 anticipates. + +**Alternatives considered**: + +- *A sibling `infrahub_sdk/error_catalogue.py`.* Fails FR-005 — `infrahub_sdk.exceptions` could not + re-export it without reintroducing the cycle. +- *Keep one `exceptions.py` and wildcard-import the generated module at the bottom of the file.* + Works, and is precisely the fragility this decision exists to avoid. +- *Generate the whole of `exceptions.py`.* Rejected. Roughly thirty hand-written exceptions + unrelated to the catalogue live there — rate limiting, fragment rendering, YAML validation, and the + three unified classes with custom constructor and `__str__` logic. Generating the file would put all + of that under a generator owned by another repository, and would require the generator to carry + hand-written class bodies as template data. What this design does take from that idea is the part + worth keeping: the generated file is generated *in full*, so there is never a hand-edited region + inside a generated file or a generated region inside a hand-written one. +- *Split the generated models into their own module below `base.py`.* Necessary only if a hand-written + class must name a generated model type, which the promotion decision in R6 removes. Without that + need the split buys nothing and costs a module, a second artefact to validate, and an ordering + constraint on generation. + +## R2 — Where the generator lives and how validation is wired + +**Decision**: one Jinja2 template `backend/templates/generate_sdk_errors.j2`, rendered by a +`_generate_sdk_error_bindings` helper in `tasks/backend.py` and called from the existing +`backend.generate` task, with `backend.validate-generated` gaining +`git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py`. + +No new command, and no task outside the `backend` namespace generates the SDK's bindings. + +**Rationale**: the entry point already exists and already does exactly this job. + +```python +@task +def generate(context: Context) -> None: + """Generate internal backend models.""" + _generate_schemas(context=context) # → python_sdk/infrahub_sdk/schema/generated + _generate_protocols(context=context) # → python_sdk/infrahub_sdk/protocols.py +``` + +`backend.generate` writes into the SDK submodule today, paired with `backend.validate-generated`. The +error bindings are a third artefact of the same kind — Infrahub-generated, SDK-destined — so they +belong in the same pair rather than in a second entry point for the same category of work. + +The namespace boundary that matters here is **producer, not location**. `backend` names the side that +generates; that it writes into `python_sdk/` is the point of the task, not a violation of it. The +frontend is a sibling *consumer* of the same catalogue, which is why hooking regeneration into +`frontend.regenerate-error-bindings` was wrong: a consumer generating another consumer's artefact. + +Two incidental notes for the implementer. `generate`'s docstring says "internal backend models", which +has been inaccurate since it started generating SDK protocols; adding a third artefact is a good moment +to correct it. And `_generate_custom_graphql_types` is called by `validate_generated` but not by +`generate`, so `generate` is already the smaller set of the two — adding the error bindings to both +keeps them in step. + +**On the CI job**: no new step and no new job. `backend-validate-generated` runs +`backend.validate-generated`, which now covers the error bindings too. Submodule availability is not a +discriminator in that placement: `infrahub-sdk = { path = "python_sdk", editable = true }` in Infrahub's +`pyproject.toml` means `uv sync` fails without the submodule, so every Python job in that repository +already requires `submodules: true` — 20 of the 32 checkouts in `ci.yml` set it today. + +**Consequences**: + +- That job's `if` currently fires on `backend == 'true' || documentation == 'true'`. Add + `error_catalogue == 'true'` so a hand-edit of `schema/error-catalogue.json` alone cannot slip past. + This is the only CI edit required. +- **No new path filter.** An entry for the submodule artefact could never match: from the superproject + a submodule is a single gitlink, so a change inside it appears only as a change to `python_sdk` — + the same limitation that forces the diff to run inside the submodule. The repository already handles + this with `sdk_files: - "python_sdk"`, commented "Catch updates to the submodule commit" and included + in `backend_all`. The generator template is likewise already covered, since `backend_files` is + `backend/**`, and `error_catalogue_files` already lists the catalogue JSON. +- The failure hint names `uv run invoke backend.generate`. +- The SDK's bindings move with the other artefacts Infrahub generates into the SDK — the schema models + and the protocols — which is the grouping that matters for this feature. The catalogue's *other* + derived artefacts (the frontend bindings and the docs page) are regenerated by their own consumers, + and CI is what keeps all of them in step: the `error_catalogue` filter gates every one of their + checks, so a catalogue change that skips any of them fails the pull request that made it. + +**Pre-existing wart, deliberately left alone**: `tasks/frontend.py::regenerate_error_bindings` already +calls `backend.export_error_catalogue` and `docs.generate_error_catalogue`, so it is a catalogue +orchestrator carrying a frontend label. Tidying that is not this feature's job — but it is why +regeneration must not be hooked there: a consumer generating another consumer's artefact. + +**Alternatives considered**: + +- *A pytest in `backend/tests/unit/errors/test_export.py` that renders in memory and byte-compares + against the committed files*, mirroring `test_export_matches_committed_file` for the catalogue JSON. + Attractive — precise failure output, no git involved — but the renderer's output only becomes + canonical after `ruff format`, so the test would have to shell out to ruff or the template would + have to emit already-formatted output for all 15 codes. Rejected as one mechanism too many; + `validate_generated` already does render-then-format-then-diff for the two other submodule + artefacts. Worth revisiting if the diff output ever proves hard to act on. +- *Adding the SDK generation to `frontend.regenerate-error-bindings` and the check to + `frontend.check-error-bindings`.* Rejected: the frontend namespace should generate frontend artefacts + only, and a consumer should not generate another consumer's artefact. It would also have needed a + Python toolchain in a Node-only CI job to validate a Python artefact, making the job name lie. +- *A new `generate` / `validate` pair in `tasks/sdk.py`, matching the shape `tasks/schema.py` uses.* + Internally consistent, and the right answer if no entry point existed — but `backend.generate` and + `backend.validate-generated` already are that pair for everything Infrahub generates into the + submodule. A second entry point for the same category of work costs discoverability and invites the + two from drifting apart. +- *A standalone Python script mirroring the frontend's `.mjs` generator.* Would duplicate the + template-render-then-ruff pipeline `tasks/backend.py` already has. +- *No generator at all — hand-write the 15 classes once.* Worth stating plainly, because 15 classes is + not much typing. The generator's value is not saved keystrokes, it is US5: a catalogue change that + skips the SDK fails the pull request that made it, instead of surfacing months later as a code that + quietly falls back. Hand-written classes buy the typing and none of the drift protection. + +## R3 — Name derivation + +**Decision**: the exception class name is the code's underscore-separated parts capitalised and +joined, with `Error` appended only when the result does not already end in `Error`. The payload model +name is `data_schema.title` verbatim. + +Worked examples: `UNIQUENESS_VIOLATION` → `UniquenessViolationError` / `UniquenessViolationData`; +`UNDEFINED_ERROR` → `UndefinedError` (not `UndefinedErrorError`) / `UndefinedErrorData`; +`MERGE_IN_PROGRESS` → `MergeInProgressError` / `MergeInProgressData`. + +**Rationale**: FR-006 and FR-007. Taking the payload name from the declared title rather than +deriving it keeps SDK and frontend binding names identical, which the frontend generator already +relies on. The generator asserts a non-empty `data_schema.title`, as the frontend one does, so a +catalogue entry that omits it fails generation loudly. + +**Undeclared collisions must fail generation.** Deriving a name that already exists in `base.py` is +fine when the SDK declared that adoption (R4) and a latent disaster otherwise. The façade re-exports +`base` and then `catalogue`, so a generated class would win the name and silently shadow the +hand-written one — changing what an existing `except` clause catches, from a change made in the other +repository. This is not hypothetical. The SDK already defines `ValidationError`, `RateLimitError`, +`InvalidResponseError`, `FileNotValidError`, `ResourceNotDefinedError`, and `TimestampFormatError`, and +the codes `VALIDATION_ERROR`, `RATE_LIMIT`, `INVALID_RESPONSE`, `FILE_NOT_VALID`, +`RESOURCE_NOT_DEFINED`, and `TIMESTAMP_FORMAT` derive exactly those names — checked against the current +module, and this is the set the collision check in R4 is exercised against. A 429 code in the catalogue +is an entirely ordinary thing to add. + +The generator therefore collects every class name defined in `base.py`, not only those carrying a +`CODE`, and aborts on any derived name that matches one without a matching `CODE` declaration. The +remedy is then a deliberate SDK decision — adopt the code, or rename — taken in the pull request that +adds the code rather than discovered months later. + +## R4 — Codes whose class already exists + +**Decision**: adoption is discovered, not configured. The hand-written class declares the code it +represents (`CODE = "NODE_NOT_FOUND"` on `NodeNotFoundError`, and likewise for `BranchNotFoundError` +and `SchemaNotFoundError`); the generator parses `infrahub_sdk/exceptions/base.py` with `ast`, +collects every class whose body assigns a `CODE` string, and for those codes emits an import plus a +`CODE_TO_EXCEPTION` entry instead of a class definition. It still emits the payload model for an +adopted code. + +**Rationale**: the alternative is a hand-maintained code-to-class table in the generator, which is +the kind of thing FR-008 exists to avoid and which would live in the wrong repository — the decision +to unify a name is an SDK decision. Discovery puts the declaration next to the class it describes, so +adopting a fourth code later is a one-line SDK change with no generator edit. + +Reading the source with `ast` rather than importing the SDK keeps the generator a pure text transform, +as the frontend's generator is, and means generation never depends on the SDK checkout being in an +importable state. The same walk collects every class *name* defined in `base.py`, which is what the +collision check in R3 needs. + +**Fallback**: if the `ast` walk proves awkward, import `infrahub_sdk.exceptions.base` and read `CODE` +off the classes — `base.py` imports nothing from inside the package, so it is importable on its own. +Such a walk must filter to classes whose *own* `__dict__` carries `CODE`, because `NodeInvalidError` +inherits `CODE = "NODE_NOT_FOUND"` from `NodeNotFoundError`; it would otherwise see two classes +claiming the same code and let dict ordering pick the winner. The `ast` walk is immune, since it only +sees class bodies. + +**Executed against the real files.** The walk finds all 32 classes in today's `exceptions.py`, and +classifying every catalogue code through it produces exactly the split this plan claims: nine to +generate, three with no class (the 401/403 codes), and three colliding with an existing class. Because +the SDK has not yet declared any `CODE`, those three currently classify as **abort**, which is the +correct answer pre-adoption and becomes **adopt** the moment `base.py` declares them. The collision +check was run against every hypothetical code R3 enumerates and fires on all of them, and a synthetic +subclass inheriting `CODE` is correctly invisible to the walk. + +**An ordering constraint this surfaced.** The generator aborts until `base.py` declares the three +`CODE` attributes, so those declarations must land in the SDK *before* the generator is first run +against a catalogue containing `NODE_NOT_FOUND`, `BRANCH_NOT_FOUND`, or `SCHEMA_NOT_FOUND` — which is +to say, before the first run at all. That is consistent with R17's landing order but sharper than it: +not merely "the SDK lands first", but "the adoption declarations are a prerequisite for generation +succeeding". Worth stating in the task breakdown, since the natural instinct is to build the generator +first. + +**Alternative considered**: generating all 15 classes under distinct names and having the +hand-written unified classes subclass them. Rejected — the resolution map would then point at the +generated base, so the factory would raise the generated class and never the unified one that +consumers catch. + +## R5 — Which codes get their own class + +**Decision**: the code's declared HTTP status decides whether a code gets a class at all, not which +parent it takes. A code declaring 401 or 403 gets **no class**; every other code gets one, descending +from `GraphQLError` alone. No class in the design has more than one parent. + +Today that yields twelve codes with a class under `GraphQLError` — nine emitted by the generator and +three adopted from `base.py` per R4 — while `AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, and +`PERMISSION_DENIED` carry their identity in `exc.code` on whichever generic class their transport +already produces. + +```text +Error +└── ApiError + ├── GraphQLError # + code="PERMISSION_DENIED" on an HTTP 200 + │ ├── NodeNotFoundError, BranchNotFoundError, SchemaNotFoundError (adopted) + │ └── + └── AuthenticationError # + code="TOKEN_EXPIRED" on a real 401 +``` + +**The problem this solves.** The three authentication codes are the only ones that reach the SDK on two +different transports, and the transport determines which existing `except` clause has to keep working. +`backend/infrahub/graphql/app.py:298-300` returns `status_code=200` for every executed query, and +`graphql/error_formatter.py` maps resolver-raised failures onto catalogue codes inside that response's +`errors` array — including all three authentication codes. Only failures escaping *before* execution +get a real 401/403, which `api/exception_handlers.py:52-55` states outright. So: + +| Arrival | Raised today | Must keep being caught by | +|---------|--------------|---------------------------| +| Real 401/403 on `/graphql` | `AuthenticationError` | `except AuthenticationError` | +| Inside a 200 `errors` array | `GraphQLError` | `except GraphQLError` | + +One class per code can satisfy both only by inheriting from both branches. Not generating a class lets +the transport rule already in FR-012 pick the right generic class, and `exc.code` carries the +distinction. + +**Why not the diamond.** An earlier version of this decision generated +`class PermissionDeniedError(GraphQLError, AuthenticationError)`. It worked — verified: sibling bases, +the expected linearisation, both `isinstance` checks passing — but it cost more than it bought: + +- Multiple inheritance in a public exception hierarchy, which the first maintainer to read it misread + as `GraphQLError` descending from `AuthenticationError`. +- Two constructor footguns, both found in review rather than by design. Method resolution handed those + classes `GraphQLError.__init__`, whose first positional parameter is `errors`, so constructing one + positionally with a message corrupted the error list. And `GraphQLError.__init__`'s cooperative + `super().__init__` reached `AuthenticationError.__init__` rather than `Error.__init__`, where its + default message escaped substitution only because `GraphQLError` always computes a non-empty message + first. +- Two shapes in the generator instead of one. + +All of that to distinguish three codes whose payloads are empty (`AuthenticationRequiredData`) or +entirely nullable and usually unset (`TokenExpiredData.expired_at`, `PermissionDeniedData.action` and +`.resource_kind`). The structural cost is permanent and paid by every consumer; the benefit is a typed +attribute nobody can populate yet. + +**What this costs.** The three authentication codes have no typed payload attributes. If the catalogue +later gives one of them substantive fields, they are reachable only through `exc.extensions["data"]` +until the decision is revisited — at which point the trade has a concrete benefit to weigh rather than +a speculative one. US3's acceptance scenarios were amended to match, since they had specified distinct +types before the HTTP 200 behaviour was verified. + +**Rejected alternatives**: + +- *Classes under `AuthenticationError` only.* Single inheritance, all 15 codes typed, but + `except GraphQLError` stops catching a 200-response permission failure — an FR-018 violation for + exactly the codes FR-008 is about. +- *Classes under `GraphQLError` only.* Breaks `except AuthenticationError` around a GraphQL call, which + is where consumers put their re-login handling. Worse than the above. +- *Registering the generated classes as virtual subclasses of `AuthenticationError` via `ABCMeta`.* + Does not work: CPython matches `except` clauses with real subtype checks and ignores + `__subclasshook__`. +- *All 15 codes as direct children of `ApiError`, retiring the branch split.* The cleanest tree, and it + serves `except ApiError` well, but it breaks every existing `except GraphQLError` for the common data + failures. Too large a compatibility break for an SDK. + +**Note on `http_status`**: a generated class's `http_status` class attribute is the catalogue's declared +value, which is what US1 acceptance scenario 3 asserts. The wire value can differ — +`api/exception_handlers.py:26-27` replaces a declared 500 with the real HTTP status when it has a more +accurate one — and stays available as `exc.extensions["http_status"]`. + +## R6 — How a consumer reads the payload + +**Decision**: the payload's fields are promoted to directly typed attributes on the exception class. +The payload model validates the envelope and populates them; it is not the access path. + +```python +# generated +class UniquenessViolationError(GraphQLError): + CODE = "UNIQUENESS_VIOLATION" + code = "UNIQUENESS_VIOLATION" + http_status = 422 + DATA_MODEL = UniquenessViolationData + + def __init__(self, node_kind: str, fields: list[str], **envelope: Any) -> None: + self.node_kind = node_kind + self.fields = fields + super().__init__(**envelope) +``` + +```python +# consumer +except UniquenessViolationError as exc: + print(exc.node_kind, exc.fields) # str, list[str] +``` + +Attribute types mirror the catalogue exactly: a required field is not optional, and a nullable field +carries its declared default. There is no payload attribute on `ApiError` and none on the generated +classes either. + +**Rationale**: this is what US1 actually asks for — "carrying the node kind and the colliding field +names as typed attributes". A payload *object* on the exception was never a requirement; it was a +design choice, and a costly one. Exposing `data` on the base forces the base to name a type for it, +and narrowing that type on each subclass is an unsound override of a mutable attribute, so it can only +be bought with `Any` on the base, a read-only property pair, or a generic hierarchy. Promotion makes +the whole question disappear: there is nothing on the base to narrow. + +It is also the access pattern the design already used for the three adopted classes, whose payload +fields land on the existing `node_type` and `identifier` attributes (R9). Promoting everywhere removes +an inconsistency where three codes were read one way and twelve another. + +Two further simplifications fall out. `base.py` no longer needs to name a generated model type, so the +package needs no separate generated models module and no type-only import to stay independent of +generated code (R1). And the `Optional` payload attribute — with its `if exc.data:` guard at every call +site — is gone. + +**Construction**: the factory never assembles attributes itself. Each class exposes a +`from_payload(cls, payload, **envelope)` classmethod — generated for generated classes, hand-written +for the three adopted ones, where it maps `node_kind` onto the existing `node_type` and so on. The +factory validates with `DATA_MODEL` and calls `cls.from_payload(...)`, so promotion stays with the +class that knows its own attribute names and the factory carries no per-code branching. + +**Payload model shape**: generated payload models are pydantic v2 `BaseModel`s with +`model_config = ConfigDict(extra="ignore")`, honouring the catalogue's `required` list so a required +field is a required model field. JSON Schema is mapped as: `string` → `str`, `integer` → `int`, +`number` → `float`, `boolean` → `bool`, `string` with `format: date-time` → `datetime`, +`array` → `list[T]`, `anyOf: [T, {"type": "null"}]` → `T | None` with the declared default. Any +construct outside that vocabulary fails generation with the offending fragment in the message, as the +frontend generator does. A code with an empty `properties` object still gets a real model class with +no fields, not a special case. + +**Decision on validation failure**: if the payload does not validate — a server violating its own +emission contract, or a `date-time` the SDK cannot parse — the operation falls back to the generic class +for the branch, with `code` readable, the raw `extensions` retained, and a debug-level log recording the +failure. A `ValidationError` never escapes a raise path. + +**Rationale**: FR-004 requires tolerating unknown *fields*, which `extra="ignore"` delivers, and User +Story 2 requires that nothing raises during parsing. Promotion then forces this: a required field is a +non-optional attribute, and there is nothing to populate it with when validation fails. The alternative +is making every promoted attribute optional on every class, which taxes every consumer of the feature's +central use for a narrow server-side glitch. + +An earlier form of this decision kept the specific class and left the payload `None`, on the grounds +that FR-013 requires the raised type to be "a pure function of the response" and so it must not depend +on payload validity. That reasoning was overreaching. A malformed payload *is* part of the response; +FR-013's concern is that the type must not depend on **binding freshness** — on which SDK version the +consumer happens to hold — and falling back on an invalid payload does not touch that property. + +The case is also narrower than it first appears. `graphql/error_formatter.py:59-60` initialises the +payload to `UndefinedErrorData()` and only overwrites it when an `isinstance` guard matches, so the +server can emit `data: {}` under a code whose schema declares required fields. But for the adopted +codes — the ones US1's acceptance scenarios exercise — the guard tests the very exception type the code +was resolved from, so it cannot realistically fail. + +## R7 — Raise-time resolution + +**Decision**: two hand-written factories in `infrahub_sdk/exceptions/factory.py`: + +- `graphql_error_from_response(errors, query, variables)` for the `errors`-array path. +- `authentication_error_from_response(response)` for the 401/403 path, which subsumes the four-line + shape repeated at eleven call sites and preserves today's ` | `-joined message. + +Resolution reads `extensions.code` from the **first** error in the response and looks it up in +`CODE_TO_EXCEPTION`. A hit validates `extensions.data` with the class's `DATA_MODEL` and raises via +`cls.from_payload(...)`. A miss — unrecognised code, absent `extensions`, a non-string `code`, or a +payload that fails validation — raises the generic class for the transport the SDK observed. + +**Which class is raised and what `code` reports are separate questions.** `exc.code` is set from the +wire whenever the wire carried a *string* code, whether or not a class matched it: an unrecognised code +from a newer server is readable there (US2 acceptance scenario 1), and so is a recognised code whose +payload failed to validate. `exc.code` is `None` only when there was no string code to read — an absent +`extensions`, or the REST envelope's integer `code` (FR-003). Conflating the two would break both places +that key on `exc.code is not None`: the CLI branch in R11 and the server-reported test in R9. + +The complete `errors` list is retained on the exception in every case, unreordered (FR-013) — which is +why `errors` belongs on `ApiError` rather than only on `GraphQLError`, since the authentication branch +must retain it too and FR-015 freezes `AuthenticationError`'s constructor. + +**The fallback follows the transport, never the code's declared status.** For an unrecognised code the +SDK has no binding and therefore cannot know the declared status at all; for a recognised one the +declared status describes the failure, not the transport. Routing by declared status would send a +401/403 code arriving inside an HTTP 200 body to the authentication branch, where an existing +`except GraphQLError` would stop catching it. + +Under R5 this rule carries more weight than it first appears: the three authentication codes have no +class of their own, so *every* one of them takes this path. The transport rule is not an edge-case +fallback for them, it is the mechanism by which they reach the right class at all. + +**Construct with keyword arguments, always.** A generated class's `__init__` takes its promoted fields +first and forwards the envelope to `super().__init__`, which resolves to `GraphQLError.__init__` — +whose *first positional parameter* is `errors`. Anything constructing one positionally with a message +assigns a string into a field expecting a list of error dicts, reproducing the corruption +`analyzer.py` already has. The factories construct with keywords only, and a test asserts `exc.errors` +is a sequence of dicts on both paths. + +**The auth factory must tolerate a non-JSON body.** Two of the sites it replaces call +`exc.response.json()` directly, so a 401 carrying an HTML error page from a proxy currently raises a +JSON decode error in place of the authentication error. The factory reads `response.json()` and falls +back to the plain status when the body is not JSON, which is the same tolerance R10 requires of the +relogin helper for the same reason. Not `utils.decode_json`: what it adds over `.json()` is raising +`JsonDecodeError` carrying the response URL and body, and a factory built to absorb that case catches +and discards both — the status and the server's reason are what the caller needs here — and since +`infrahub_sdk.utils` imports the exceptions package, reaching for it would force a deferred import +inside the function body, the exact shape R1's layering test exists to catch. The exceptions package +therefore depends on nothing else in the SDK, which is a property the test now asserts directly. + +**The factory must be total.** It sits on the failure path of every client method, so an unexpected +exception inside it would replace a legitimate server error with an SDK `TypeError` and lose the +original failure entirely — the worst possible blast radius for a library. Resolution is therefore +wrapped so that *any* unexpected error degrades to constructing today's generic exception. Tested by +feeding the factory deliberately malformed envelopes: `errors` as a string (which `analyzer.py` +produces today), `extensions` as a list, `code` as a nested object. + +**Fallback logging**: every fallback — unresolved code, absent envelope, invalid payload — logs at +debug with the code involved. Cross-version fallbacks are precisely the signal a maintainer wants from +the field when an SDK meets a newer server, and silence makes SC-004's guarantees observable only in +tests. + +**Rationale**: the first error governs unconditionally, so the raised type is a pure function of the +response rather than of which generated bindings the SDK happens to hold — the reasoning FR-013 +records. Reading `extensions.code` only when it is a `str` is what keeps the REST envelope's integer +`code` from ever being surfaced as a catalogue code (FR-003). + +**Defensive detail**: two call sites already construct `GraphQLError` with something that is not a +list of dicts. `infrahub_sdk/analyzer.py:42` passes the bare string `"Schema is not provided"`, and +`infrahub_sdk/testing/schemas/animal.py:154` passes `[resp.errors]`, a list whose single element is not +a dict. The factory is on neither path, but the CLI renderer and anything iterating `errors` is. +Correct both call sites, and keep `print_graphql_errors`' non-list guard — which needs a `return` it +does not currently have, since today it prints the non-list value and then falls through and iterates +it anyway. + +## R8 — Message construction + +**Decision**: `GraphQLError.__init__` gains an optional `message` parameter. The factory passes a +message naming the code and the server's message for a catalogued failure and passes nothing for an +uncatalogued one, so today's `f"An error occurred while executing the GraphQL Query {query}, +{errors}"` string is reproduced byte-for-byte in the uncatalogued case (FR-023, SC-007). `query` and +`variables` remain attributes in both cases (FR-024). + +**Consequence**: `tests/unit/sdk/test_graph_traversal.py:383` asserts on `GraphQLError`'s message +text (`match="Source node not found"`). That path stays uncatalogued today, so the assertion holds; +the test is re-checked deliberately rather than assumed, per the specification's edge case. + +## R9 — Unifying `NodeNotFoundError`, `BranchNotFoundError`, `SchemaNotFoundError` + +**Decision**: these three promote their payload fields onto the attributes they already have, via a +hand-written `from_payload`, so a consumer reads the same attribute regardless of which path raised the +error: + +| Class | Existing attribute | Promoted from | +|-------|--------------------|---------------| +| `NodeNotFoundError` | `node_type`, `identifier` | `node_kind`, `identifier` | +| `BranchNotFoundError` | `identifier` | `branch_name` | +| `SchemaNotFoundError` | `identifier` | `kind` | + +This is the same promotion R6 applies to every other code; the only difference is that the target +attribute names already exist and are not the catalogue's, so the mapping is hand-written rather than +generated. + +`identifier`'s annotation widens to `Mapping[str, list[str]] | str`, which is what FR-016 calls +"documented as such": the plain string is not new behaviour, it is what +`infrahub_sdk/file_handler.py:168` already passes and what the current annotation wrongly excludes. The +three classes are re-rooted under `GraphQLError` (via `ApiError`), and `NodeInvalidError` inherits that +re-rooting — asserted by a test, not assumed. + +Because there is no payload attribute, "did this come from the server?" is answered by `exc.code is not +None`, which is true of every server-reported error rather than only of ones carrying a payload. + +**Rationale**: this satisfies "one documented way to obtain the identifying detail that works +regardless of which path raised the error" without inventing a new accessor that existing consumers +do not know about. Nothing in this repository reads these attributes except the classes' own `__str__` +rendering, so the blast radius is entirely external, which is why the widening goes in the release +notes. + +**Alternative considered**: a new normalised property (`identifier_display` or similar) alongside an +unchanged `identifier`. Rejected — it adds surface for a problem the widening already solves, and +leaves the file handler's existing string still outside the declared type. + +### Promoted attributes on an adopted class are optional, by policy + +Neither provenance can populate the full attribute set, and they fail to in *both* directions: + +`NodeNotFoundError` is constructed at nine sites — eight `raise` statements plus one built for deferred +raising at `store.py:184`: + +| Attribute | The nine construction sites | Catalogue payload | +|-----------|-----------------------------|-------------------| +| `identifier` | a mapping at eight (five in `store.py`, two in `client.py`, one in `ctl/object/utils.py`); a plain string at `file_handler.py:168` | a single string, declared required | +| `node_type` | supplied by five; the four `store.py` `raise` sites omit it and fall back to `"unknown"` | `node_kind`, declared required | +| `branch_name` | supplied by three | **not in the payload at all** | + +So a promoted attribute on an adopted class MUST be optional and documented as "populated when the +server reported it", even where the catalogue declares the underlying field required. A field can only +be non-optional on a class that is always constructed from that field, and these three are not. + +Whether the SDK can fill a *new* catalogue field client-side depends entirely on the field: were +`branch_name` added to `NodeNotFoundData`, three raise sites could supply it and the four store lookups +could not, since a store miss has no branch in scope. Something genuinely server-side — a database +identifier, a permission context — could not be supplied anywhere. That variability is why the policy +has to be optional-by-default rather than decided per field. + +**Tripwire for revisiting**: an adopted code gaining a field that is both *required* and semantically +server-only. At that point the unification has a concrete cost rather than a speculative one, and the +split below becomes the answer. + +### Direction of travel: separate the SDK's own errors from the server's + +Unification is a deliberate waypoint, not the end state. The maintainer's intent is to eventually give +the SDK's own failures their own classes, distinct from the ones representing what the server reported, +which would retire both problems above: `identifier` would stop carrying two meanings, and +`branch_name` would be coherent on the class that actually has a branch. + +That is not done here because it cannot be done without a breaking change. The derived name for +`NODE_NOT_FOUND` *is* `NodeNotFoundError`, so a split requires renaming the existing class (which +FR-005 forbids), giving the generated class a non-derived name (which FR-006 forbids), or moving the +client-side raises to a new class (which breaks every existing `except NodeNotFoundError` around a store +lookup). All three cost more today than an optional attribute. + +The path that does work is the constitution's deprecation path, in this order: ship the new +client-side classes as subclasses of the existing ones, so every current `except` clause keeps +catching them; move the SDK's own raise sites to the new classes; emit a `DeprecationWarning` from the +old names naming the replacements; keep them working for at least one minor release; and drop them in a +major. That sequence is a separate change with its own release note, and it is the reason this one +accepts the dual meaning rather than designing around it. + +## R10 — The typed silent-refresh decision + +**Decision**: `handle_relogin` and `handle_relogin_sync` decide via a shared helper that reads +`errors[0].extensions.code == "TOKEN_EXPIRED"`, falling back to the existing +`"Expired Signature" in messages` check when no code is present (FR-019). The helper tolerates a +non-JSON or empty 401 body rather than letting `response.json()` raise, since the wrapper sees REST +responses too and only GraphQL carries the catalogue envelope. + +Both the code check and the legacy fallback live *inside* that helper, so the literal +`"Expired Signature"` appears exactly once in the SDK afterwards, where it appears twice today — once +in each wrapper. That is what makes the grep in the quickstart's scenario 5 a meaningful check rather +than a count of duplicated logic. + +**Rationale**: the wrapper inspects the raw response before any exception exists, so it cannot reuse +the factory; a small shared reader keeps the async and sync copies from drifting. Keeping the legacy +check as a fallback is what makes a pre-catalogue server still refresh. + +**Out of scope, deliberately**: the GraphQL schema-validation probing used for server feature +detection matches on *uncatalogued* conditions and stays exactly as it is (FR-021). + +## R11 — The re-rooted classes' missing attributes, and the CLI ladder + +**The defect this fixes.** `NodeNotFoundError.__init__` does not call `GraphQLError.__init__`, and +neither do `BranchNotFoundError`'s or `SchemaNotFoundError`'s. Re-rooting them under `GraphQLError` +therefore produces instances on which `errors`, `query`, and `variables` do not exist *at all*. A +consumer writing `except GraphQLError as exc: … exc.errors` gets an `AttributeError` on any +client-side lookup miss, and `print_graphql_errors(errors=exc.errors)` raises while reading its +argument. Note that degrading the renderer on an *empty* `errors` does not address this: the attribute +is absent, not empty, so the renderer raises before it can check. + +**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 + today's ladder unchanged. +- The three adopted classes call `super().__init__(errors=[], query=None, variables=None, message=...)` + explicitly, so their envelope attributes are set by the constructor that owns them. This is the fix + rather than relying on class-level defaults: a default standing in for constructor state also makes + `exc.errors` a *tuple* on a client-side raise and a *list* everywhere else, so the documented type + would be wrong for exactly the classes this feature unifies. +- `ApiError` still declares defaults for `errors`, `query`, and `variables` — an immutable empty tuple + for `errors` — but only as a can't-crash floor for a directly constructed `AuthenticationError`, whose + constructor FR-015 freezes. Anything built from a response goes through a constructor or factory that + sets a list. +- `print_graphql_errors` degrades to the exception's message when there is nothing to render. Its + `isinstance(errors, list)` guard also needs the `return` it currently lacks, since today it prints the + non-list value and then falls through and iterates it anyway. +- In `infrahub_sdk/ctl/utils.py::handle_exception`, move the + `(SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, GraphQLQueryError)` branch + *above* the `GraphQLError` branch, since re-rooting makes the later branch unreachable and would + silently change CLI output for exactly the errors this feature makes specific. + +**Tests**: read `exc.errors`, `exc.query`, and `exc.variables` off a purely client-side +`NodeNotFoundError`; drive `handle_exception` with each class to assert the ladder's behaviour rather +than reading the source; render an exception with no server errors behind it. + +**Why a keyed branch rather than a reordered class branch.** The requirement is that a user can see why +something failed. Neither existing branch delivers that for a catalogued failure: the +`AuthenticationError` branch would print "Authentication failure: …" for a `PERMISSION_DENIED`, which +mislabels it — the user is authenticated and simply not permitted — while the `GraphQLError` branch +prints the server error list without naming the condition. Rendering the code plus the server's message +names the actual failure in both cases. + +Keying the branch on `exc.code is not None` rather than on a class also removes the shadowing hazard for +catalogued errors permanently: it tests data rather than class identity, so no future re-rooting can +make it unreachable. And because it only claims errors carrying a code, every uncatalogued failure keeps +today's rendering byte-identical, which is the same guarantee FR-023 makes for messages. + +**Also checked**: `infrahub_sdk/ctl/cli_commands.py:237` and `infrahub_sdk/ctl/validate.py:88` each +catch `GraphQLError` with no competing branch, so re-rooting cannot shadow anything there. The class +ladder still needs its reordering, because a client-side `NodeNotFoundError` has no code and so reaches +it. + +## R12 — Testing strategy + +**Decision**: response-envelope fixtures under `tests/fixtures/error_catalogue/`, loaded via +`read_fixture()`, driven through `httpx_mock` at the transport boundary — no `unittest.mock`. +Parametrized cases use the dataclass-with-`name` pattern, and every `pytest.raises` carries `match=`. + +Coverage is split explicitly by layer, because SC-006 read literally ("across all catalogued codes") +would mean 15 codes on both clients through the client layer, which fights the constitution's +requirement that unit tests stay fast: + +| Layer | Scope | +|-------|-------| +| Factory | Exhaustive: every catalogue code, its raised class, and every promoted attribute's value. All cross-version cases — unknown code, unknown payload field, absent `extensions`, pre-catalogue integer `code`, invalid payload falling back to the generic class — plus the malformed-envelope totality cases. | +| Client | Representative parity set covering both branches, both transports, and the file-upload variant, parametrized over `["standard", "sync"]` via the `BothClients` fixture. | +| Hierarchy | Each authentication code reaching the class its transport dictates while `exc.code` identifies it; `NodeInvalidError` inheriting the re-rooting; attribute access on a client-side raise; the ladder's behaviour. Also that no class in the package has more than one parent, asserted directly, so the diamond cannot creep back. | +| Public surface | Every name importable from `infrahub_sdk.exceptions` before the change is still importable from it, pinned against a committed snapshot list. | +| Broadenings | Both accepted behaviour changes asserted directly: `except GraphQLError` catches a client-side `NodeNotFoundError`; a catalogued message differs from the generic one while an uncatalogued message stays byte-identical. | +| Integration | A small number of real catalogued failures driven against a live server via testcontainers, on both clients. | + +**Rationale**: the constitution requires both paths tested, concrete assertions, and deliberate +behaviour changes pinned by a test rather than worked around. The public-surface snapshot is what makes +"no name importable from `infrahub_sdk.exceptions` may disappear" a check rather than an intention — +necessary because the module is being restructured into a package. + +**Why unit tests alone are not sufficient here.** Every fixture in the layers above is written by the +same hand that writes the parser, so the whole suite can pass green against an envelope shape the +server never sends — and the shape is the entire contract this feature consumes. The constitution is +explicit that behaviour depending on real server responses belongs in integration tests, and the tier +already exists (`tests/integration/`, `infrahub-testcontainers`, with per-client files +`test_infrahub_client.py` and `test_infrahub_client_sync.py`). + +Scope it small and keep it there: drive two genuinely reachable failures — a uniqueness violation on +`.save()` and a missing node on `.delete()` — against a live server, and assert the raised class, the +code, and the promoted attributes. Two cases are enough to validate the envelope shape that every +unit fixture then reuses; the exhaustive per-code coverage stays at the fast, mocked layer where it +belongs. + +## R13 — Documentation + +**Decision**: a new hand-written `docs/docs/python-sdk/topics/error_handling.mdx` covering the +hierarchy, how to catch by branch versus by code, the cross-version guarantees, the two accepted +broadenings, and the note that `infrahub_sdk.exceptions` is the supported import path. It links to +Infrahub's published catalogue reference for the code list instead of restating it. The Python SDK +sidebar globs the `topics` directory, so no sidebar edit is needed. + +**Rationale**: restating 15 codes in this repository creates a second source of truth that rots the +first time a code is added upstream, and nothing here validates it — a direct Principle VII risk. +Infrahub already generates that list from the same artefact. What is genuinely SDK-specific is the +hierarchy and the guarantees, and that is what the page carries. + +One content note: a catalogued message now names the failing action and resource kind where the +catalogue provides them (`PermissionDeniedData` carries both), and that text reaches logs and CLI +output where a wall of query text used to be. Worth a line for anyone shipping SDK logs onward. + +## R14 — Release notes + +**Decision**: towncrier fragments in `changelog/`, one per user-visible change — the typed errors, the +`identifier` widening, and the `except GraphQLError` broadening. + +**Rationale**: FR-016 requires the widening to be called out in the change's release notes, and this +repository's release notes are files, not prose in a pull request: `[tool.towncrier]` in +`pyproject.toml` sets `directory = "changelog"` with `orphan_prefix = "+"` for entries without an issue +number. Naming the fragments as work makes FR-016 verifiable instead of aspirational. + +## R15 — Type-check the generated class shape before generating 15 of it + +**Done, and it passes.** A hand-written class in the generated shape — promoted attributes assigned in +`__init__`, a `from_payload` classmethod returning `Self`, `**envelope` forwarded to +`super().__init__`, plus the adopted variant with optional attributes — was checked against both +checkers at the versions this repository pins (`mypy` 1.11.2, `ty` 0.0.14): + +```text +mypy: Success: no issues found in 1 source file +ty: All checks passed! +``` + +Zero suppressions were needed. The types a consumer actually sees were asserted by typed assignment +rather than inspected, so a wrong one would have been an error rather than a note: + +| Expression | Type | +|------------|------| +| `exc.node_kind` on a generated class | `str` — not optional, not `Any` | +| `exc.fields` | `list[str]` | +| `exc.code` on `ApiError` | `str \| None` | +| `exc.errors` | `Sequence[dict[str, Any]]` | +| `UniquenessViolationError.from_payload(...)` | `UniquenessViolationError`, so `Self` resolves concretely | +| `exc.identifier` on the adopted class | `Mapping[str, list[str]] \| str` | + +The risk had already dropped twice over — promotion (R6) removed the variance problem that made it +real, and dropping the diamond (R5) removed the least ordinary construct in the shape — so this is a +confirmation rather than a decision input. It stays recorded because the constitution requires both +checkers clean, and because the numbers above are the contract the template has to reproduce. + +Note that no suppression is anticipated anywhere in this design. If the spike shows one is needed, that +is a signal the shape is wrong rather than a licence to add it. + +## R16 — Sequencing across the two repositories + +**Decision**: the Infrahub-side generator and its first hand-verified run come before the SDK-side +typed-raising work can be demonstrated, regardless of user-story priority labels. + +**Rationale**: US1 is P1 and US5 is P2, but the per-code classes US1 delivers are produced by the +generator US5 builds. Priority labels describe value, not order. Task generation must take the order +from the dependency, not from the labels, or the P1 story will be picked up first and immediately +block. FR-002 does soften this — the envelope parses onto the base classes with no bindings at all, so +`code`, `http_status`, and the relogin fix (US4) are independently landable — but the typed per-code +classes are not. + +## R17 — Landing order across the two repositories + +**Decision**: the SDK change lands first, then Infrahub bumps its submodule pointer to it. + +**Rationale**: this is the pattern the two repositories already follow. Infrahub's pointer-moving +commits — `chore(sdk): bump python_sdk to head of infrahub-develop` and feature commits that move the +pointer inline — target SDK commits already present on the SDK's `infrahub-develop` branch, so the SDK +side is merged before the pointer advances. + +It is also the only order that works here. Infrahub's `validate_generated` diffs the submodule's +*content*, so the generated module must already exist in the SDK at the pointer Infrahub carries. The +first generation is therefore hand-run locally to produce the artefact for the SDK pull request, which +is what US5 anticipates, and every regeneration afterwards follows the same order. + +Inferred from the repositories' history rather than from a written practice, so worth one confirmation +from whoever owns the release flow before the paired pull requests go up. + +## R18 — What this plan does not touch + +The git integrator's repository-import failure handling, anything about what the server emits, and +any release-time gate on either side. Pull-request-time validation is the only enforcement mechanism, +matching how the existing generated artefacts are treated (FR-027). diff --git a/dev/specs/ifc-3034-error-catalogue/spec.md b/dev/specs/ifc-3034-error-catalogue/spec.md new file mode 100644 index 000000000..f2c9791c2 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/spec.md @@ -0,0 +1,501 @@ +# Feature Specification: Error Catalogue in the Python SDK + +**Feature Branch**: `pog-error-catalogue-IFC-3034` + +**Created**: 2026-08-21 + +**Status**: Draft + +**Input**: IFC-3034 — Implement the error catalogue in the Python SDK. Related: IFC-2279 (spike), INFP-468 (backend catalogue), GitHub #7498. + +## Context + +Infrahub's GraphQL error catalogue gives every GraphQL error a stable string `extensions.code`, an +integer `extensions.http_status`, and a typed `extensions.data` payload, published as a +machine-readable schema at `schema/error-catalogue.json` in the Infrahub repository. The frontend +already consumes it through generated TypeScript bindings. + +The SDK consumes none of it. `execute_graphql` raises a generic `GraphQLError` whose message embeds +the entire query text, and consumers that need to branch on a failure still match on message +strings. This feature makes ordinary SDK operations raise the specific error for the failure. + +Two wire shapes matter, and they are not the same: + +- **`/graphql`** carries the catalogue envelope: string `code`, integer `http_status`, typed `data`. +- **`/api/...` (REST)** carries the legacy envelope, where `extensions.code` is an *integer* + mirroring the HTTP status. There is no catalogue code and no `data`. + +The catalogue is therefore GraphQL-only, and a REST `extensions.code` is a different thing with a +different type that must never be mistaken for a catalogue code. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Branch on a specific server failure (Priority: P1) + +A developer writing automation against Infrahub needs to react differently to different failures. A +`.save()` that collides on a uniqueness constraint should be distinguishable from a validation +failure, and the collision detail should be available as attributes rather than parsed out of prose. + +**Why this priority**: This is the feature. Everything else either protects it or maintains it. + +**Independent Test**: Drive each catalogued failure against a server (or a fixture of its response +envelope) and assert the raised type and the typed attributes carrying its detail, without reading any +message. + +**Acceptance Scenarios**: + +1. **Given** a node whose unique attribute already exists, **When** the developer calls `.save()`, + **Then** `UniquenessViolationError` is raised carrying the node kind and the colliding field names + as typed attributes. +2. **Given** a node that no longer exists, **When** the developer calls `.delete()`, **Then** + `NodeNotFoundError` is raised carrying the node kind and identifier. +3. **Given** any catalogued failure, **When** it is raised, **Then** `exc.code` equals the catalogue + code string and `exc.http_status` equals the catalogue status. +4. **Given** a developer who catches `GraphQLError` today, **When** a catalogued GraphQL failure + occurs, **Then** the specific subclass is caught by that existing clause. + +--- + +### User Story 2 - Keep working against any server version (Priority: P1) + +The SDK and the server are versioned and released independently, so any SDK version may talk to any +server version. Neither direction may break. + +**Why this priority**: The ticket states this is a hard requirement, not a nice-to-have. User Story 1 +is not shippable without it — typed errors that raise on an unrecognised payload would be a +regression, not a feature. + +**Independent Test**: Replay response fixtures representing a newer server, an older server, and a +pre-catalogue server against the parsing layer, asserting no parse failure and correct fallback in +each case. + +**Acceptance Scenarios**: + +1. **Given** a code the SDK has never heard of, emitted by a newer server, **When** the SDK raises, + **Then** it raises the generic fallback for that transport branch with `exc.code` readable as a + plain string, and does not raise on parse. +2. **Given** an existing code whose payload has gained a new attribute in a newer server, **When** + an older SDK parses it, **Then** the unknown attribute is ignored and behaviour is unchanged. +3. **Given** a server that predates the catalogue, or an error carrying no `extensions`, **When** the + SDK raises, **Then** behaviour matches today's and `exc.code` is `None`. +4. **Given** a pre-catalogue server emitting an *integer* `extensions.code` on `/graphql`, **When** + the SDK parses it, **Then** it is not surfaced as a catalogue code and `exc.code` is `None`. +5. **Given** any of the above, **When** the developer regenerates nothing, **Then** correctness is + unaffected — regeneration buys typed handling of newly catalogued errors, never correctness. + +--- + +### User Story 3 - Catch server-reported errors uniformly across transports (Priority: P2) + +Authentication failures reach the developer from both the REST and GraphQL paths. Today they collapse +into a single `AuthenticationError` that cannot distinguish "no credentials" from "token expired" +from "not permitted". The catalogue splits these into three codes, and the SDK needs a hierarchy +where that split is expressible without stranding the REST path. + +**Why this priority**: It restructures the hierarchy every other story hangs off, but User Story 1 +delivers value with the existing flat `AuthenticationError` still in place. + +**Independent Test**: Assert the class hierarchy directly, and assert that each existing `except` +clause in the SDK and CLI still catches what it caught before. + +**Acceptance Scenarios**: + +1. **Given** a GraphQL request with an expired token returning a real 401, **When** it fails, **Then** + `AuthenticationError` is raised as it is today and caught by an existing + `except AuthenticationError` clause, carrying `exc.code == "TOKEN_EXPIRED"`. +2. **Given** a GraphQL request the user is not permitted to make, **When** it fails, **Then** the + failure is distinguishable from a missing-credentials one by `exc.code`, which reads + `"PERMISSION_DENIED"` rather than `"AUTHENTICATION_REQUIRED"`. +3. **Given** a REST request that fails authentication, **When** it fails, **Then** + `AuthenticationError` is raised as it is today, with `exc.code` as `None`. +4. **Given** a developer who wants to catch anything the server rejected regardless of transport, + **When** they catch `ApiError`, **Then** both GraphQL and auth failures are caught. +5. **Given** any of the three authentication codes returned inside an HTTP 200 response, because the + failure was raised from within a resolver, **When** it fails, **Then** `GraphQLError` is raised as it + is today, carrying that code, so an existing `except GraphQLError` clause keeps catching it. + +**Note on the three authentication codes**: they are distinguished by `exc.code` rather than by +distinct exception types. See FR-008 — a single class per code cannot satisfy both scenario 1 and +scenario 5 without multiple inheritance, and the code carries the distinction at no structural cost. +Any of the three can arrive by either route, so `except ApiError` is the only clause that catches a +given code regardless of how the server happened to fail. + +--- + +### User Story 4 - Stop the SDK string-matching its own server (Priority: P2) + +The SDK's silent token-refresh path decides whether to re-login by matching the literal string +`"Expired Signature"` in the response body. The catalogue makes that a typed decision. + +**Why this priority**: A correctness improvement to existing behaviour, valuable independently, but +it depends on the envelope parsing from User Story 1. + +**Independent Test**: Drive the relogin path with a catalogue `TOKEN_EXPIRED` envelope, with the +legacy string on a pre-catalogue server, and with an unrelated 401, asserting a refresh is attempted +in the first two cases and not the third. + +**Acceptance Scenarios**: + +1. **Given** a 401 carrying `TOKEN_EXPIRED`, **When** the SDK receives it, **Then** it refreshes the + token and retries, without inspecting any message text. +2. **Given** a 401 from a pre-catalogue server carrying the legacy `"Expired Signature"` message, + **When** the SDK receives it, **Then** it still refreshes and retries. +3. **Given** a 401 that is neither, **When** the SDK receives it, **Then** no refresh is attempted. + +--- + +### User Story 5 - Bindings that cannot silently drift (Priority: P2) + +A catalogue change that is not reflected in the SDK's bindings must surface as a failure, in the +change that caused it, rather than as silence that is noticed months later when a code falls back. + +**Why this priority**: Without it the typed errors decay. It is P2 rather than P1 only because the +first generation can be landed and verified by hand once. + +**Independent Test**: Modify the catalogue without regenerating, and confirm the validation step +fails; regenerate, and confirm it passes. + +**Acceptance Scenarios**: + +1. **Given** a change to the catalogue in the Infrahub repository, **When** the bindings in the SDK + submodule are not regenerated, **Then** Infrahub's generated-artefact validation fails the pull + request that changed the catalogue. +2. **Given** a regenerated set of bindings, **When** validation runs, **Then** it passes and the + generated file is byte-identical to a fresh generation. +3. **Given** the generated bindings file, **When** a developer opens it, **Then** it is marked as + generated and not to be edited, consistent with the repository's other generated artefacts. + +--- + +### User Story 6 - Messages that are about the failure (Priority: P3) + +`GraphQLError`'s message embeds the whole query text, so a one-line failure produces a wall of +output in logs and CLI sessions. + +**Why this priority**: Observable quality-of-life improvement, no functional dependency either way. + +**Independent Test**: Trigger a catalogued failure and an uncatalogued one, and compare their +messages. + +**Acceptance Scenarios**: + +1. **Given** a catalogued failure, **When** its message is rendered, **Then** it names the code and + the server's message and does not contain the query text. +2. **Given** an uncatalogued failure, **When** its message is rendered, **Then** it is unchanged from + today's, query text included. +3. **Given** any GraphQL failure, **When** a developer needs the query, **Then** it is still + available on the exception. + +### Edge Cases + +These are specific hazards found while surveying the current code, not hypotheticals. + +- **Ordered `isinstance` ladder is shadowed.** The CLI's error handler tests + `isinstance(exc, GraphQLError)` *before* it tests + `isinstance(exc, (SchemaNotFoundError, NodeNotFoundError, ...))`. Re-rooting those classes under + `GraphQLError` makes the later branch unreachable, silently changing CLI output for exactly the + errors this feature makes specific. The ladder must be reordered, and the same shadowing hazard + checked wherever else the SDK or CLI tests these classes in sequence. +- **A GraphQL error renderer with no server errors to render.** The CLI's `GraphQLError` branch + renders `exc.errors`, which is a list of server error dicts. A unified `NodeNotFoundError` raised + purely client-side has no server response behind it, so that list is empty. Rendering must degrade + to the message rather than printing nothing. +- **`identifier` already means two different things.** The client-side `NodeNotFoundError` declares + `identifier` as a mapping of filters, and the store and client lookup paths pass one. The file + handler, however, already passes a plain string, which the declared type does not admit — so the + attribute is heterogeneous today, before any unification. The catalogue payload adds a third + reading: a single server-side identifier string. Unification does not create this problem, it + forces a decision on it. FR-016 pins the resulting contract; the mechanism is left to the plan. + Nothing in this repository reads the attribute except the exception's own string rendering, so the + compatibility risk is entirely external. +- **A subclass inherits the re-rooting.** `NodeInvalidError` subclasses `NodeNotFoundError`, so it + silently becomes a `GraphQLError` too. Intended, but it must be asserted rather than assumed. +- **Pre-existing constructor misuse, in two places.** One call site constructs `GraphQLError` with a + plain string where the constructor expects a list of error dicts, so `errors` holds a string; another + passes a list whose single element is not a dict. Any code that now iterates `errors` to resolve a + code will meet both. Re-rooting makes this worse before it makes it better: once a class inherits a + constructor whose *first positional parameter* is `errors`, passing a message positionally silently + produces the same corruption. +- **A message-matching test inside our own suite.** At least one existing test asserts on + `GraphQLError`'s message text. Message changes must be reflected in the suite deliberately, not + worked around. +- **More than one error in one response.** A GraphQL response may carry several errors with different + codes. The rule for which code determines the raised class must be explicit, and no error may be + discarded from the exception. +- **`UNDEFINED_ERROR` is a code, not the absence of one.** A server that explicitly says + `UNDEFINED_ERROR` is reporting a catalogue gap on its side. That is distinct from an error carrying + no `extensions` at all, and the two must not collapse. +- **Codes with no payload.** Several codes declare an empty payload object. These must still produce + a usable class rather than a special case. +- **Silent-refresh runs on both transports.** The relogin wrapper inspects raw responses from REST + *and* GraphQL calls, but only GraphQL carries the catalogue envelope. It must read the code where + one exists and fall back to the legacy check where one does not. +- **GraphQL data errors arrive as HTTP 200, and so do some auth failures.** Catalogued data errors + come back with status 200 and an `errors` array. Only auth failures that escape *before* query + execution come back as real 401/403 responses on a separate code path; a permission or + authentication failure raised inside a resolver is formatted at the GraphQL layer and returned in + the 200 response's `errors` array like any other error. So the transport a code arrives on cannot be + inferred from the code, and a code's declared `http_status` is metadata about the failure rather than + the status the SDK saw. The declared status can also differ from the status on the wire: the server + replaces a declared 500 with the real HTTP status when it has a more accurate one. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Envelope parsing + +- **FR-001**: The SDK MUST expose a base class representing "the server reported an error", carrying + the catalogue code, the HTTP status, the raw error envelope, and the server's error list, from which + both the GraphQL and the authentication branches descend. The base MUST NOT declare an attribute for + the typed payload: a payload's fields belong to the specific class that has a type for them, and a + base-level payload attribute could only be typed loosely enough to be useless. +- **FR-002**: The SDK MUST parse the error envelope onto the shared base class of FR-001 — not onto the + generated per-code classes — so the code is readable against any server version without regenerated + 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 + strict emission contract. Where a payload does not validate at all, the operation MUST fall back to + the generic class for the branch, with the code still readable, and MUST NOT raise from parsing. + This case is reachable rather than defensive — the server has a fallback path that emits an empty + payload under a code whose schema declares required fields. + + Rationale: the specific class exposes the payload's fields as attributes typed exactly as the + catalogue declares them, so a required field is not optional. There is nothing to populate those + attributes with when validation fails, and the alternative — making every payload attribute + optional on every class — would tax every consumer of the feature's central use for a narrow + server-side glitch. Note that this does not weaken FR-013: payload validity is a property of the + response, so the raised class remains a function of the response alone and never of which + generated bindings the SDK holds. + +#### Generated bindings + +- **FR-005**: Every catalogue code MUST have one typed payload model, and every code except the + 401/403 ones (see FR-008) MUST also have one exception class rooted at the SDK's base `Error` class. + Both MUST be importable from `infrahub_sdk.exceptions`, but the payload model is the parsing + mechanism rather than the access path: where a code has a class, each payload field MUST be reachable + as a directly typed attribute on the exception itself, typed as the catalogue declares it. Every name + importable from `infrahub_sdk.exceptions` before this change MUST remain importable from it + afterwards, and that MUST be pinned by a test rather than asserted, since the module is being + restructured. +- **FR-006**: Exception class names MUST derive from the code deterministically, without producing a + doubled `Error` suffix for codes that already end in `_ERROR`. A derived name that collides with an + exception the SDK already defines MUST either be an intentional adoption, declared by the SDK, or + fail generation. It MUST NOT silently produce two classes with one name. + + Rationale: the SDK already defines `ValidationError`, `RateLimitError`, `InvalidResponseError`, + `FileNotValidError`, and `ResourceNotDefinedError`, every one of which is the name a plausible future + code would derive. A collision that is not caught would shadow the hand-written class of that name, + changing what an existing `except` clause catches — in this repository, from a change made in + another one. +- **FR-007**: Payload model names MUST come from the catalogue's declared payload title, so SDK and + frontend bindings agree on naming. +- **FR-008**: The code's declared HTTP status MUST determine *whether a code gets its own exception + class*, with no hand-maintained per-code mapping. A code declaring 401 or 403 MUST NOT get a class; + every other code MUST get one, descending from the GraphQL branch. Where a code has no class, the + generic class for the observed transport is raised and carries the code (FR-012), so the failure is + still identifiable without reading a message. + + Rationale: the three authentication codes can arrive on two different transports, and the transport + determines which existing `except` clause has to keep working. A real 401 must stay catchable by + `except AuthenticationError`; a permission failure raised inside a resolver comes back as an HTTP 200 + GraphQL response and must stay catchable by `except GraphQLError`. One class per code cannot satisfy + both without inheriting from both branches, and a diamond in a public exception hierarchy is a + permanent structural cost paid by every consumer to distinguish three codes that `exc.code` already + distinguishes. The asymmetry is deliberate: 12 codes carry typed payload attributes, and 3 carry + their identity in `exc.code`, whose payloads today are empty or entirely nullable. + + Consequence to accept: if the catalogue later gives an authentication code substantive payload + fields, they will be reachable only through `exc.extensions["data"]` until this decision is + revisited. +- **FR-009**: The generated artefact MUST carry the same "generated, do not edit" marking as the + repository's other generated files, and MUST record the catalogue version it was generated from. +- **FR-010**: The SDK MUST NOT contain a copy of the catalogue schema. The generated bindings are the + only artefact that crosses the repository boundary. + +#### Raising the specific error + +- **FR-011**: Every operation that today raises the generic GraphQL error MUST raise the specific + class when the response carries a recognised code. +- **FR-012**: Where no class matches the code — unrecognised, absent, or an integer from a + pre-catalogue server — or where the matched class's payload does not validate, the operation MUST + raise the generic class for **the transport it observed**: the GraphQL error for anything read from an + `errors` array, and the authentication error only for a response the SDK saw as HTTP 401 or 403. The + fallback MUST NOT be selected from the code's declared HTTP status. + + Rationale: for an unrecognised code the SDK holds no binding and so cannot know the declared status + at all, and for a recognised one the declared status describes the failure rather than the transport. + Routing by declared status would therefore send a recognised 401/403 code whose payload fails to + validate, arriving inside an HTTP 200 body, to the authentication branch — where an existing + `except GraphQLError` would stop catching it, the coverage loss FR-018 forbids. The transport rule is + the only thing that preserves that coverage — and under FR-008 it carries more weight still, since the + three authentication codes have no class of their own and therefore *always* take this path. + + Where a string code was on the wire it MUST remain readable as `exc.code` even though the generic + class was raised; `exc.code` is `None` only when no string code was present. +- **FR-013**: Where a response carries several errors, the **first** error in the response determines + the class raised. The exception MUST retain the complete list, and MUST NOT discard or reorder it. + The first error governs even when it carries no code and a later one does, in which case the generic + class for the branch is raised. + + Rationale: this makes the raised type a pure function of the response, independent of which version + of the generated bindings the SDK holds. Selecting the first *recognised* code instead would make + the type depend on binding freshness, so regenerating bindings could change which exception a + consumer receives for a byte-identical response — the opposite of the guarantee in FR-010 and User + Story 2. +- **FR-014**: Async and sync clients MUST behave identically, per the constitution's parity + principle, and both paths MUST be tested. + +#### Reconciling names that already exist + +- **FR-015**: `AuthenticationError` MUST keep its name and constructor, and MUST remain the class + raised for every failure the SDK observes as HTTP 401 or 403 — on the REST path, where `exc.code` + stays `None`, and on the GraphQL path, where it carries the catalogue code. It gains no subclasses; + per FR-008 the three authentication codes get no class of their own. +- **FR-016**: `NodeNotFoundError` MUST be unified into a single class covering both the client-side + and the server-reported cases, re-rooted so that an existing `except GraphQLError` clause catches + it. The consequent broadening — that clause now also catches purely client-side lookup misses — is + accepted. + + The unified class MUST satisfy all of the following observable contract. The mechanism that achieves + it is left to the plan; the contract is not. + + - Every construction shape in use today MUST keep working unchanged. That includes the mapping of + filters passed on the store and client lookup paths **and** the plain string the file handler + already passes, which the current type annotation does not actually admit. + - The server-reported node kind and identifier MUST be reachable as typed attributes when the error + came from the server. + - There MUST be one documented way to obtain the identifying detail that works regardless of which + path raised the error, so a consumer never has to test which case it is holding. + - Any attribute whose type widens as a result MUST be documented as such, and the widening MUST be + called out in the change's release notes, since external consumers read these attributes even + though nothing in this repository does. + - Every attribute carrying a payload field on a unified class MUST be optional, even where the + catalogue declares that field required, and MUST be documented as populated only when the server + reported the error. Neither provenance can populate the full attribute set: the catalogue supplies + no `branch_name`, and four of the nine construction sites supply no node kind. A field can + only be non-optional on a class that is always constructed from it. +- **FR-017**: `BranchNotFoundError` and `SchemaNotFoundError` MUST be reconciled the same way as + `NodeNotFoundError`. +- **FR-018**: Every existing `except` clause and `isinstance` check in the SDK and CLI MUST still + catch what it caught before the change, with ordered ladders corrected where re-rooting shadows a + later branch. + +#### Removing string matching + +- **FR-019**: The silent token-refresh decision MUST be made from the catalogue code where one is + present, retaining the existing message check only as the fallback for servers that predate the + catalogue. +- **FR-020**: The SDK's remaining message-string checks for catalogued failures MUST be replaced with + typed handling. +- **FR-021**: Checks that detect *uncatalogued* conditions — notably GraphQL schema-validation probing + used for server feature detection — are explicitly out of scope and MUST be left in place. + +#### 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 + `NodeNotFoundError` — its message MUST remain exactly as it is today. +- **FR-023**: An uncatalogued error's message MUST remain exactly as it is today, query text included. +- **FR-024**: The query and variables MUST remain available as attributes on the exception in both + cases. + +#### Generation and validation (Infrahub repository) + +- **FR-025**: Infrahub MUST generate the SDK's error bindings into the SDK submodule as part of its + existing generation task, alongside the schema models and protocols it already generates there. +- **FR-026**: Infrahub's existing generated-artefact validation MUST be extended to fail when the + submodule's bindings do not match a fresh generation, so a catalogue change that skips regeneration + fails the pull request that made it. +- **FR-027**: No release-time gate is added on either side. Pull-request-time validation is the + mechanism, matching the treatment the existing generated artefacts receive. + +#### Documentation + +- **FR-028**: SDK documentation MUST describe the exception hierarchy, how to catch by branch and by + code, and the cross-version behaviour a consumer can rely on, updated in the same change as the + behaviour. It MUST reference the server's published catalogue for the code list rather than + restating it, so a code added upstream cannot leave the SDK's documentation quietly wrong. It MUST + also note that a catalogued message now names the failing action and resource kind where the + catalogue provides them, since that text reaches logs and CLI output. + +### Key Entities + +- **Catalogue code**: A stable string naming one failure mode, with a declared description, stability + level, HTTP status, and payload schema. Owned by Infrahub; the SDK is a consumer. +- **Error envelope**: What the server puts on the wire for one error. Two shapes exist — the catalogue + envelope on GraphQL, and the legacy integer-code envelope on REST. +- **Payload model**: The typed `data` for one code, tolerant of fields it does not recognise. Used to + validate the envelope and populate the exception's attributes; not the way a consumer reads them. +- **Exception hierarchy**: A tree rooted at the SDK's `Error`; below it a base for server-reported + errors, splitting into the authentication branch and the GraphQL branch, with one generated class per + non-401/403 code under the GraphQL branch. No class has more than one parent. +- **Generated bindings module**: The single artefact crossing from Infrahub into the SDK, holding the + payload models, the per-code classes with their promoted attributes, and the code-to-class + resolution used at raise time. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Every code in the catalogue is identifiable without reading a message — the non-401/403 + codes as their own exception type with the catalogue's payload fields readable as typed attributes, + and the 401/403 codes through `exc.code` on the class their transport already produced. +- **SC-002**: No message-string matching remains in the SDK for any failure the catalogue covers. +- **SC-003**: The existing test suite passes with no `except` clause losing coverage it had before; + every deliberate behaviour change is pinned by a test that asserts the new behaviour. +- **SC-004**: Every cross-version case — unknown code, unknown payload field, absent envelope, + pre-catalogue integer code — is covered by a test and none of them raises during parsing. +- **SC-005**: A catalogue change that omits regeneration fails validation in the pull request that + introduced it, and a regenerated artefact is byte-identical to a fresh generation. +- **SC-006**: Async and sync clients raise the same type with the same attributes for the same + failure, across all catalogued codes. +- **SC-007**: A catalogued failure's message contains no query text, while an uncatalogued failure's + message is byte-identical to today's. +- **SC-008**: A developer can catch every server-reported error, on either transport, with one + `except` clause. + +## Assumptions + +- **The catalogue is GraphQL-only.** Confirmed against the server: REST responses keep the legacy + integer-code envelope. If REST later adopts the catalogue, the base class introduced here is where + it would attach, but no REST parsing is in scope. +- **Class and code names are the product, not implementation detail.** For an SDK the exception + hierarchy *is* the user-facing contract, so this spec names classes and codes. It deliberately does + not specify module layout, file names, generator implementation, or test framework mechanics. +- **Generation belongs to Infrahub.** The SDK cannot regenerate its own protocols or schema models + today either; those come from Infrahub's generation task writing into the submodule. Error bindings + follow that established pattern rather than introducing a second mechanism, which also removes any + need to keep a vendored catalogue copy in sync. +- **Unification is a waypoint, not the end state.** Giving the SDK's own failures their own classes, + distinct from the ones representing what the server reported, is the intended direction — it would + retire `identifier`'s dual meaning and make `branch_name` coherent. It is out of scope here because it + cannot be done without a breaking change, and it belongs on the constitution's deprecation path + rather than inside this change. See research R9 for the sequence. +- **`infrahub_sdk.exceptions` is the supported import path, and it is treated as public.** A consumer + should never need to know which module inside it defines a given exception: every exception the SDK + raises — hand-written or generated — is importable from `infrahub_sdk.exceptions`, and no name + importable from it today may stop being importable from it. Modules beneath it are internal and are + not an import path for consumers. This is a stronger promise than the constitution's tiering + strictly requires, and it is made deliberately, because `infrahubctl`, the Ansible collection, and + external consumers already import from it directly. +- **The broadenings are accepted deliberately**: `except GraphQLError` will additionally catch node, + branch, and schema lookup misses that never involved a GraphQL request at all — both the client-side + ones and the REST 404 the file handler turns into a `NodeNotFoundError`; code that catches the + generic error to inspect its message will now sometimes receive a subclass with a different message, + or the same class carrying a catalogued message; and a failure the server reports under an adopted + code now raises that code's class rather than the generic one, so an `except NodeNotFoundError` + clause sees server-reported misses as well as client-side ones. They are left uncounted here because + adopting a further code adds another. Each follows from an answered decision rather than oversight. + + Nothing changes about which class a 401 or 403 produces: it remains `AuthenticationError`, and a + permission failure inside an HTTP 200 response remains a `GraphQLError`. That is what FR-008's + no-class rule for the authentication codes preserves. +- **The repository-import failure handling in the git integrator (GitHub #7498) is out of scope**, as + is any change to what the server emits. +- **Both repositories are in scope for this document.** Requirements FR-025 to FR-027 land in the + Infrahub repository and must be executed from that checkout; everything else lands here. diff --git a/dev/specs/ifc-3034-error-catalogue/tasks.md b/dev/specs/ifc-3034-error-catalogue/tasks.md new file mode 100644 index 000000000..2ed8af506 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/tasks.md @@ -0,0 +1,640 @@ +# Tasks: Error Catalogue in the Python SDK + +**Input**: Design documents from `dev/specs/ifc-3034-error-catalogue/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), +[data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: Included. Constitution Principle V requires tests to ship in the same change, and +[research.md](./research.md) R12 fixes the layer split they follow. + +**Organization**: Tasks are grouped by user story. Phase numbering is a nominal sequence, not a strict +dependency order - see the note below for the two adjacencies that are actually forced. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1-US6) +- Every task names the exact file it touches + +## Path conventions + +Two checkouts are involved: + +- **SDK** (this repository): paths are relative to the repository root, e.g. `infrahub_sdk/exceptions/base.py`. +- **Infrahub** (`opsmill/infrahub`, phase 7 only): paths are prefixed `[infrahub]`, e.g. + `[infrahub] tasks/backend.py`. + +## Phase order is a nominal sequence, not a strict dependency order + +Phases are numbered so there is one obvious path through the work. Only two adjacencies are actually +forced, and both cut against the priority labels ([research.md](./research.md) R16 and R4): + +1. **US3 must precede US5.** The three `CODE` declarations (T034) are a prerequisite for generation + succeeding at all, not merely for its output: the generator aborts on an undeclared name collision. +2. **US5 must precede US1.** The generator produces the per-code classes US1 delivers, so the P1 MVP + story lands last among the behaviour phases. Priority labels describe value, not order. + +Everything else is free. **US2, US3, US4, and US6 depend only on Foundational and are mutually +independent**, so do not serialize them just because they are numbered in sequence. The envelope parses +onto the base classes with **no generated bindings at all**, which is what makes US2 and US4 landable +before anything has been generated. See Parallel opportunities below for the file overlaps to watch if +they are worked concurrently. + +Nominal sequence: Setup → Foundational → US2 → US3 → US4 → US6 → US5 → US1 → Polish. +US1 remains the feature's reason for existing; it is simply the last brick, not the first. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Restructure `exceptions.py` into a package without changing a single observable behaviour, +and pin that invisibility before touching anything. + +- [X] T001 Capture the current public surface as a committed snapshot: write every name importable from + `infrahub_sdk.exceptions` today into `tests/fixtures/error_catalogue/public_names.json`, and add + `tests/unit/sdk/test_exceptions_public_names.py` asserting every snapshot name is still importable. + Author this **before** the restructure so the snapshot records the pre-change surface. + **The committed snapshot is post-change: it includes `ApiError`.** It still pins that every name + importable before the split is importable now, which is the property that matters, but it is not + the untouched pre-change baseline this task asked for. +- [X] T002 [P] Create the response-envelope fixture directory `tests/fixtures/error_catalogue/` with a + `README.md` stating that each file is a verbatim server response envelope, not a hand-shaped dict. + **The README distinguishes two kinds instead.** The claim cannot hold for the `malformed_*` + fixtures, since a correct server does not produce them; they are constructed and the README says + so. The captured/not-parser-shaped rule stands for every fixture representing a real response. +- [X] T003 Convert `infrahub_sdk/exceptions.py` into `infrahub_sdk/exceptions/base.py` by verbatim move + (no behaviour edits in this task), and add `__all__` to it listing every class it defines. +- [X] T004 Create the façade `infrahub_sdk/exceptions/__init__.py` re-exporting with `from .base import *` + and nothing else yet. +- [X] T005 [P] Add `"exceptions"` to `packages_to_ignore` in `tasks.py::get_modules_to_document`, so + `docs-generate` does not fail with `Uncategorized packages under infrahub_sdk/` and `sdk_ref` + output stays byte-identical. +- [X] T006 [P] Add the `per-file-ignores` entry for `infrahub_sdk/exceptions/__init__.py` in + `pyproject.toml` silencing `F403`/`F405`, with a comment giving the reason, mirroring the existing + `infrahub_sdk/schema/generated/*.py` entry. +- [X] T007 Run `uv run pytest tests/unit/ -q` and `uv run invoke format lint-code docs-generate docs-validate` + to confirm the restructure is invisible from outside the package. + +**Checkpoint**: `infrahub_sdk.exceptions` is a package; nothing else has changed. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The parsed envelope on the base classes, and the two raise-time factories every existing +raise site funnels through. Every user story below depends on this phase. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T008 Add `ApiError(Error)` to `infrahub_sdk/exceptions/base.py` with class-level defaults for + `code` (`str | None`), `http_status` (`int | None`), `extensions` (`dict[str, Any] | None`), + `errors` (immutable empty tuple), `query`, and `variables`, adding no required constructor + arguments. The defaults are a can't-crash floor, not a substitute for constructor state. + **Landed without `query` and `variables` on the base.** Only `GraphQLError` ever sets them, so on + `ApiError` they would be permanently `None` on every `AuthenticationError`, telling a caller that a + REST failure had no query rather than that it can never have one. They live on `GraphQLError`. +- [X] T009 Re-root `GraphQLError` under `ApiError` in `infrahub_sdk/exceptions/base.py` and give its + constructor an optional `message` parameter. When `message` is omitted the string it builds MUST be + byte-identical to today's. **Re-rooting landed; the `message` parameter is deferred to its first + caller.** Nothing in this issue passes it, and an unused public parameter is surface the SDK would + have to keep. T035 adds it as part of the same edit that first calls it. The byte-identical default + message is unaffected and is pinned by `test_malformed_envelope_keeps_the_payload_in_the_message`. +- [X] T010 Re-root `AuthenticationError` under `ApiError` in `infrahub_sdk/exceptions/base.py`, leaving + its name, constructor signature, and default message untouched. +- [X] T011 Create `infrahub_sdk/exceptions/factory.py` with `graphql_error_from_response(errors, query, + variables)`: read `extensions.code` from the **first** error only when it is a `str`, read + `extensions.http_status`, retain the complete unreordered error list, and construct with keyword + arguments only. +- [X] T012 Add `authentication_error_from_response(response)` to `infrahub_sdk/exceptions/factory.py`, + subsuming the eleven-site "decode, collect messages, join with ` | `" shape and preserving that + message byte-for-byte, falling back to the plain status when the body is not JSON, since two of the + sites it replaces call `response.json()` directly. **Landed reading `response.json()` directly + rather than `decode_json`.** What `decode_json` adds over `.json()` is raising `JsonDecodeError` + carrying the response URL and body, and this factory catches and discards both, so it was building + context it never surfaces: on this path the status and the server's reason are what the caller + needs. It also cost a deferred import inside the function body — `infrahub_sdk.utils` + imports this package, so the module-level spelling is a cycle — which is precisely the shape T015's + layering test exists to catch. The tolerance R10 asks for is unchanged. +- [X] T013 Make both factories total in `infrahub_sdk/exceptions/factory.py`: wrap resolution so any + unexpected error degrades to constructing today's generic exception rather than replacing the + server's failure with an SDK `TypeError`, and log every fallback at debug level with the code + involved. +- [X] T014 Extend the façade `infrahub_sdk/exceptions/__init__.py` to import the factory module, keeping + imports pointing strictly downward. **Landed declaring `__all__` on the façade as well**, and writing + the class list out by hand rather than star-importing it: a wildcard hides what the package exports, + which is the one thing this file exists to state. Importing the submodules makes `base` and `factory` + attributes of the package, so without `__all__` an `import *` hands a caller two names that are an + artefact of the layout and shadow those names in their scope. The raise-time factories stay + importable by name; they are simply not part of the wildcard, since what an end user catches is the + classes. Three tests hold the hand-written lists in step with `base`: the façade's `__all__` against + `base.__all__`, every declared name against what the import block actually binds, and + `base.__all__` against the classes `base` defines — the last one closing a gap where omitting a + class from `base.__all__` left it absent everywhere downstream with nothing to notice. +- [X] T015 Add `tests/unit/sdk/test_exceptions_layering.py`, parsing every module in + `infrahub_sdk/exceptions/` with `ast` and failing on any intra-package import that points at its own + layer or higher. It MUST walk imports inside function bodies and `TYPE_CHECKING` blocks, not only + module-level ones. **Landed also asserting the package imports nothing else in the SDK**, in either + the relative or the absolute spelling. The internal ordering was only ever half the property: every + other module is free to raise, so any dependency in that direction is a cycle waiting to be found, + and the deferred import that works around one is invisible to the intra-package check. +- [X] T016 Replace the four `GraphQLError` raise sites in `infrahub_sdk/client.py` (`_execute_graphql` + and the file-upload variants, async and sync) with `graphql_error_from_response`. +- [X] T017 Replace the four `AuthenticationError` raise sites in `infrahub_sdk/client.py` with + `authentication_error_from_response`. +- [X] T018 [P] Replace the six `AuthenticationError` raise sites in `infrahub_sdk/object_store.py` with + `authentication_error_from_response`. +- [X] T019 [P] Replace the `AuthenticationError` raise site at `infrahub_sdk/file_handler.py:164` with + `authentication_error_from_response`. +- [X] T020 [P] Correct the pre-existing constructor misuse at `infrahub_sdk/analyzer.py:42`, which passes + the bare string `"Schema is not provided"` where a list of error dicts is expected. + **No change needed - the premise does not hold.** `analyzer.py` imports `GraphQLError` from + `graphql` (graphql-core), whose constructor takes a message string, so the call is correct. +- [X] T021 [P] Correct the pre-existing constructor misuse at + `infrahub_sdk/testing/schemas/animal.py:154`, which passes a list whose single element is not a dict. + **No change needed - the premise does not hold.** `SchemaLoadResponse.errors` is annotated + `dict` and pydantic rejects a list, so `[resp.errors]` is already a `list[dict]`. +- [X] T022 Add `tests/unit/sdk/test_error_catalogue.py` covering the generic factory path with no bindings + present: `code` and `http_status` readable off a `GraphQLError` and an `AuthenticationError`, the + complete error list retained unreordered, and `exc.errors` asserted to be a sequence of dicts on + both paths. + +**Checkpoint**: The envelope is readable against any server version with no generated bindings at all. + +--- + +## Phase 3: User Story 2 - Keep working against any server version (Priority: P1) + +**Goal**: Parsing never raises, on any server version, for any envelope shape. + +**Independent Test**: Replay fixtures representing a newer server, an older server, and a pre-catalogue +server through the factory and assert no parse failure and the correct fallback in each case +(quickstart scenario 2). + +### Tests for User Story 2 + +- [X] T023 [P] [US2] Add cross-version envelope fixtures under `tests/fixtures/error_catalogue/`: an + unknown string code, a known code carrying an extra payload field, an error with no `extensions`, a + pre-catalogue integer `extensions.code` on `/graphql`, and a payload that violates its own declared + schema. +- [X] T024 [P] [US2] Add malformed-envelope fixtures under `tests/fixtures/error_catalogue/`: `errors` as + a bare string, `extensions` as a list, and `code` as a nested object. +- [X] T025 [US2] Add the `crossversion`-marked cases to `tests/unit/sdk/test_error_catalogue.py`: each + fixture from T023 raises the generic class for its transport, none raises during parsing, and + `exc.code` reads the wire string for the unknown code and `None` for the absent-envelope and + integer-code cases. +- [X] T026 [US2] Add the `malformed`-marked totality cases to `tests/unit/sdk/test_error_catalogue.py`, + asserting each fixture from T024 degrades to today's generic exception rather than an SDK `TypeError`. + +### Implementation for User Story 2 + +- [X] T027 [US2] Confirm in `infrahub_sdk/exceptions/factory.py` that an integer `extensions.code` never + reaches `exc.code`, and that `exc.code` is set from the wire whenever a string code was present, + including when no class matched it. Which class is raised and what `code` reports are separate + questions. +- [X] T028 [US2] Add the debug-level fallback log assertions to `tests/unit/sdk/test_error_catalogue.py`, + using `caplog`, so a cross-version fallback is diagnosable in the field rather than only in tests. + +**Checkpoint**: Every cross-version case is covered and none of them raises during parsing. + +--- + +## Phase 4: User Story 3 - Catch server-reported errors uniformly across transports (Priority: P2) + +**Goal**: The hierarchy is a tree under `ApiError`, the three unified classes are re-rooted with their +envelope state actually set, and no `except` clause or `isinstance` ladder loses coverage. + +**Prerequisite for phase 7**: the `CODE` declarations added here are what stop the generator aborting. + +**Independent Test**: Assert the class hierarchy directly and assert each existing `except` clause in +the SDK and CLI still catches what it caught before (quickstart scenario 3). + +### Tests for User Story 3 + +- [X] T029 [P] [US3] Add hierarchy tests to `tests/unit/sdk/test_exceptions.py`: no class in + `infrahub_sdk.exceptions` has more than one parent; `GraphQLError` and `AuthenticationError` are + siblings under `ApiError`; `NodeInvalidError` is an instance of `GraphQLError`. +- [X] T030 [P] [US3] Add transport tests to `tests/unit/sdk/test_exceptions.py`: a real 401 carrying + `TOKEN_EXPIRED` is caught by `except AuthenticationError` with `exc.code == "TOKEN_EXPIRED"`, and a + `PERMISSION_DENIED` inside an HTTP 200 body is caught by `except GraphQLError` with + `exc.code == "PERMISSION_DENIED"`. Add the matching envelope fixtures under + `tests/fixtures/error_catalogue/`. +- [X] T031 [P] [US3] Add the attribute-access test to `tests/unit/sdk/test_exceptions.py`: reading + `exc.errors`, `exc.query`, and `exc.variables` off a purely client-side `NodeNotFoundError` returns + empty/`None` rather than raising `AttributeError`, and `exc.errors` is a `list`, not the base's tuple. +- [X] T032 [P] [US3] Add the broadening test to `tests/unit/sdk/test_exceptions.py`: `except GraphQLError` + catches a client-side `NodeNotFoundError`, asserting the accepted behaviour change rather than + working around it. +- [X] T033 [P] [US3] Add ladder tests to `tests/unit/ctl/test_utils.py` driving `handle_exception` with + `NodeNotFoundError`, `SchemaNotFoundError`, a catalogued `GraphQLError`, and a catalogued + `AuthenticationError`, asserting rendered output rather than reading the source. + +### Implementation for User Story 3 + +- [X] T034 [US3] Declare `CODE = "NODE_NOT_FOUND"` on `NodeNotFoundError`, `CODE = "BRANCH_NOT_FOUND"` on + `BranchNotFoundError`, and `CODE = "SCHEMA_NOT_FOUND"` on `SchemaNotFoundError` in + `infrahub_sdk/exceptions/base.py`. +- [X] T035 [US3] Re-root the three classes under `GraphQLError` in `infrahub_sdk/exceptions/base.py`, each + calling `super().__init__(errors=[], query=None, variables=None, message=...)` explicitly so their + envelope attributes are set by the constructor that owns them. This is the first caller of + `GraphQLError`'s optional `message` parameter, which T009 deferred, so add the parameter here. +- [X] T036 [US3] Widen `NodeNotFoundError.identifier` to `Mapping[str, list[str]] | str` in + `infrahub_sdk/exceptions/base.py`, admitting the plain string `infrahub_sdk/file_handler.py:168` + already passes. **Landed in issue 1.** Hardening the 404 branch against a body carrying no + `detail` narrowed the argument from `Any` to `str`, which made the type checker report the + violation that had been there all along. Widening is the fix; a suppression would not be. +- [X] T037 [US3] Add a hand-written `from_payload` classmethod to each of the three classes in + `infrahub_sdk/exceptions/base.py`, mapping `node_kind`→`node_type` and `identifier`→`identifier`, + `branch_name`→`identifier`, and `kind`→`identifier`. Every promoted attribute on these three stays + **optional**, since neither provenance can populate the full set. + **Landed taking the payload alone, with no envelope parameters.** The envelope has no caller until + T072 routes the factory through `from_payload`, and T009's precedent is to add a parameter with the + edit that first passes it. The payload argument is typed by a small `Protocol` per class rather than + by the generated model, which `base.py` may not import: it sits at the bottom of the package. +- [X] T038 [US3] Add the catalogued branch to `handle_exception` in `infrahub_sdk/ctl/utils.py`, placed + **above** the class ladder and keyed on `exc.code is not None`, rendering the code and the server's + message. An error with no code falls through to today's ladder unchanged. + **The branch escapes the message and still renders the remaining server errors**, both found by + review. Rich reads `[main]` in a server message as markup and deletes it, which is why the two + renderers beside this one already escape; and since the code names only the governing error, a + multi-error response needs the rest listed under it or errors two onward never reach the user. +- [X] T039 [US3] Move the `(SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, + GraphQLQueryError)` branch **above** the `GraphQLError` branch in + `infrahub_sdk/ctl/utils.py::handle_exception`, which re-rooting would otherwise make unreachable. + **`BranchNotFoundError` was added to that tuple**, which predates this design and so did not list + it. It is one of the three classes being re-rooted, so leaving it out is the exact silent CLI + change the reordering exists to prevent: it would fall to the `GraphQLError` branch and render an + empty server error list where it used to print its message. +- [X] T040 [US3] Fix `print_graphql_errors` in `infrahub_sdk/ctl/utils.py`: degrade to the exception's + message when there are no server errors to render. The `isinstance(errors, list)` guard this task + also meant to fix is already gone: issue 1 made `exc.errors` a list of dicts by construction, so the + guard became unreachable and was removed with the annotation widened to `Sequence[dict[str, Any]]`. + **Landed in issue 1, via an optional `fallback` argument** rather than by passing the exception, so + the existing signature keeps working for any caller outside this repository. Pulled forward because + the empty-list gap is a regression issue 1 introduces: without it `infrahubctl` exits non-zero with + no output at all where it previously printed the payload. +- [X] T041 [US3] Verify no other ordered `isinstance` ladder is shadowed by the re-rooting: check + `infrahub_sdk/ctl/cli_commands.py:237` and `infrahub_sdk/ctl/validate.py:88`, and grep the SDK and + CLI for further sequential tests of these classes. **Landed in issue 1, and the two sites needed + fixing rather than only checking.** Both rendered a `str` entry through an `isinstance(error, str)` + branch that filtering `exc.errors` to dicts made unreachable, taking the `--branch` hint with it and + reporting `0 error(s)` for a payload the SDK could not read. Both now share + `print_graphql_query_errors`, which keys the hint on the server's message and degrades to the + exception's message. No further sequential tests of these classes exist outside `handle_exception`. +- [X] T042 [US3] Update `tests/unit/sdk/test_exceptions_public_names.py` so the snapshot check runs against + the re-rooted hierarchy, confirming the restructure is still invisible from outside the package. + **The snapshot itself is unchanged, so the existing check needed an addition rather than an edit.** + It collects exception classes by name, which re-rooting cannot disturb; what re-rooting can disturb + is where each class sits, so a new case asserts every snapshot name still descends from `Error` — + a class that leaves that root is as invisible to `except Error` as one that stopped being importable. + +**Checkpoint**: The hierarchy is a tree, every existing clause still catches what it caught, and the +generator's adoption prerequisite is satisfied. + +--- + +## Phase 5: User Story 4 - Stop the SDK string-matching its own server (Priority: P2) + +**Goal**: The silent token-refresh decision is typed, with the legacy string check surviving only as the +pre-catalogue fallback and appearing exactly once in the SDK. + +**Independent Test**: Drive the relogin path with a `TOKEN_EXPIRED` envelope, with the legacy +`"Expired Signature"` message, and with an unrelated 401 (quickstart scenario 5). + +### Tests for User Story 4 + +- [X] T043 [P] [US4] Extend `tests/unit/sdk/test_relogin_headers.py` with three cases: a 401 carrying + `TOKEN_EXPIRED` refreshes and retries, a 401 carrying the legacy `"Expired Signature"` message + refreshes and retries, and an unrelated 401 does not. +- [X] T044 [P] [US4] Add a case to `tests/unit/sdk/test_relogin_headers.py` driving a 401 with a non-JSON + body (an HTML proxy error page) and an empty body, asserting neither raises a decode error. + +### Implementation for User Story 4 + +- [X] T045 [US4] Add the shared refresh-decision helper to `infrahub_sdk/client.py`, reading + `errors[0].extensions.code == "TOKEN_EXPIRED"` and falling back to the existing + `"Expired Signature" in messages` check when no code is present. It MUST tolerate a non-JSON or empty + body rather than letting `response.json()` raise, since the wrapper sees REST responses too. + **Landed scanning every error, not `errors[0]`.** A stale token is a fact about the request, not + about which error happens to lead; reading only the first would refuse to refresh when the server + orders the codes differently. Tolerance also covers a body that decodes to valid JSON that is not an + object, which `response.json().get(...)` raised an `AttributeError` on. +- [X] T046 [US4] Route both `handle_relogin` and `handle_relogin_sync` in `infrahub_sdk/client.py` through + that helper, so the literal `"Expired Signature"` appears exactly once in the SDK, down from twice. +- [X] T047 [US4] Confirm `grep -rn "Expired Signature" infrahub_sdk/` returns exactly one site, and that + the GraphQL schema-validation probing used for server feature detection is untouched - it detects an + uncatalogued condition and is deliberately out of scope. + +**Checkpoint**: The refresh decision is typed and a pre-catalogue server still refreshes. + +--- + +## Phase 6: User Story 6 - Messages that are about the failure (Priority: P3) + +**Goal**: A server-reported catalogued failure's message names the code and the server's message and +carries no query text; an uncatalogued failure's message is byte-identical to today's. + +**Independent Test**: Trigger a catalogued failure and an uncatalogued one and compare their messages +(quickstart scenario 6). + +### Tests for User Story 6 + +- [X] T048 [P] [US6] Add `message`-marked cases to `tests/unit/sdk/test_exceptions.py`: a server-reported + catalogued failure's message names the code and the server's message and contains no query text; an + uncatalogued failure's message is byte-identical to today's string; and a unified class raised with + no catalogue code behind it keeps today's message exactly. +- [X] T049 [P] [US6] Add a case to `tests/unit/sdk/test_exceptions.py` asserting `exc.query` and + `exc.variables` remain populated on a catalogued failure. + +### Implementation for User Story 6 + +- [X] T050 [US6] Have the factories in `infrahub_sdk/exceptions/factory.py` pass a message naming the code + and the server's message for a catalogued failure, and pass nothing for an uncatalogued one so + `GraphQLError` reproduces today's string. + **The authentication factory names the code too, which changed two assertions issue 1 landed.** + FR-022 is about a server-reported catalogued failure, not about a transport, and both factories + produce one; the auth message keeps its whole existing fallback chain — joined server messages, + then the REST `detail`, then the plain status — with the code named ahead of whichever survived. + `test_message_joins_the_server_messages` and `test_a_403_is_parsed_the_same_way_as_a_401` now + assert the new string rather than working around it. + **On the GraphQL path only the governing error's message is named**, corrected after review. An + earlier form joined every message behind the first error's code, filing later errors under a code + that was not theirs; the complete list was always retained on `exc.errors` either way. + **A second changelog fragment covers this**, since T077's is issue 3's and T079's is the + re-rooting: the message change ships here and is visible to anyone matching on the old strings. +- [X] T051 [US6] Re-check the message assertion at `tests/unit/sdk/test_graph_traversal.py:383` + (`match="Source node not found"`) deliberately: that path stays uncatalogued, so confirm the + assertion still holds rather than assuming it. + **Confirmed by running it: the assertion holds untouched.** The response it mocks carries no + `extensions` at all, so no code resolves, and the message is today's string with the server's + error list embedded in it verbatim. + +**Checkpoint**: Message behaviour is pinned in both directions. + +--- + +## Phase 7: User Story 5 - Bindings that cannot silently drift (Priority: P2) + +**Goal**: Infrahub generates `infrahub_sdk/exceptions/catalogue.py` into the SDK submodule as part of its +existing generation task, and its existing validation fails when the committed artefact is stale. + +**Runs from the Infrahub checkout.** Depends on phase 4 (T034): the generator aborts on an undeclared name +collision, so the three `CODE` declarations must already be in the SDK. + +**Independent Test**: Change the catalogue without regenerating and confirm validation fails; regenerate +and confirm it passes (quickstart scenario 7). + +### Implementation for User Story 5 + +- [ ] T052 [US5] Create `[infrahub] backend/templates/generate_sdk_errors.j2` emitting, in sorted code + order: a header marking the file generated and not to be edited, naming + `schema/error-catalogue.json`, recording `infrahub_catalogue_version`, and giving the regeneration + command; one pydantic payload model per code; one exception class per non-adopted, non-401/403 code; + `CODE_TO_EXCEPTION`; and `__all__`. +- [ ] T053 [US5] Implement name derivation in the template's helper in `[infrahub] tasks/backend.py`: the + code's parts capitalised and joined with `Error` appended only when the result does not already end + in `Error`, and the payload model name taken from `data_schema.title` verbatim. +- [ ] T054 [US5] Implement the JSON-Schema-to-Python type mapping in `[infrahub] tasks/backend.py` covering + `string`, `integer`, `number`, `boolean`, `string`+`format: date-time`, `array`, and + `anyOf: [T, null]`. Anything outside that vocabulary MUST abort with the offending fragment in the + message. +- [ ] T055 [US5] Implement the adoption walk in `[infrahub] tasks/backend.py`: parse + `python_sdk/infrahub_sdk/exceptions/base.py` with `ast`, collect every class whose **own body** + assigns a `CODE` string, and emit an import plus a `CODE_TO_EXCEPTION` entry for those codes instead + of a class definition. The payload model is still emitted. Parsing rather than importing is what + keeps `NodeInvalidError`'s inherited `CODE` invisible. +- [ ] T056 [US5] Implement the collision check in `[infrahub] tasks/backend.py` from the same walk: abort + when a derived name matches a class defined in `base.py` that has not declared that code. Exercise it + against `ValidationError`, `RateLimitError`, `InvalidResponseError`, `FileNotValidError`, + `ResourceNotDefinedError`, and `TimestampFormatError`, every one of which a plausible future code + would derive. +- [ ] T057 [US5] Add the remaining abort conditions to `[infrahub] tasks/backend.py`: no integer + `http_status`, no non-empty `data_schema.title`, empty `codes`, or a non-object root. +- [ ] T058 [US5] Emit no class for a code declaring 401 or 403, and root every other class at + `GraphQLError` with exactly one parent, in `[infrahub] backend/templates/generate_sdk_errors.j2`. +- [ ] T059 [US5] Emit each generated class's promoted attributes typed exactly as the catalogue declares + them (required fields non-optional, nullable fields carrying their declared default), assigned in + `__init__`, plus a `from_payload` classmethod and a docstring carrying the catalogue's `description` + and `stability`, in `[infrahub] backend/templates/generate_sdk_errors.j2`. +- [ ] T060 [US5] Add `_generate_sdk_error_bindings` to `[infrahub] tasks/backend.py` (render, then + `ruff format`, as the sibling generators do) and call it from the existing `generate` task. Correct + `generate`'s docstring, which has said "internal backend models" since it started generating SDK + protocols. +- [ ] T061 [US5] Extend `validate_generated` in `[infrahub] tasks/backend.py` with + `git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py`, with a failure hint naming + `uv run invoke backend.generate`. The diff MUST run inside the submodule; from the superproject + `git diff` sees only the gitlink. +- [ ] T062 [US5] Add `error_catalogue == 'true'` to the `backend-validate-generated` job trigger in + `[infrahub] .github/workflows/ci.yml`, so a hand-edit of the catalogue JSON alone cannot slip past. + This is the only CI edit; no new path filter can match a file inside a submodule. +- [ ] T063 [US5] Run `uv run invoke backend.generate` from the Infrahub checkout and hand-verify the + resulting `infrahub_sdk/exceptions/catalogue.py` in the SDK: nine generated classes, three adopted + imports, fifteen payload models, and no class for the three 401/403 codes. +- [ ] T064 [US5] Commit the generated `infrahub_sdk/exceptions/catalogue.py` in the SDK repository and + extend the façade `infrahub_sdk/exceptions/__init__.py` with `from .catalogue import *`. +- [ ] T065 [US5] Prove the negative from the Infrahub checkout: add a code to the backend catalogue, run + `uv run invoke backend.export-error-catalogue` alone, confirm `backend.validate-generated` exits + non-zero naming the stale artefact, then revert. + +**Checkpoint**: A catalogue change that skips regeneration fails the pull request that made it. + +--- + +## Phase 8: User Story 1 - Branch on a specific server failure (Priority: P1) 🎯 MVP + +**Goal**: Every catalogued failure raises its own class carrying the payload's fields as directly typed +attributes. + +**Independent Test**: Drive each catalogued failure through a fixture of its response envelope and assert +the raised type and the typed attributes, reading no message (quickstart scenarios 1, 4, 6b). + +### Tests for User Story 1 + +- [ ] T066 [P] [US1] Add one response-envelope fixture per catalogue code under + `tests/fixtures/error_catalogue/`, each a verbatim server response rather than a hand-shaped dict. +- [ ] T067 [US1] Add the exhaustive factory cases to `tests/unit/sdk/test_error_catalogue.py`: one per + code, asserting the raised class, every promoted attribute's concrete value, and that `exc.code` and + `exc.http_status` match the catalogue entry. No case reads a payload object, because there is none. +- [ ] T068 [P] [US1] Add the adopted-class cases to `tests/unit/sdk/test_error_catalogue.py`: a + server-reported `NODE_NOT_FOUND` populates `node_type` and `identifier`, `BRANCH_NOT_FOUND` and + `SCHEMA_NOT_FOUND` populate `identifier`, and `exc.code is not None` distinguishes a server-reported + raise from a client-side one. +- [ ] T069 [P] [US1] Add the representative parity set to `tests/unit/sdk/test_client.py`, parametrized + over `["standard", "sync"]` via the `BothClients` fixture, covering both branches, both transports, + and the file-upload variant, asserting the same class and the same attributes on each. +- [ ] T070 [P] [US1] Add a `catalogue`-marked case to `tests/integration/test_infrahub_client.py`: saving a + node that collides on a unique attribute raises `UniquenessViolationError` with the node kind and + colliding fields from the real payload, and deleting a missing node raises `NodeNotFoundError` with + its kind and identifier. +- [ ] T071 [P] [US1] Add the same two `catalogue`-marked cases to + `tests/integration/test_infrahub_client_sync.py`. + +### Implementation for User Story 1 + +- [ ] T072 [US1] Extend `graphql_error_from_response` in `infrahub_sdk/exceptions/factory.py` to look the + first error's code up in `CODE_TO_EXCEPTION`, validate `extensions.data` with the class's + `DATA_MODEL`, and raise via `cls.from_payload(...)`. The factory never assembles attributes itself. +- [ ] T073 [US1] Implement the validation-failure fallback in `infrahub_sdk/exceptions/factory.py`: an + invalid payload falls back to the generic class for the observed transport with `exc.code` still + readable, the raw `extensions` retained, and a debug log. A pydantic `ValidationError` never escapes + a raise path. +- [ ] T074 [US1] Confirm in `infrahub_sdk/exceptions/factory.py` that the fallback follows **the transport + the SDK observed** and never the code's declared status: the GraphQL branch for anything read from + an `errors` array, the authentication branch only for a response the SDK saw as HTTP 401 or 403. + This is the only rule under which the three authentication codes reach the right class at all. +- [ ] T075 [US1] Add a test to `tests/unit/sdk/test_error_catalogue.py` asserting the first error governs + even when it carries no code and a later one does, and that the complete list is retained unreordered. + +**Checkpoint**: Every catalogue code is identifiable without reading a message, on both clients. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +- [ ] T076 [P] Write `docs/docs/python-sdk/topics/error_handling.mdx` covering the hierarchy, catching by + branch versus by code, the cross-version guarantees, the two accepted broadenings, and the note that + `infrahub_sdk.exceptions` is the supported import path. Link to Infrahub's published catalogue for + the code list rather than restating it, and note that a catalogued message now names the failing + action and resource kind where the catalogue provides them. +- [ ] T077 [P] Add a towncrier fragment for the typed errors in `changelog/`. +- [X] T078 [P] Add a towncrier fragment for the `NodeNotFoundError.identifier` widening in `changelog/`. + **Landed in issue 1**, alongside the widening itself (T036). +- [X] T079 [P] Add a towncrier fragment for the `except GraphQLError` broadening in `changelog/`. +- [ ] T080 Run `uv run invoke format lint-code` and confirm both `mypy` and `ty` pass with **zero** + suppressions. A needed `# type: ignore` is a signal the shape is wrong, not a licence to add one. +- [ ] T081 Run `uv run invoke docs-generate && uv run invoke docs-validate` and confirm `sdk_ref` output is + unchanged, then `uv run invoke lint-docs`. +- [ ] T082 Run the full quickstart: every scenario in [quickstart.md](./quickstart.md), including the + testcontainers scenario 6b and the Infrahub-side scenario 7. +- [ ] T083 Confirm the landing order with whoever owns the release flow before the paired pull requests go + up: the SDK change merges first, then Infrahub bumps its submodule pointer to it. This is inferred + from the repositories' history rather than from a written practice. + +--- + +## Dependencies & Execution Order + +### Phase dependencies + +- **Setup (Phase 1)**: no dependencies. +- **Foundational (Phase 2)**: depends on Setup. **Blocks every user story.** +- **US2 (Phase 3)**: depends on Foundational only. Needs no generated bindings. +- **US3 (Phase 4)**: depends on Foundational. **Blocks US5** - T034's `CODE` declarations are what stop + the generator aborting. +- **US4 (Phase 5)**: depends on Foundational only. Independent of US3 and US5. +- **US6 (Phase 6)**: depends on `GraphQLError`'s optional `message` parameter, which T009 deferred to its + first caller. Whichever of T035 and US6 lands first adds it. Independent of US5. +- **US5 (Phase 7)**: depends on US3 (T034). Runs from the Infrahub checkout. +- **US1 (Phase 8)**: depends on US5 (T064 lands `catalogue.py`) and on US3 (T037's adopted `from_payload`). +- **Polish (Phase 9)**: depends on all of the above. + +### Parallel opportunities + +- **Phase 1**: T002, T005, T006 are independent files. +- **Phase 2**: T018, T019, T020, T021 touch four different modules and can run together once T011-T013 exist. +- **Phases 3, 4, 5, and 6 are mutually independent** once Foundational is done: US2, US3, US4, and US6 + each depend on Phase 2 alone. Two file overlaps to coordinate if they are genuinely worked in + parallel: US2 and US6 both edit `infrahub_sdk/exceptions/factory.py`, and US3 and US6 both add cases + to `tests/unit/sdk/test_exceptions.py`. US4 touches neither. +- **Phase 4**: T029-T033 are five independent test additions. +- **Phase 8**: T066, T068, T069, T070, T071 are independent; T070 and T071 are the two integration files. +- **Phase 9**: T076-T079 are four independent files. + +### Within each story + +- Fixtures before the tests that load them. +- Tests before the implementation that satisfies them. +- Base classes before the factory; the factory before the raise sites. +- Deliberate behaviour changes are pinned by a test asserting the **new** behaviour, never worked around. + +--- + +## Parallel Example: User Story 3 + +```bash +# Five independent test additions, one file each: +Task: "Hierarchy tests in tests/unit/sdk/test_exceptions.py" +Task: "Transport tests in tests/unit/sdk/test_exceptions.py" +Task: "Attribute-access test in tests/unit/sdk/test_exceptions.py" +Task: "Broadening test in tests/unit/sdk/test_exceptions.py" +Task: "Ladder tests in tests/unit/ctl/test_utils.py" +``` + +## Parallel Example: Phase 2 raise-site wiring + +```bash +Task: "Replace the six AuthenticationError raise sites in infrahub_sdk/object_store.py" +Task: "Replace the AuthenticationError raise site in infrahub_sdk/file_handler.py" +Task: "Correct the constructor misuse in infrahub_sdk/analyzer.py" +Task: "Correct the constructor misuse in infrahub_sdk/testing/schemas/animal.py" +``` + +--- + +## Pull requests and issues + +Task count is not pull-request count. The 83 tasks above are implementation granularity; the boundaries +below are set by three things, none of which is task count: + +1. **The repository split.** The generator lives in `opsmill/infrahub`, so it is a separate pull request + whatever else happens. +2. **The landing order** ([research.md](./research.md) R17): the SDK merges first, then Infrahub bumps its + submodule pointer. Infrahub's `validate_generated` diffs submodule *content*, so `catalogue.py` must + already exist at the pointer Infrahub carries. +3. **Where the review risk actually is.** The compatibility-sensitive work (re-rooting three classes, + reordering the CLI ladder, two deliberate broadenings) is the only place FR-018 can be violated. It + gets its own pull request so a reviewer reads it on its own. + +### Four issues, four pull requests + +| # | Issue | Repo | Tasks | Scope | +|---|-------|------|-------|-------| +| 1 | Parse the catalogue envelope onto the base classes | SDK | T001-T028, T036, T040-T041, T043-T047, T078 | The exceptions package, `ApiError`, the two factories, every raise site rewired, cross-version tolerance, and the typed refresh decision. No generated bindings, no re-rooting, no class anyone catches changes shape. | +| 2 | Unify and re-root the not-found classes | SDK | T029-T035, T037-T039, T042, T048-T051, T079 | The `CODE` declarations, the re-rooting, the CLI ladder, the message change, and the changelog fragment documenting the `except GraphQLError` broadening. | +| 3 | Typed per-code exceptions | SDK | T063-T064, T066-T077 | The generated `catalogue.py`, per-code resolution and its validation-failure fallback, both-client parity, the integration tests, the topic page, and the typed-errors changelog fragment. | +| 4 | Generate the SDK's error bindings | **Infrahub** | T052-T062, T065 | The template, the renderer, the adoption walk and collision check, `validate_generated`, the CI trigger, and the submodule pointer bump. | + +T080-T082 (format, lint, type-check, docs-validate, quickstart) run on **every** pull request for the +scenarios that request covers; quickstart scenario 7 runs only on issue 4. T083 (confirm the landing order +with whoever owns the release flow) belongs to issue 1's timeframe, before issues 3 and 4 go up. + +Two pull requests would also work (one SDK, one Infrahub), since the tasks are ordered so a single branch +carries them. It is not recommended: issue 2 is where "every existing `except` clause still catches what it +caught" has to be verified, and the implementation that has to be verified is only eleven tasks +(T029-T035, T037-T039, T042) across three files, ahead of its tests and changelog fragment. + +### Issue dependencies to record + +- **Issue 2 blocks issue 4.** The three `CODE` declarations (T034) are what stop the generator aborting on + an undeclared name collision. Generation fails outright without them; this is not a preference about + ordering. +- **Issue 3 depends on issues 2 and 4.** +- Not a dependency, but worth scheduling around: issues 1 and 2 both touch + `infrahub_sdk/exceptions/base.py`, so running them in sequence avoids a conflict rather than + satisfying a constraint. + +### Issue 4 is developed before issue 3 but merges after it + +`catalogue.py` in issue 3 is produced by running the generator from issue 4's unmerged branch (T063). So +issue 4 sits in progress while issue 3, which consumes its output, merges. That is the correct order, not +a mistake to be tidied up: the SDK pull request must carry the generated artefact before Infrahub's +content-level validation can pass against the pointer it bumps to. + +## Implementation Strategy + +### MVP scope + +The feature's value is US1, and US1 cannot ship without US2 (typed errors that raise on an unrecognised +payload would be a regression, not a feature) or without the generator that produces its classes. So the +MVP is **Phases 1-4 plus 7-8**: US2, US3, US5, US1. US4 and US6 are genuinely optional to the MVP and can +follow. + +--- + +## Notes + +- [P] tasks touch different files and have no dependency on an incomplete task. +- Commit after each task or logical group; run `uv run invoke format lint-code` before each commit. +- Do not reference this document, its task IDs, or the ticket in any file that ships: code, comments, + docstrings, changelog fragments, or documentation. Every shipped file must stand on its own. +- Stop at any checkpoint to validate the increment independently. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx index a96c4ae4b..159f06151 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx @@ -17,7 +17,7 @@ optional fields omitted (the server applies its own defaults). ### `is_unknown_field_error` ```python -is_unknown_field_error(errors: list[dict[str, Any]], field_name: str) -> bool +is_unknown_field_error(errors: Sequence[dict[str, Any]], field_name: str) -> bool ``` Return True if the GraphQL errors indicate ``field_name`` is an unknown query field. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 0da3d6378..eb70860fe 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -25,7 +25,6 @@ from .data import RepositoryBranchInfo, RepositoryData, ServerInfo from .diff import DiffTreeData, NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query, get_diff_tree_query from .exceptions import ( - AuthenticationError, Error, GraphQLError, NodeNotFoundError, @@ -34,7 +33,10 @@ ServerNotResponsiveError, URLNotFoundError, VersionNotSupportedError, + authentication_error_from_response, + graphql_error_from_response, ) +from .exceptions.factory import token_expired_in from .graph_traversal.models import PathTraversalResult, ReachableNodesResult from .graph_traversal.query import ( PATH_TRAVERSAL_QUERY, @@ -177,17 +179,42 @@ class ProcessRelationsNodeSync(TypedDict): related_nodes: list[InfrahubNodeSync] +def _should_refresh_token(response: httpx.Response) -> bool: + """Decide whether a 401 is a stale token worth one silent refresh and retry. + + The wrapper also sees REST responses, and a proxy can answer with anything at all, so a body that + is not a JSON object carries no refresh signal rather than raising. + """ + try: + body = response.json() + except ValueError: + return False + + if not isinstance(body, dict): + return False + + return token_expired_in(body.get("errors", [])) + + +def _can_refresh_token(client: InfrahubClient | InfrahubClientSync) -> bool: + """Whether this client has a login it could refresh into a working token. + + `login(refresh=True)` returns without touching the auth header unless the client authenticates + with a username and password, so retrying an API-token client would replay the same stale token + and earn a second 401 for nothing. + """ + return client.config.password_authentication + + def handle_relogin( func: Callable[..., Coroutine[Any, Any, httpx.Response]], ) -> Callable[..., Coroutine[Any, Any, httpx.Response]]: @wraps(func) async def wrapper(client: InfrahubClient, *args: Any, **kwargs: Any) -> httpx.Response: response = await func(client, *args, **kwargs) - if response.status_code == 401: - errors = response.json().get("errors", []) - if "Expired Signature" in [error.get("message") for error in errors]: - await client.login(refresh=True) - return await func(client, *args, **kwargs) + if response.status_code == 401 and _can_refresh_token(client) and _should_refresh_token(response=response): + await client.login(refresh=True) + return await func(client, *args, **kwargs) return response return wrapper @@ -197,11 +224,9 @@ def handle_relogin_sync(func: Callable[..., httpx.Response]) -> Callable[..., ht @wraps(func) def wrapper(client: InfrahubClientSync, *args: Any, **kwargs: Any) -> httpx.Response: response = func(client, *args, **kwargs) - if response.status_code == 401: - errors = response.json().get("errors", []) - if "Expired Signature" in [error.get("message") for error in errors]: - client.login(refresh=True) - return func(client, *args, **kwargs) + if response.status_code == 401 and _can_refresh_token(client) and _should_refresh_token(response=response): + client.login(refresh=True) + return func(client, *args, **kwargs) return response return wrapper @@ -1429,7 +1454,7 @@ async def execute_graphql( AuthenticationError: If the server returns a 401 or 403 response. URLNotFoundError: If the server returns a 404 response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name, at=at) @@ -1450,10 +1475,7 @@ async def execute_graphql( resp.raise_for_status() except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = decode_json(response=exc.response) - errors = response.get("errors", []) - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc if exc.response.status_code == 404: raise URLNotFoundError(url=url) from exc # Any other status falls through: the body is expected to carry a GraphQL error envelope. @@ -1469,7 +1491,7 @@ async def execute_graphql( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -1511,8 +1533,10 @@ async def _execute_graphql_with_file( Raises: GraphQLError: When the GraphQL response contains errors. + AuthenticationError: If the server returns a 401 or 403 response. + httpx.HTTPStatusError: For any other non-2xx response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name) @@ -1541,7 +1565,12 @@ async def _execute_graphql_with_file( retry_state=retry_state, ) - resp.raise_for_status() + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {401, 403}: + raise authentication_error_from_response(response=exc.response) from exc + raise response = decode_json(response=resp) if "errors" in response: @@ -1553,7 +1582,7 @@ async def _execute_graphql_with_file( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -1852,10 +1881,7 @@ async def login(self, refresh: bool = False) -> None: # If we got a 401 while trying to refresh a token we must restart the authentication process # Other status codes indicate other errors if exc.response.status_code != 401: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc url = f"{self.address}/api/auth/login" response = await self._request( @@ -2470,7 +2496,7 @@ def execute_graphql( AuthenticationError: If the server returns a 401 or 403 response. URLNotFoundError: If the server returns a 404 response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name, at=at) @@ -2491,10 +2517,7 @@ def execute_graphql( resp.raise_for_status() except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = decode_json(response=exc.response) - errors = response.get("errors", []) - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc if exc.response.status_code == 404: raise URLNotFoundError(url=url) from exc # Any other status falls through: the body is expected to carry a GraphQL error envelope. @@ -2510,7 +2533,7 @@ def execute_graphql( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -2552,8 +2575,10 @@ def _execute_graphql_with_file( Raises: GraphQLError: When the GraphQL response contains errors. + AuthenticationError: If the server returns a 401 or 403 response. + httpx.HTTPStatusError: For any other non-2xx response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name) @@ -2582,7 +2607,12 @@ def _execute_graphql_with_file( retry_state=retry_state, ) - resp.raise_for_status() + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {401, 403}: + raise authentication_error_from_response(response=exc.response) from exc + raise response = decode_json(response=resp) if "errors" in response: @@ -2594,7 +2624,7 @@ def _execute_graphql_with_file( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -4074,10 +4104,7 @@ def login(self, refresh: bool = False) -> None: # If we got a 401 while trying to refresh a token we must restart the authentication process # Other status codes indicate other errors if exc.response.status_code != 401: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc url = f"{self.address}/api/auth/login" response = self._request( diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 9d947ff1f..632be2ed4 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -43,6 +43,7 @@ execute_graphql_query, load_yamlfile_from_disk_and_exit, parse_cli_vars, + print_graphql_query_errors, ) from ..ctl.validate import app as validate_app from ..exceptions import GraphQLError, ModuleImportError @@ -235,14 +236,7 @@ async def _run_transform( console.print(f"[red]Unable to find query : {exc}") raise typer.Exit(1) from exc except GraphQLError as exc: - console.print(f"[red]{len(exc.errors)} error(s) occurred while executing the query") - for error in exc.errors: - if isinstance(error, dict) and "message" in error and "locations" in error: - console.print(f"[yellow] - Message: {error['message']}") # type: ignore[typeddict-item] - console.print(f"[yellow] Location: {error['locations']}") # type: ignore[typeddict-item] - elif isinstance(error, str) and "Branch:" in error: - console.print(f"[yellow] - {error}") - console.print("[yellow] you can specify a different branch with --branch") + print_graphql_query_errors(console=console, exc=exc) raise typer.Abort from None if inspect.iscoroutinefunction(transform_func): diff --git a/infrahub_sdk/ctl/utils.py b/infrahub_sdk/ctl/utils.py index 3898b7d90..1e18a5664 100644 --- a/infrahub_sdk/ctl/utils.py +++ b/infrahub_sdk/ctl/utils.py @@ -3,7 +3,7 @@ import inspect import logging import traceback -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Sequence from functools import wraps from pathlib import Path from typing import TYPE_CHECKING, Any, NoReturn, TypeVar @@ -15,7 +15,9 @@ from rich.markup import escape from ..exceptions import ( + ApiError, AuthenticationError, + BranchNotFoundError, Error, FileNotValidError, GraphQLError, @@ -26,6 +28,7 @@ ServerNotReachableError, ServerNotResponsiveError, ValidationError, + code_names_the_failure, ) from ..graphql.query_renderer import render_query from ..yaml import YamlFile @@ -52,27 +55,58 @@ def init_logging(debug: bool = False) -> None: def handle_exception(exc: Exception, console: Console, exit_code: int) -> NoReturn: - """Handle exception in a different fashion based on its type.""" + """Handle exception in a different fashion based on its type. + + Two orderings are load-bearing, and both are here rather than in the branches that depend on + them. The described-code branch comes first because it is the only one that can name the failure + the way the server did, and every class-keyed branch below would either mislabel it or drop the + code. The lookup misses come ahead of `GraphQLError`, which they now descend from and which would + otherwise claim them and render an empty server error list in place of their message. + + Every message reaching the console is escaped: a server's own words routinely contain + brackets (a branch name, an identifier) and rich would read those as a style tag and delete them. + """ if isinstance(exc, typer.Exit): raise exc + if isinstance(exc, ApiError) and code_names_the_failure(exc.code): + if exc.errors: + # The server's errors carry the path naming the operation that failed, which the coded + # line has nowhere to put, so they render the detail and the code just names the failure. + console.print(f"[red]{escape(exc.code)}") + print_graphql_errors(console=console, errors=exc.errors, raw_entry_without_path=False) + else: + console.print(f"[red]{escape(str(exc))}") + raise typer.Exit(code=exit_code) if isinstance(exc, AuthenticationError): - console.print(f"[red]Authentication failure: {exc!s}") + console.print(f"[red]Authentication failure: {escape(str(exc))}") raise typer.Exit(code=exit_code) if isinstance(exc, (ServerNotReachableError, ServerNotResponsiveError)): - console.print(f"[red]{exc!s}") + console.print(f"[red]{escape(str(exc))}") raise typer.Exit(code=exit_code) if isinstance(exc, HTTPError): - console.print(f"[red]HTTP communication failure: {exc!s} on {exc.request.method} to {exc.request.url}") + console.print( + f"[red]HTTP communication failure: {escape(str(exc))} " + f"on {escape(str(exc.request.method))} to {escape(str(exc.request.url))}" + ) raise typer.Exit(code=exit_code) - if isinstance(exc, GraphQLError): - print_graphql_errors(console=console, errors=exc.errors) + if isinstance( + exc, + ( + SchemaNotFoundError, + NodeNotFoundError, + BranchNotFoundError, + ResourceNotDefinedError, + GraphQLQueryError, + ), + ): + console.print(f"[red]Error: {escape(str(exc))}") raise typer.Exit(code=exit_code) - if isinstance(exc, (SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, GraphQLQueryError)): - console.print(f"[red]Error: {exc!s}") + if isinstance(exc, GraphQLError): + print_graphql_errors(console=console, errors=exc.errors, fallback=str(exc)) raise typer.Exit(code=exit_code) - console.print(f"[red]Error: {exc!s}") - console.print(traceback.format_exc()) + console.print(f"[red]Error: {escape(str(exc))}") + console.print(escape(traceback.format_exc())) raise typer.Exit(code=exit_code) @@ -137,15 +171,57 @@ def execute_graphql_query( return response -def print_graphql_errors(console: Console, errors: list) -> None: - if not isinstance(errors, list): - console.print(f"[red]{escape(str(errors))}") +def print_graphql_errors( + console: Console, + errors: Sequence[dict[str, Any]], + fallback: str | None = None, + raw_entry_without_path: bool = True, +) -> None: + """Render the server's errors, degrading to `fallback` when there is nothing to render. + + An envelope whose entries did not match the declared shape leaves `errors` empty, and exiting + non-zero with no output at all would tell the user nothing. + + `raw_entry_without_path` decides what an entry carrying no path renders as. By default the whole + decoded entry prints, because a validation error carries `locations` instead of a path and those + coordinates are the useful part. A caller that has already printed a line naming the failure + passes `False`, so the rest read as the server's sentences rather than as decoded dicts. + """ + if not errors: + if fallback: + console.print(f"[red]{escape(fallback)}") + return for error in errors: - if isinstance(error, dict) and "message" in error and "path" in error: - console.print(f"[red]{escape(str(error['path']))} {escape(str(error['message']))}") - else: + # An explicit null path is as good as an absent one; keying on the key alone renders "None" + # in front of the message. + path = error.get("path") if isinstance(error, dict) else None + if isinstance(error, dict) and "message" in error and path is not None: + console.print(f"[red]{escape(str(path))} {escape(str(error['message']))}") + elif raw_entry_without_path or not isinstance(error, dict): console.print(f"[red]{escape(str(error))}") + else: + console.print(f"[red]{escape(str(error.get('message', error)))}") + + +def print_graphql_query_errors(console: Console, exc: GraphQLError) -> None: + """Render the failure of a GraphQL query the CLI ran on the user's behalf. + + The branch hint is keyed on the server's message rather than on the entry's Python type, because + the exception's `errors` are dicts by construction and a bare string never reaches here. + """ + if not exc.errors: + console.print(f"[red]{escape(str(exc))}") + return + + console.print(f"[red]{len(exc.errors)} error(s) occurred while executing the query") + for error in exc.errors: + message = str(error.get("message", error)) + console.print(f"[yellow] - Message: {escape(message)}") + if "locations" in error: + console.print(f"[yellow] Location: {escape(str(error['locations']))}") + if "Branch:" in message: + console.print("[yellow] you can specify a different branch with --branch") def parse_cli_vars(variables: list[str] | None) -> dict[str, str]: diff --git a/infrahub_sdk/ctl/validate.py b/infrahub_sdk/ctl/validate.py index 8b03b332e..8ea37822b 100644 --- a/infrahub_sdk/ctl/validate.py +++ b/infrahub_sdk/ctl/validate.py @@ -11,7 +11,7 @@ from ..async_typer import AsyncTyper from ..ctl.client import initialize_client_sync from ..ctl.exceptions import QueryNotFoundError -from ..ctl.utils import catch_exception, find_graphql_query, parse_cli_vars +from ..ctl.utils import catch_exception, find_graphql_query, parse_cli_vars, print_graphql_query_errors from ..exceptions import GraphQLError from ..schema import validate_schema as validate_schema_offline from ..utils import write_to_file @@ -86,14 +86,7 @@ def validate_graphql( variables=variables_dict, ) except GraphQLError as exc: - console.print(f"[red]{len(exc.errors)} error(s) occurred while executing the query") - for error in exc.errors: - if isinstance(error, dict) and "message" in error and "locations" in error: - console.print(f"[yellow] - Message: {error['message']}") - console.print(f"[yellow] Location: {error['locations']}") - elif isinstance(error, str) and "Branch:" in error: - console.print(f"[yellow] - {error}") - console.print("[yellow] you can specify a different branch with --branch") + print_graphql_query_errors(console=console, exc=exc) sys.exit(1) console.print("[green] Query executed successfully.") diff --git a/infrahub_sdk/exceptions/__init__.py b/infrahub_sdk/exceptions/__init__.py new file mode 100644 index 000000000..94b565290 --- /dev/null +++ b/infrahub_sdk/exceptions/__init__.py @@ -0,0 +1,88 @@ +"""The supported import path for every SDK exception. + +The class list below is written out rather than star-imported, so reading this file tells you what +the package exports. It has to be kept in step with `base.__all__` by hand; the tests in +`tests/unit/sdk/test_exceptions_public_names.py` fail if the two drift apart, or if a class defined +in `base` is left out of either. + +`__all__` is what `import *` hands a caller: the exception classes and nothing else. Without it the +wildcard also carries the `base` and `factory` submodule names, which are an artefact of the layout. +The raise-time factories and `code_names_the_failure` stay importable by name, since what an end user +catches is the classes. Nothing else is re-exported: a name here is a stability promise, so it earns +its place by having a caller rather than by being plausibly useful one day. +""" + +from .base import ( + ApiError, + AuthenticationError, + BranchNotFoundError, + CircularFragmentError, + DuplicateFragmentError, + Error, + FeatureNotSupportedError, + FileNotValidError, + FragmentFileNotFoundError, + FragmentNotFoundError, + GraphQLError, + GraphQLQueryError, + InfrahubCheckNotFoundError, + InfrahubTransformNotFoundError, + InvalidResponseError, + JsonDecodeError, + ModuleImportError, + NodeInvalidError, + NodeNotFoundError, + NodeNotSavedError, + ObjectValidationError, + QuerySyntaxError, + RateLimitError, + RepositoryFileNotFoundError, + ResourceNotDefinedError, + SchemaNotFoundError, + ServerNotReachableError, + ServerNotResponsiveError, + TimestampFormatError, + UninitializedError, + URLNotFoundError, + ValidationError, + VersionNotSupportedError, +) +from .base import code_names_the_failure as code_names_the_failure +from .factory import authentication_error_from_response as authentication_error_from_response +from .factory import graphql_error_from_response as graphql_error_from_response + +__all__ = [ + "ApiError", + "AuthenticationError", + "BranchNotFoundError", + "CircularFragmentError", + "DuplicateFragmentError", + "Error", + "FeatureNotSupportedError", + "FileNotValidError", + "FragmentFileNotFoundError", + "FragmentNotFoundError", + "GraphQLError", + "GraphQLQueryError", + "InfrahubCheckNotFoundError", + "InfrahubTransformNotFoundError", + "InvalidResponseError", + "JsonDecodeError", + "ModuleImportError", + "NodeInvalidError", + "NodeNotFoundError", + "NodeNotSavedError", + "ObjectValidationError", + "QuerySyntaxError", + "RateLimitError", + "RepositoryFileNotFoundError", + "ResourceNotDefinedError", + "SchemaNotFoundError", + "ServerNotReachableError", + "ServerNotResponsiveError", + "TimestampFormatError", + "URLNotFoundError", + "UninitializedError", + "ValidationError", + "VersionNotSupportedError", +] diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions/base.py similarity index 58% rename from infrahub_sdk/exceptions.py rename to infrahub_sdk/exceptions/base.py index 02111b9ac..34c220c81 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions/base.py @@ -1,7 +1,59 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, ClassVar, Protocol, TypeGuard + +from typing_extensions import Self + +# The code the server reports where its own catalogue has no entry for the failure. +UNDEFINED_ERROR_CODE = "UNDEFINED_ERROR" + +__all__ = [ + "ApiError", + "AuthenticationError", + "BranchNotFoundError", + "CircularFragmentError", + "DuplicateFragmentError", + "Error", + "FeatureNotSupportedError", + "FileNotValidError", + "FragmentFileNotFoundError", + "FragmentNotFoundError", + "GraphQLError", + "GraphQLQueryError", + "InfrahubCheckNotFoundError", + "InfrahubTransformNotFoundError", + "InvalidResponseError", + "JsonDecodeError", + "ModuleImportError", + "NodeInvalidError", + "NodeNotFoundError", + "NodeNotSavedError", + "ObjectValidationError", + "QuerySyntaxError", + "RateLimitError", + "RepositoryFileNotFoundError", + "ResourceNotDefinedError", + "SchemaNotFoundError", + "ServerNotReachableError", + "ServerNotResponsiveError", + "TimestampFormatError", + "URLNotFoundError", + "UninitializedError", + "ValidationError", + "VersionNotSupportedError", +] + + +def code_names_the_failure(code: str | None) -> TypeGuard[str]: + """Whether a catalogue code tells the reader something the server's message does not. + + The server codes every error it reports, falling back to `UNDEFINED_ERROR` wherever its own + catalogue has no entry, so "carries a code" is not the same question as "was described". That + fallback names nothing, and letting it displace the query text and the later errors the way a + described code does would lose detail and gain none. It stays readable on `exc.code` either way. + """ + return code is not None and code != UNDEFINED_ERROR_CODE class Error(Exception): @@ -57,12 +109,54 @@ def __init__(self, url: str, timeout: int | None = None, message: str | None = N super().__init__(self.message) -class GraphQLError(Error): - def __init__(self, errors: list[dict[str, Any]], query: str | None = None, variables: dict | None = None) -> None: +def as_error_list(errors: Any) -> list[dict[str, Any]]: + """The subset of a server's `errors` payload that matches the declared shape. + + Anything else keeps its text in the exception message, so the attribute can stay a list of dicts + and a caller iterating it never has to guard. + """ + if not isinstance(errors, list): + return [] + return [error for error in errors if isinstance(error, dict)] + + +class ApiError(Error): + """Base for a server-reported failure carrying the parsed response envelope. + + Not every failure the server reports is an ApiError. The ones raised from a status code alone, + such as URLNotFoundError and RateLimitError, have no envelope to carry and stay under Error. + + The defaults guarantee the attributes exist even on an instance no factory ever touched. + """ + + # The catalogue code this class represents, for the classes that adopted one. Optional so that a + # subclass of an adopted class can clear it rather than inherit a code it does not represent. + CODE: ClassVar[str | None] = None + + code: str | None = None + http_status: int | None = None + extensions: dict[str, Any] | None = None + errors: Sequence[dict[str, Any]] = () + + +class GraphQLError(ApiError): + query: str | None = None + variables: dict | None = None + + def __init__( + self, + errors: list[dict[str, Any]], + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: self.query = query self.variables = variables - self.errors = errors - self.message = f"An error occurred while executing the GraphQL Query {self.query}, {self.errors}" + # `is not None` rather than `or`: an empty message is a deliberate one, not a request for + # the default. + default = f"An error occurred while executing the GraphQL Query {query}, {errors}" + self.message = message if message is not None else default + self.errors = as_error_list(errors) super().__init__(self.message) @@ -76,18 +170,55 @@ def __init__(self, feature: str, required_version: str) -> None: super().__init__(self.message) -class BranchNotFoundError(Error): +class BranchNotFoundPayload(Protocol): + """The fields a server-reported BRANCH_NOT_FOUND carries.""" + + branch_name: str + + +class BranchNotFoundError(GraphQLError): + CODE: ClassVar[str | None] = "BRANCH_NOT_FOUND" + def __init__(self, identifier: str, message: str | None = None) -> None: self.identifier = identifier - self.message = message or f"Unable to find the branch '{identifier}' in the Database." - super().__init__(self.message) + super().__init__( + errors=[], + query=None, + variables=None, + message=message or f"Unable to find the branch '{identifier}' in the Database.", + ) + @classmethod + def from_payload(cls, payload: BranchNotFoundPayload) -> Self: + """Build the exception from the payload a server-reported failure carries. + + The mapping onto `identifier` is hand-written because the attribute predates the catalogue + and keeps its own name. + """ + return cls(identifier=payload.branch_name) + + +class SchemaNotFoundPayload(Protocol): + """The fields a server-reported SCHEMA_NOT_FOUND carries.""" + + kind: str + + +class SchemaNotFoundError(GraphQLError): + CODE: ClassVar[str | None] = "SCHEMA_NOT_FOUND" -class SchemaNotFoundError(Error): def __init__(self, identifier: str, message: str | None = None) -> None: self.identifier = identifier - self.message = message or f"Unable to find the schema '{identifier}'." - super().__init__(self.message) + super().__init__( + errors=[], + query=None, + variables=None, + message=message or f"Unable to find the schema '{identifier}'.", + ) + + @classmethod + def from_payload(cls, payload: SchemaNotFoundPayload) -> Self: + return cls(identifier=payload.kind) class ModuleImportError(Error): @@ -96,10 +227,21 @@ def __init__(self, message: str | None = None) -> None: super().__init__(self.message) -class NodeNotFoundError(Error): +class NodeNotFoundPayload(Protocol): + """The fields a server-reported NODE_NOT_FOUND carries.""" + + node_kind: str + identifier: str + + +class NodeNotFoundError(GraphQLError): + CODE: ClassVar[str | None] = "NODE_NOT_FOUND" + def __init__( self, - identifier: Mapping[str, list[str]], + # A plain string is admitted because the file handler names the missing file that way, and + # the identifier is only ever interpolated into the message. + identifier: Mapping[str, list[str]] | str, message: str = "Unable to find the node in the database.", branch_name: str | None = None, node_type: str | None = None, @@ -108,8 +250,7 @@ def __init__( self.identifier = identifier self.branch_name = branch_name - self.message = message - super().__init__(self.message) + super().__init__(errors=[], query=None, variables=None, message=message) def __str__(self) -> str: return f""" @@ -117,9 +258,21 @@ def __str__(self) -> str: {self.branch_name} | {self.node_type} | {self.identifier} """ + @classmethod + def from_payload(cls, payload: NodeNotFoundPayload) -> Self: + """`node_kind` lands on `node_type`, the name this class has always used for it.""" + return cls(identifier=payload.identifier, node_type=payload.node_kind) + class NodeInvalidError(NodeNotFoundError): - pass + """Raised when a node was found but is not of the kind that was asked for. + + That is not a lookup miss, so it claims no catalogue code and clears the one it would otherwise + inherit. `from_payload` is inherited and unused: nothing dispatches a payload to a class that + represents no code. + """ + + CODE: ClassVar[str | None] = None class NodeNotSavedError(Error): @@ -175,7 +328,7 @@ def __str__(self) -> str: return f"{'.'.join(str(p) for p in self.position)}: {self.message}" -class AuthenticationError(Error): +class AuthenticationError(ApiError): def __init__(self, message: str | None = None) -> None: self.message = message or "Authentication Error, unable to execute the query." super().__init__(self.message) diff --git a/infrahub_sdk/exceptions/factory.py b/infrahub_sdk/exceptions/factory.py new file mode 100644 index 000000000..ec12901df --- /dev/null +++ b/infrahub_sdk/exceptions/factory.py @@ -0,0 +1,292 @@ +"""Raise-time construction of an exception from a server response envelope. + +Every authentication and GraphQL raise site that has a server response behind it funnels through +here, so envelope parsing lives in one place rather than being repeated per call site. Both factories +are total: a shape the parser does not recognise degrades to the generic exception that call site +raises anyway, never to a decode error or a TypeError originating in the SDK. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from .base import ( + AuthenticationError, + BranchNotFoundError, + Error, + GraphQLError, + NodeNotFoundError, + SchemaNotFoundError, + as_error_list, + code_names_the_failure, +) + +if TYPE_CHECKING: + import httpx + +LOGGER = logging.getLogger("infrahub_sdk") + +# The names the package façade re-exports. `token_expired_in` is deliberately absent: it is public to +# the SDK, which imports it from this module, but it is not part of the published exception surface. +__all__ = ["authentication_error_from_response", "graphql_error_from_response"] + + +def _extensions_of(error: Any) -> dict[str, Any] | None: + if not isinstance(error, dict): + return None + extensions = error.get("extensions") + return extensions if isinstance(extensions, dict) else None + + +def _first_extensions(errors: Any) -> dict[str, Any] | None: + """Extensions of the governing error: the first one, whether or not it carries a code.""" + if not isinstance(errors, list) or not errors: + return None + return _extensions_of(errors[0]) + + +def _catalogue_code(extensions: dict[str, Any] | None) -> str | None: + """Only a string is a catalogue code. + + A pre-catalogue envelope puts an integer here mirroring the HTTP status, which must never be + mistaken for one. + """ + if extensions is None: + return None + code = extensions.get("code") + return code if isinstance(code, str) else None + + +def _declared_http_status(extensions: dict[str, Any] | None) -> int | None: + if extensions is None: + return None + status = extensions.get("http_status") + # bool is an int subclass, and a JSON `true` here is not a status. + return status if isinstance(status, int) and not isinstance(status, bool) else None + + +def _server_messages(errors: Any) -> list[str]: + if not isinstance(errors, list): + return [] + return [error["message"] for error in errors if isinstance(error, dict) and isinstance(error.get("message"), str)] + + +def _detail_message(body: dict[str, Any]) -> str | None: + """The REST API rejects a request with a bare `detail` string rather than an `errors` array.""" + detail = body.get("detail") + return detail if isinstance(detail, str) and detail else None + + +def _governing_message(errors: Any) -> str: + """The message of the error the code came from, which is the first one. + + Only this error's message may be named beside the code. Joining the whole list would file every + later error under a code that is not theirs; the complete list stays on `exc.errors`. + """ + if not isinstance(errors, list) or not errors: + return "" + first = errors[0] + message = first.get("message") if isinstance(first, dict) else None + return message if isinstance(message, str) else "" + + +def _named_by_code(code: str, message: str) -> str: + """The message for a described failure: the code and the server's message, and no query text. + + A described failure is one the server's catalogue has an entry for, so its own words are what the + reader needs; the query stays on the exception as an attribute. + + Callers apply this only when the governing error carried a message. A code with nothing beside it + is a worse headline than whatever that transport would otherwise have produced, and it is no loss + of information: the code is on `exc.code` either way. + """ + return f"{code}: {message}" + + +def _replace_message(exc: Error, message: str) -> None: + """Swap the message of an exception that is already built. + + `args` is reassigned alongside it because that, not `message`, is what `str()` reads on a class + that does not override `__str__`. + """ + exc.message = message + exc.args = (message,) + + +@dataclass +class _NodeNotFoundData: + """Stands in for the generated payload model, which the SDK does not carry yet. + + Plain rather than frozen, because the payload protocols declare settable attributes: the shape + the generated pydantic models will have. + """ + + node_kind: str + identifier: str + + +@dataclass +class _BranchNotFoundData: + branch_name: str + + +@dataclass +class _SchemaNotFoundData: + kind: str + + +def _payload_strings(data: Any, names: tuple[str, ...]) -> dict[str, str] | None: + """The named payload fields, when every one of them is present as a string. + + A payload that violates the catalogue's own contract yields `None` so the caller falls back to + the generic class, rather than a TypeError raised from inside the SDK while the caller is + already failing. + """ + if not isinstance(data, dict): + return None + values = {name: data.get(name) for name in names} + if any(not isinstance(value, str) for value in values.values()): + return None + return {name: value for name, value in values.items() if isinstance(value, str)} + + +def _adopted_exception(code: str | None, extensions: dict[str, Any] | None) -> GraphQLError | None: + """The class that adopted `code`, built from the payload the envelope carries. + + Only the three codes the SDK already ships a class for are resolved here, and each class maps the + payload itself through its own `from_payload`. Every other code raises the generic class for the + transport with the code readable on `exc.code`; turning the rest into classes of their own is + what the generated bindings buy. + + `None` means no class was resolved, whether because the code has none or because its payload did + not carry the fields the class needs. + """ + if code is None: + return None + data = extensions.get("data") if extensions is not None else None + + if code == NodeNotFoundError.CODE: + node = _payload_strings(data=data, names=("node_kind", "identifier")) + if node is not None: + payload = _NodeNotFoundData(node_kind=node["node_kind"], identifier=node["identifier"]) + return NodeNotFoundError.from_payload(payload=payload) + elif code == BranchNotFoundError.CODE: + branch = _payload_strings(data=data, names=("branch_name",)) + if branch is not None: + return BranchNotFoundError.from_payload(payload=_BranchNotFoundData(branch_name=branch["branch_name"])) + elif code == SchemaNotFoundError.CODE: + schema = _payload_strings(data=data, names=("kind",)) + if schema is not None: + return SchemaNotFoundError.from_payload(payload=_SchemaNotFoundData(kind=schema["kind"])) + else: + return None + + LOGGER.debug("Payload for %s does not carry the fields its class needs: %r", code, data) + return None + + +def _log_unresolved_code(extensions: dict[str, Any] | None, source: str) -> None: + if extensions is not None and _catalogue_code(extensions) is None: + LOGGER.debug("No catalogue code resolved from %s error extensions: %r", source, extensions.get("code")) + + +def token_expired_in(errors: Any) -> bool: + """Whether a decoded `errors` array reports the caller's token as expired. + + Lives here so the client's silent-refresh decision reads the envelope through the same parser as + everything else. Every error is scanned rather than only the first: a stale token is a fact about + the request, not about which error happens to lead. The legacy message check is the fallback for + servers that predate the catalogue; `query_groups` keeps the only other one, for the same reason. + """ + if not isinstance(errors, list): + return False + if any(_catalogue_code(_extensions_of(error)) == "TOKEN_EXPIRED" for error in errors): + return True + return "Expired Signature" in _server_messages(errors) + + +def graphql_error_from_response( + errors: Any, + query: str | None = None, + variables: dict | None = None, +) -> GraphQLError: + """Build the exception for an `errors` array returned on the GraphQL path. + + `errors` is raw decoded JSON, so it is read defensively. The complete list is retained + unreordered. A code the SDK has adopted a class for raises that class; every other failure raises + `GraphQLError`, and one the server's catalogue could not describe keeps the message this call site + has always produced, query text included. + """ + extensions = _first_extensions(errors) + code = _catalogue_code(extensions) + governing = _governing_message(errors) + message = _named_by_code(code, governing) if code_names_the_failure(code) and governing else None + + adopted = _adopted_exception(code=code, extensions=extensions) + if adopted is not None: + exc: GraphQLError = adopted + # An adopted class builds itself from its payload alone, so the envelope it came out of is + # attached here. A silent governing error leaves `message` None, and the class its own text. + if message is not None: + _replace_message(exc, message) + exc.query = query + exc.variables = variables + else: + exc = GraphQLError(errors=errors, query=query, variables=variables, message=message) + + exc.errors = as_error_list(errors) + exc.code = code + exc.http_status = _declared_http_status(extensions) + exc.extensions = extensions + _log_unresolved_code(extensions=extensions, source="GraphQL") + return exc + + +def authentication_error_from_response(response: httpx.Response) -> AuthenticationError: + """Build the exception for a response the SDK rejected as an authentication failure. + + Most call sites reach here on a 401 or 403, but the two that handle a failed token refresh reach + it on any other status, so the status itself is not assumed. The message joins the server's + messages with `" | "`, as every call site this replaces did, falling back to the REST API's bare + `detail` string and then to the plain status. A body the SDK cannot read as an envelope therefore + still names the status rather than surfacing a decode error in place of the authentication failure. + + A described failure names its code and the governing error's message instead, on the same rule as + the GraphQL path: only the error the code came from may be named beside it, and the complete list + stays on `exc.errors`. + """ + errors: Any = [] + message = f"HTTP {response.status_code}" + + # Read raw rather than through utils.decode_json, which raises JsonDecodeError on a body that is + # not JSON. Tolerating that body is the whole point here, so the wrapping would be built and then + # discarded. The catch stays broad because this runs while the caller is already failing: whatever + # a proxy answered with, the authentication failure is what has to reach them. + try: + body = response.json() + except Exception: + LOGGER.debug("Authentication response body could not be parsed; using the plain status", exc_info=True) + else: + if isinstance(body, dict): + errors = body.get("errors", []) + # Each fallback only applies when the one before it yielded nothing, so a body carrying + # no readable reason keeps the status rather than losing it to an empty join. + message = " | ".join(_server_messages(errors)) or _detail_message(body) or message + else: + LOGGER.debug("Authentication response body is not an envelope object; using the plain status: %r", body) + + extensions = _first_extensions(errors) + code = _catalogue_code(extensions) + governing = _governing_message(errors) + if code_names_the_failure(code) and governing: + message = _named_by_code(code, governing) + + exc = AuthenticationError(message) + exc.code = code + exc.http_status = _declared_http_status(extensions) + exc.extensions = extensions + exc.errors = as_error_list(errors) + _log_unresolved_code(extensions=extensions, source="authentication") + return exc diff --git a/infrahub_sdk/file_handler.py b/infrahub_sdk/file_handler.py index fa7cdede2..9946cb203 100644 --- a/infrahub_sdk/file_handler.py +++ b/infrahub_sdk/file_handler.py @@ -10,7 +10,12 @@ import anyio import httpx -from .exceptions import AuthenticationError, NodeNotFoundError, ServerNotReachableError, ServerNotResponsiveError +from .exceptions import ( + NodeNotFoundError, + ServerNotReachableError, + ServerNotResponsiveError, + authentication_error_from_response, +) if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync @@ -158,16 +163,18 @@ def handle_error_response(exc: httpx.HTTPStatusError) -> None: NodeNotFoundError: If the file/node is not found (404). httpx.HTTPStatusError: For other HTTP errors. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors", []) - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc if exc.response.status_code == 404: - response = exc.response.json() - detail = response.get("detail", "File not found") - raise NodeNotFoundError(node_type="FileObject", identifier=detail) from exc + # The body is whatever the server or an intermediary sent, so a shape carrying no detail + # must degrade to the generic reason rather than raising out of the error handler. + detail: str | None = None + with suppress(ValueError): + body = exc.response.json() + if isinstance(body, dict) and isinstance(body.get("detail"), str): + detail = body["detail"] + raise NodeNotFoundError(node_type="FileObject", identifier=detail or "File not found") from exc raise exc @staticmethod diff --git a/infrahub_sdk/graph_traversal/query.py b/infrahub_sdk/graph_traversal/query.py index 9813e4dd8..302b432b1 100644 --- a/infrahub_sdk/graph_traversal/query.py +++ b/infrahub_sdk/graph_traversal/query.py @@ -8,6 +8,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Any # Selection set shared by both queries. @@ -39,7 +40,7 @@ }}""" -def is_unknown_field_error(errors: list[dict[str, Any]], field_name: str) -> bool: +def is_unknown_field_error(errors: Sequence[dict[str, Any]], field_name: str) -> bool: """Return True if the GraphQL errors indicate ``field_name`` is an unknown query field. Used to detect a pre-1.10 server that lacks the traversal queries, so the SDK can diff --git a/infrahub_sdk/object_store.py b/infrahub_sdk/object_store.py index c770d016a..550716b90 100644 --- a/infrahub_sdk/object_store.py +++ b/infrahub_sdk/object_store.py @@ -4,7 +4,7 @@ import httpx -from .exceptions import AuthenticationError, ServerNotReachableError +from .exceptions import ServerNotReachableError, authentication_error_from_response if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync @@ -52,10 +52,7 @@ async def get(self, identifier: str, tracker: str | None = None) -> str: raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.text @@ -72,10 +69,7 @@ async def upload(self, content: str, tracker: str | None = None) -> dict[str, st raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.json() @@ -88,7 +82,7 @@ async def _get_file(self, url: str, identifier: str, tracker: str | None = None) AuthenticationError: If the server returns a 401 or 403 response. HTTPStatusError: For other non-2xx HTTP responses. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's headers = self.client._request_headers(tracker=tracker) try: @@ -99,10 +93,7 @@ async def _get_file(self, url: str, identifier: str, tracker: str | None = None) raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return self._validate_text_content(response=resp, identifier=identifier) @@ -141,10 +132,7 @@ def get(self, identifier: str, tracker: str | None = None) -> str: raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.text @@ -161,10 +149,7 @@ def upload(self, content: str, tracker: str | None = None) -> dict[str, str]: raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.json() @@ -177,7 +162,7 @@ def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> st AuthenticationError: If the server returns a 401 or 403 response. HTTPStatusError: For other non-2xx HTTP responses. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's headers = self.client._request_headers(tracker=tracker) try: @@ -188,10 +173,7 @@ def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> st raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return self._validate_text_content(resp, identifier) diff --git a/infrahub_sdk/query_groups.py b/infrahub_sdk/query_groups.py index 3fcde3461..a7aef5c0e 100644 --- a/infrahub_sdk/query_groups.py +++ b/infrahub_sdk/query_groups.py @@ -12,6 +12,19 @@ from .node import InfrahubNode, InfrahubNodeSync, RelatedNodeBase from .schema import MainSchemaTypesAPI +# What a server older than the error catalogue reports a missing node with. Goes when those do. +_LEGACY_NODE_NOT_FOUND = "Unable to find the node" + + +def _node_already_deleted(exc: GraphQLError) -> bool: + """Whether a failed delete is reporting a node that another node's cascade already removed. + + A server that codes its errors has already been handled by the caller's `NodeNotFoundError` + clause, so a coded failure reaching here is a different failure and must not be swallowed on the + strength of its wording. + """ + return exc.code is None and exc.message is not None and _LEGACY_NODE_NOT_FOUND in exc.message + class InfrahubGroupContextBase: """Base class for InfrahubGroupContext and InfrahubGroupContextSync.""" @@ -114,10 +127,11 @@ async def delete_unused(self) -> None: if member.id in self.unused_member_ids and member.typename: try: await self.client.delete(kind=member.typename, id=member.id) + except NodeNotFoundError: + # Already gone, cascade-deleted along with another node. + continue except GraphQLError as exc: - if not exc.message or "Unable to find the node" not in exc.message: - # If the node already has been deleted, skip the error as it would have been deleted - # by the cascade delete of another node + if not _node_already_deleted(exc): raise async def add_related_nodes(self, ids: list[str], update_group_context: bool | None = None) -> None: @@ -212,7 +226,14 @@ def delete_unused(self) -> None: if self.previous_members and self.unused_member_ids: for member in self.previous_members: if member.id in self.unused_member_ids and member.typename: - self.client.delete(kind=member.typename, id=member.id) + try: + self.client.delete(kind=member.typename, id=member.id) + except NodeNotFoundError: + # Already gone, cascade-deleted along with another node. + continue + except GraphQLError as exc: + if not _node_already_deleted(exc): + raise def add_related_nodes(self, ids: list[str], update_group_context: bool | None = None) -> None: """Add related Nodes IDs to the context. diff --git a/infrahub_sdk/transfer/importer/json.py b/infrahub_sdk/transfer/importer/json.py index 8b0c1e730..4e24f0590 100644 --- a/infrahub_sdk/transfer/importer/json.py +++ b/infrahub_sdk/transfer/importer/json.py @@ -194,9 +194,13 @@ async def execute_batches( if self.console: progress.stop() raise result - if isinstance(result, GraphQLError): + # Keyed on there being server errors to render rather than on the class: a + # lookup miss is a GraphQLError too, and carries its reason in its message. + if isinstance(result, GraphQLError) and result.errors: error_name = type(result).__name__ - error_msgs = [err["message"] for err in result.errors] + # `.get` rather than indexing, so an entry with no message cannot raise out + # of the branch whose whole job is to keep going. + error_msgs = [err.get("message", err) for err in result.errors] error_str = f"{error_name}: {error_msgs}" else: error_str = str(result) diff --git a/pyproject.toml b/pyproject.toml index 0abd9ef1c..74978bf82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,11 @@ filterwarnings = [ "ignore:Module already imported so cannot be rewritten", "ignore:Deprecated call to", ] +markers = [ + "crossversion: Parses an envelope from a server whose version differs from the SDK's", + "malformed: Parses an envelope that violates the shape the SDK expects", + "message: Pins an exception's message text, in either the catalogued or the uncatalogued direction", +] addopts = "-vs --strict-markers --cov-report term-missing --cov-report xml --dist loadscope" [tool.ty] @@ -248,6 +253,12 @@ include = ["tests/unit/sdk/conftest.py"] [tool.ty.overrides.rules] invalid-argument-type = "ignore" # 434 violations - test fixtures with dynamic types +[[tool.ty.overrides]] +include = ["tests/unit/ctl/test_utils.py", "tests/unit/sdk/test_exceptions_public_names.py"] + +[tool.ty.overrides.rules] +unresolved-attribute = "ignore" # 2 violations - a narrowed Console file and a module object + [[tool.ty.overrides]] include = [ "tests/unit/sdk/test_node.py", diff --git a/tasks.py b/tasks.py index b29b1d695..833c4d452 100644 --- a/tasks.py +++ b/tasks.py @@ -200,6 +200,7 @@ def get_modules_to_document() -> list[str]: # Packages explicitly ignored for API doc generation. packages_to_ignore = [ "ctl", + "exceptions", "graphql", "protocols_generator", "pytest_plugin", diff --git a/tests/fixtures/error_catalogue/README.md b/tests/fixtures/error_catalogue/README.md new file mode 100644 index 000000000..43c051e08 --- /dev/null +++ b/tests/fixtures/error_catalogue/README.md @@ -0,0 +1,27 @@ +# Error catalogue fixtures + +Two transports appear here: + +- **GraphQL** (`/graphql`). Data failures arrive as HTTP 200 with an `errors` array; authentication + failures arrive as a real 401 or 403. +- **REST** (`/api/...`), which carries no `data`. + +Cutting across both is what `extensions.code` holds. A catalogue-aware server sends a **string**, the +catalogue code. A pre-catalogue server sends an **integer** mirroring the HTTP status, which is not a +catalogue code and which the parser resolves to `code=None`. The integer shape is the norm on REST +(`rest_legacy_401.json`) but also reaches the GraphQL path from an older server +(`graphql_integer_code.json`), so the two axes are independent: do not read a `graphql_*` filename as a +promise that the code is a string. + +The `graphql_*`, `auth_*`, and `rest_*` files are captured 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 `malformed_*` files are the exception: they are constructed, because a correct server does not +produce them. They stand in for a proxy, a gateway, or a future server that answers in a shape the +SDK does not expect, and they exist to pin that such a shape degrades rather than raising. + +`public_names.json` is not an envelope. It is the committed snapshot of every exception class +importable from `infrahub_sdk.exceptions`, which pins that restructuring the module into a package +stays invisible from outside. It lists exception classes only; incidental typing imports that the +flat module used to re-export are not part of that surface and are no longer importable. diff --git a/tests/fixtures/error_catalogue/auth_token_expired.json b/tests/fixtures/error_catalogue/auth_token_expired.json new file mode 100644 index 000000000..62b4f7ec1 --- /dev/null +++ b/tests/fixtures/error_catalogue/auth_token_expired.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "message": "Expired Signature", + "extensions": { + "code": "TOKEN_EXPIRED", + "http_status": 401, + "data": {} + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/auth_two_messages.json b/tests/fixtures/error_catalogue/auth_two_messages.json new file mode 100644 index 000000000..3075fab09 --- /dev/null +++ b/tests/fixtures/error_catalogue/auth_two_messages.json @@ -0,0 +1,14 @@ +{ + "errors": [ + { + "message": "first problem", + "extensions": { + "code": "AUTHENTICATION_REQUIRED", + "http_status": 401 + } + }, + { + "message": "second problem" + } + ] +} diff --git a/tests/fixtures/error_catalogue/graphql_extra_payload_field.json b/tests/fixtures/error_catalogue/graphql_extra_payload_field.json new file mode 100644 index 000000000..f5889d7f2 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_extra_payload_field.json @@ -0,0 +1,19 @@ +{ + "errors": [ + { + "message": "Node of kind TestPerson already has name 'John'", + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ], + "introduced_in_a_later_version": "ignored" + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_integer_code.json b/tests/fixtures/error_catalogue/graphql_integer_code.json new file mode 100644 index 000000000..f8b1c1d7f --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_integer_code.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "extensions": { + "code": 500 + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_multiple_errors.json b/tests/fixtures/error_catalogue/graphql_multiple_errors.json new file mode 100644 index 000000000..0dfe69b5a --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_multiple_errors.json @@ -0,0 +1,36 @@ +{ + "errors": [ + { + "message": "first failure", + "extensions": { + "code": "SCHEMA_NOT_FOUND", + "http_status": 422, + "data": { + "kind": "TestWidget" + } + } + }, + { + "message": "second failure", + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ] + } + } + }, + { + "message": "third failure", + "extensions": { + "code": "UNDEFINED_ERROR", + "http_status": 500, + "data": {} + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_no_extensions.json b/tests/fixtures/error_catalogue/graphql_no_extensions.json new file mode 100644 index 000000000..c562b88a7 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_no_extensions.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "path": [ + "TestPersonCreate" + ] + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_permission_denied.json b/tests/fixtures/error_catalogue/graphql_permission_denied.json new file mode 100644 index 000000000..fbf64e440 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_permission_denied.json @@ -0,0 +1,19 @@ +{ + "errors": [ + { + "message": "You do not have the permission to update TestPerson", + "path": [ + "TestPersonUpdate" + ], + "extensions": { + "code": "PERMISSION_DENIED", + "http_status": 403, + "data": { + "action": "update", + "kind": "TestPerson" + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_uniqueness_violation.json b/tests/fixtures/error_catalogue/graphql_uniqueness_violation.json new file mode 100644 index 000000000..e794338e9 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_uniqueness_violation.json @@ -0,0 +1,21 @@ +{ + "errors": [ + { + "message": "Node of kind TestPerson already has name 'John'", + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ] + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_unknown_code.json b/tests/fixtures/error_catalogue/graphql_unknown_code.json new file mode 100644 index 000000000..871a02869 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_unknown_code.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "message": "A failure this SDK has never heard of", + "extensions": { + "code": "SOMETHING_WE_HAVE_NEVER_HEARD_OF", + "http_status": 418, + "data": { + "whatever": "the newer server sent" + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/malformed_code_as_object.json b/tests/fixtures/error_catalogue/malformed_code_as_object.json new file mode 100644 index 000000000..eb841978b --- /dev/null +++ b/tests/fixtures/error_catalogue/malformed_code_as_object.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "extensions": { + "code": { + "nested": "object" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/malformed_errors_as_string.json b/tests/fixtures/error_catalogue/malformed_errors_as_string.json new file mode 100644 index 000000000..d344c4016 --- /dev/null +++ b/tests/fixtures/error_catalogue/malformed_errors_as_string.json @@ -0,0 +1,3 @@ +{ + "errors": "a bare string where an array belongs" +} diff --git a/tests/fixtures/error_catalogue/malformed_extensions_as_list.json b/tests/fixtures/error_catalogue/malformed_extensions_as_list.json new file mode 100644 index 000000000..b0a70d9d5 --- /dev/null +++ b/tests/fixtures/error_catalogue/malformed_extensions_as_list.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "extensions": [ + "not", + "a", + "mapping" + ] + } + ] +} diff --git a/tests/fixtures/error_catalogue/public_names.json b/tests/fixtures/error_catalogue/public_names.json new file mode 100644 index 000000000..79a809034 --- /dev/null +++ b/tests/fixtures/error_catalogue/public_names.json @@ -0,0 +1,35 @@ +[ + "ApiError", + "AuthenticationError", + "BranchNotFoundError", + "CircularFragmentError", + "DuplicateFragmentError", + "Error", + "FeatureNotSupportedError", + "FileNotValidError", + "FragmentFileNotFoundError", + "FragmentNotFoundError", + "GraphQLError", + "GraphQLQueryError", + "InfrahubCheckNotFoundError", + "InfrahubTransformNotFoundError", + "InvalidResponseError", + "JsonDecodeError", + "ModuleImportError", + "NodeInvalidError", + "NodeNotFoundError", + "NodeNotSavedError", + "ObjectValidationError", + "QuerySyntaxError", + "RateLimitError", + "RepositoryFileNotFoundError", + "ResourceNotDefinedError", + "SchemaNotFoundError", + "ServerNotReachableError", + "ServerNotResponsiveError", + "TimestampFormatError", + "URLNotFoundError", + "UninitializedError", + "ValidationError", + "VersionNotSupportedError" +] diff --git a/tests/fixtures/error_catalogue/rest_legacy_401.json b/tests/fixtures/error_catalogue/rest_legacy_401.json new file mode 100644 index 000000000..53aeafce4 --- /dev/null +++ b/tests/fixtures/error_catalogue/rest_legacy_401.json @@ -0,0 +1,10 @@ +{ + "errors": [ + { + "message": "Authentication is required", + "extensions": { + "code": 401 + } + } + ] +} diff --git a/tests/unit/ctl/test_utils.py b/tests/unit/ctl/test_utils.py index f968072ba..b3e18fe5b 100644 --- a/tests/unit/ctl/test_utils.py +++ b/tests/unit/ctl/test_utils.py @@ -1,13 +1,63 @@ +import json +from io import StringIO +from typing import Any + +import httpx +import pytest import typer from rich.console import Console from typer.testing import CliRunner from infrahub_sdk.async_typer import AsyncTyper -from infrahub_sdk.ctl.utils import catch_exception +from infrahub_sdk.ctl.utils import ( + catch_exception, + handle_exception, + print_graphql_errors, + print_graphql_query_errors, +) +from infrahub_sdk.exceptions import ( + AuthenticationError, + BranchNotFoundError, + NodeNotFoundError, + SchemaNotFoundError, + authentication_error_from_response, + graphql_error_from_response, +) from tests.helpers.cli import remove_ansi_color +from tests.helpers.fixtures import read_fixture runner = CliRunner() +FIXTURE_SUBDIR = "error_catalogue" + + +def rendered(recorder: Console) -> str: + return remove_ansi_color(recorder.file.getvalue()) # type: ignore[attr-defined] + + +def recording_console() -> Console: + """A console that captures its output instead of writing it to the terminal.""" + return Console(record=True, file=StringIO(), width=200) + + +def load_envelope(name: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=name, fixture_subdir=FIXTURE_SUBDIR)) + + +def rendered_for(exc: Exception) -> str: + """Drive the ladder and return what the user would have seen. + + The exit code is asserted rather than matched on: `typer.Exit` carries no message for `match=` to + read, so the code is the only thing that confirms the ladder exited the way it was asked to. + """ + console = recording_console() + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(exc=exc, console=console, exit_code=1) + + assert exc_info.value.exit_code == 1 + return rendered(console) + def test_catch_exception_async_passes_through_typer_exit() -> None: console = Console() @@ -28,6 +78,231 @@ async def fail() -> None: assert "Error: 1" not in stdout +def test_print_graphql_errors_renders_the_server_errors() -> None: + console = recording_console() + + print_graphql_errors( + console=console, + errors=[{"message": "boom", "path": ["TestPerson"]}], + fallback="never reached", + ) + + output = rendered(console) + assert "boom" in output + assert "TestPerson" in output + assert "never reached" not in output + + +def test_print_graphql_errors_degrades_to_the_fallback() -> None: + """An envelope whose entries did not match the declared shape must not exit silently.""" + console = recording_console() + + print_graphql_errors(console=console, errors=[], fallback="the raw payload the server sent") + + assert "the raw payload the server sent" in rendered(console) + + +def test_print_graphql_query_errors_reports_every_error() -> None: + console = recording_console() + exc = graphql_error_from_response(errors=[{"message": "first", "locations": [{"line": 1}]}, {"message": "second"}]) + + print_graphql_query_errors(console=console, exc=exc) + + output = rendered(console) + assert "2 error(s) occurred" in output + assert "Message: first" in output + assert "'line': 1" in output + assert "Message: second" in output + + +def test_print_graphql_query_errors_keeps_the_branch_hint() -> None: + """The hint reads the server's message, since a bare string never survives into `errors`.""" + console = recording_console() + exc = graphql_error_from_response(errors=[{"message": "Branch: does-not-exist not found"}]) + + print_graphql_query_errors(console=console, exc=exc) + + assert "you can specify a different branch with --branch" in rendered(console) + + +def test_print_graphql_query_errors_degrades_to_the_exception_message() -> None: + console = recording_console() + exc = graphql_error_from_response(errors=["a bare string where an array belongs"], query="query { x }") + + print_graphql_query_errors(console=console, exc=exc) + + output = rendered(console) + assert "a bare string where an array belongs" in output + assert "0 error(s)" not in output + + +class TestHandleExceptionLadder: + """Asserts the rendering each class reaches, rather than reading the order off the source.""" + + def test_a_node_lookup_miss_is_not_rendered_as_a_graphql_failure(self) -> None: + """Re-rooting puts this class under `GraphQLError`, whose branch would print an error list.""" + output = rendered_for(NodeNotFoundError(identifier={"name": ["john"]}, node_type="TestPerson")) + + assert "Error: " in output + assert "TestPerson" in output + assert "error(s) occurred" not in output + + def test_a_schema_lookup_miss_keeps_its_own_rendering(self) -> None: + output = rendered_for(SchemaNotFoundError(identifier="TestPerson")) + + assert "Error: Unable to find the schema 'TestPerson'." in output + + def test_a_catalogued_graphql_failure_names_the_code_and_the_server_message(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + exc = graphql_error_from_response(errors=envelope["errors"], query="mutation { TestPersonCreate }") + + output = rendered_for(exc) + + assert "UNIQUENESS_VIOLATION" in output + assert "already has name" in output + assert "mutation { TestPersonCreate }" not in output + + def test_a_single_error_keeps_the_path_naming_the_failed_operation(self) -> None: + """The coded line has nowhere to put the path, so the error list still has to render.""" + envelope = load_envelope("graphql_uniqueness_violation.json") + exc = graphql_error_from_response(errors=envelope["errors"]) + + output = rendered_for(exc) + + assert "TestPersonCreate" in output, "the path tells the user which operation failed" + + def test_the_governing_message_is_not_printed_twice(self) -> None: + output = rendered_for( + graphql_error_from_response(errors=load_envelope("graphql_uniqueness_violation.json")["errors"]) + ) + + assert output.count("already has name") == 1 + + def test_a_catalogued_authentication_failure_is_named_rather_than_labelled(self) -> None: + """Labelling every 401 an authentication failure mislabels PERMISSION_DENIED, so the code wins.""" + request = httpx.Request("POST", "http://mock/graphql/main") + response = httpx.Response(status_code=401, json=load_envelope("auth_token_expired.json"), request=request) + exc = authentication_error_from_response(response=response) + + output = rendered_for(exc) + + assert "TOKEN_EXPIRED" in output + assert "Expired Signature" in output + assert "Authentication failure" not in output + assert "extensions" not in output, "an envelope with no path must not render as a raw dict" + + def test_an_uncatalogued_authentication_failure_keeps_todays_rendering(self) -> None: + """Only errors carrying a code take the new branch; everything else is unchanged.""" + output = rendered_for(AuthenticationError("no token supplied")) + + assert "Authentication failure: no token supplied" in output + + def test_a_branch_lookup_miss_is_not_rendered_as_a_graphql_failure(self) -> None: + """Re-rooted alongside the other two, so it owes the same rendering as the other two.""" + output = rendered_for(BranchNotFoundError(identifier="does-not-exist")) + + assert "Error: Unable to find the branch 'does-not-exist' in the Database." in output + assert "error(s) occurred" not in output + + def test_a_server_message_is_not_read_as_console_markup(self) -> None: + """Rich eats anything shaped like a tag, and a branch name in brackets is exactly that.""" + exc = graphql_error_from_response( + errors=[{"message": "Branch [main] does not exist", "extensions": {"code": "BRANCH_NOT_FOUND"}}] + ) + + output = rendered_for(exc) + + assert "Branch [main] does not exist" in output + + def test_every_server_error_is_rendered_not_just_the_governing_one(self) -> None: + """The code names the first error only, so the rest must still reach the user.""" + envelope = load_envelope("graphql_multiple_errors.json") + exc = graphql_error_from_response(errors=envelope["errors"]) + + output = rendered_for(exc) + + assert "SCHEMA_NOT_FOUND" in output + assert "first failure" in output + assert "second failure" in output + assert "third failure" in output + + def test_an_undescribed_error_without_a_path_keeps_its_raw_entry(self) -> None: + """A validation error carries `locations` and no `path`, and those coordinates are the point. + + Undescribed rendering is unchanged by this feature, so the whole entry still prints. + """ + exc = graphql_error_from_response( + errors=[{"message": "Cannot query field 'nope'.", "locations": [{"line": 1, "column": 9}]}], + query="query { nope }", + ) + + output = rendered_for(exc) + + assert "'line': 1" in output + assert "'column': 9" in output + + def test_a_validation_error_the_server_could_not_describe_keeps_its_coordinates(self) -> None: + """The shape a real server sends: coded `UNDEFINED_ERROR`, with `locations` and no `path`. + + Keying the coded branch on `code is not None` sent this down it and dropped the coordinates, + under a headline that named nothing. + """ + exc = graphql_error_from_response( + errors=[ + { + "message": "Cannot query field 'nope' on type 'Query'.", + "locations": [{"line": 1, "column": 9}], + "extensions": {"code": "UNDEFINED_ERROR", "http_status": 500, "data": {}}, + }, + { + "message": "Cannot query field 'alsonope' on type 'Query'.", + "locations": [{"line": 1, "column": 20}], + "extensions": {"code": "UNDEFINED_ERROR", "http_status": 500, "data": {}}, + }, + ], + query="query { nope alsonope }", + ) + + output = rendered_for(exc) + + assert "'line': 1" in output + assert "'column': 9" in output + assert "'column': 20" in output, "every error still renders, not just the governing one" + # The code still appears inside each raw entry; what it must not be is a headline of its own. + assert "UNDEFINED_ERROR" not in [line.strip() for line in output.splitlines()], ( + "a code that describes nothing must not be the line naming the failure" + ) + + def test_a_lookup_miss_message_is_not_read_as_console_markup(self) -> None: + """The branch name arrives in brackets, which rich reads as a style tag and deletes.""" + output = rendered_for(BranchNotFoundError(identifier="main", message="Not found on the server [main]")) + + assert "Not found on the server [main]" in output + + def test_an_authentication_message_is_not_read_as_console_markup(self) -> None: + output = rendered_for(AuthenticationError("rejected by [proxy-01]")) + + assert "rejected by [proxy-01]" in output + + def test_a_null_path_does_not_render_as_the_word_none(self) -> None: + exc = graphql_error_from_response( + errors=[{"message": "boom", "path": None, "extensions": {"code": "UNIQUENESS_VIOLATION"}}] + ) + + output = rendered_for(exc) + + assert "boom" in output + assert "None" not in output + + def test_an_uncatalogued_graphql_failure_still_renders_the_server_errors(self) -> None: + exc = graphql_error_from_response(errors=[{"message": "boom", "path": ["TestPerson"]}]) + + output = rendered_for(exc) + + assert "boom" in output + assert "TestPerson" in output + + def test_catch_exception_sync_passes_through_typer_exit() -> None: console = Console() app = typer.Typer() diff --git a/tests/unit/sdk/test_error_catalogue.py b/tests/unit/sdk/test_error_catalogue.py new file mode 100644 index 000000000..b52101a6f --- /dev/null +++ b/tests/unit/sdk/test_error_catalogue.py @@ -0,0 +1,486 @@ +"""The raise-time factories, exercised directly against captured response envelopes.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from io import BytesIO +from typing import TYPE_CHECKING, Any + +import httpx +import pytest + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync +from infrahub_sdk.exceptions import ( + AuthenticationError, + GraphQLError, + authentication_error_from_response, + graphql_error_from_response, +) +from tests.helpers.fixtures import read_fixture + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from tests.unit.sdk.conftest import BothClients + +FIXTURE_SUBDIR = "error_catalogue" + + +def load_envelope(name: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=name, fixture_subdir=FIXTURE_SUBDIR)) + + +def auth_response(envelope: dict[str, Any] | str, status_code: int = 401) -> httpx.Response: + """Build the httpx response the SDK would have observed for a rejected request. + + The request is attached because `decode_json` reports the URL it failed on, and would raise + looking for it rather than producing the JsonDecodeError the factory is meant to absorb. + """ + request = httpx.Request("POST", "http://mock/graphql/main") + if isinstance(envelope, str): + return httpx.Response(status_code=status_code, text=envelope, request=request) + return httpx.Response(status_code=status_code, json=envelope, request=request) + + +class TestGraphQLFactory: + def test_reads_code_and_status_off_the_first_error(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }", variables={"a": 1}) + + assert isinstance(exc, GraphQLError) + assert exc.code == "UNIQUENESS_VIOLATION" + assert exc.http_status == 422 + assert exc.extensions is not None + assert exc.extensions["data"] == {"node_kind": "TestPerson", "fields": ["name"]} + + def test_retains_the_complete_error_list_unreordered(self) -> None: + envelope = load_envelope("graphql_multiple_errors.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert [error["message"] for error in exc.errors] == ["first failure", "second failure", "third failure"] + assert exc.code == "SCHEMA_NOT_FOUND", "the first error governs, not the most specific one" + + def test_errors_is_a_sequence_of_dicts(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert isinstance(exc.errors, list) + assert all(isinstance(error, dict) for error in exc.errors) + + def test_query_and_variables_are_retained(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }", variables={"a": 1}) + + assert exc.query == "query { x }" + assert exc.variables == {"a": 1} + + +class TestAuthenticationFactory: + def test_reads_code_and_status_off_a_real_401(self) -> None: + response = auth_response(envelope=load_envelope("auth_token_expired.json")) + + exc = authentication_error_from_response(response=response) + + assert isinstance(exc, AuthenticationError) + assert exc.code == "TOKEN_EXPIRED" + assert exc.http_status == 401 + + def test_errors_is_a_sequence_of_dicts(self) -> None: + response = auth_response(envelope=load_envelope("auth_token_expired.json")) + + exc = authentication_error_from_response(response=response) + + assert isinstance(exc.errors, list) + assert all(isinstance(error, dict) for error in exc.errors) + + def test_only_the_governing_message_is_named_beside_the_code(self) -> None: + response = auth_response(envelope=load_envelope("auth_two_messages.json")) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "AUTHENTICATION_REQUIRED: first problem", ( + "joining the rest would file them under a code that is not theirs, the same rule the GraphQL path follows" + ) + assert [error["message"] for error in exc.errors] == ["first problem", "second problem"], ( + "naming one message must not discard the rest, which stay on the exception" + ) + + def test_an_undescribed_code_keeps_the_joined_messages(self) -> None: + """`UNDEFINED_ERROR` names nothing, so it must not displace the reasons the server gave.""" + response = auth_response( + envelope={ + "errors": [ + {"message": "first problem", "extensions": {"code": "UNDEFINED_ERROR"}}, + {"message": "second problem"}, + ] + } + ) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "first problem | second problem" + assert exc.code == "UNDEFINED_ERROR", "the code is still readable, it is just not the headline" + + def test_rest_integer_code_is_never_a_catalogue_code(self) -> None: + response = auth_response(envelope=load_envelope("rest_legacy_401.json")) + + exc = authentication_error_from_response(response=response) + + assert exc.code is None + assert exc.extensions == {"code": 401} + + def test_empty_error_list_falls_back_to_the_status(self) -> None: + response = auth_response(envelope={"errors": []}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "HTTP 401", "an empty join must not discard the status" + + def test_a_rest_detail_string_is_used_when_there_is_no_error_array(self) -> None: + """The REST API rejects an unauthenticated request with a bare `detail` and no envelope.""" + response = auth_response(envelope={"detail": "Not authenticated"}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "Not authenticated" + assert exc.errors == [] + + def test_a_server_message_wins_over_a_detail_string(self) -> None: + response = auth_response(envelope={"detail": "Not authenticated", "errors": [{"message": "real reason"}]}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "real reason" + + def test_an_object_body_carrying_nothing_readable_falls_back_to_the_status(self) -> None: + response = auth_response(envelope={"unexpected": "shape"}, status_code=403) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "HTTP 403" + + def test_a_403_is_parsed_the_same_way_as_a_401(self) -> None: + response = auth_response(envelope=load_envelope("auth_two_messages.json"), status_code=403) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "AUTHENTICATION_REQUIRED: first problem" + + def test_a_non_auth_status_falls_back_to_that_status(self) -> None: + """A failed token refresh reaches this factory on whatever status the server sent.""" + response = auth_response(envelope="502 Bad Gateway", status_code=502) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "HTTP 502" + + +class TestErrorsAlwaysASequenceOfDicts: + """`ApiError.errors` is declared `Sequence[dict[str, Any]]`, so nothing else may reach it.""" + + @pytest.mark.malformed + def test_graphql_non_list_errors_becomes_an_empty_list(self) -> None: + exc = graphql_error_from_response(errors="a bare string where an array belongs") + + assert exc.errors == [] + + @pytest.mark.malformed + def test_auth_non_list_errors_becomes_an_empty_list(self) -> None: + response = auth_response(envelope={"errors": "a bare string where an array belongs"}) + + exc = authentication_error_from_response(response=response) + + assert exc.errors == [] + + @pytest.mark.malformed + def test_auth_errors_as_an_object_becomes_an_empty_list(self) -> None: + response = auth_response(envelope={"errors": {"message": "an object where an array belongs"}}) + + exc = authentication_error_from_response(response=response) + + assert exc.errors == [] + + @pytest.mark.malformed + def test_non_dict_entries_are_dropped(self) -> None: + exc = graphql_error_from_response(errors=[{"message": "real"}, "stray", None, 7]) + + assert exc.errors == [{"message": "real"}] + + +class TestGuardsAgainstShapesThatLookRight: + @pytest.mark.malformed + def test_a_boolean_is_not_an_http_status(self) -> None: + """`bool` is an `int` subclass, so a JSON `true` would otherwise pass the status check.""" + exc = graphql_error_from_response(errors=[{"extensions": {"code": "X", "http_status": True}}]) + + assert exc.http_status is None + + @pytest.mark.malformed + def test_extensions_that_are_not_an_object_yield_no_extensions(self) -> None: + envelope = load_envelope("malformed_extensions_as_list.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.extensions is None, "a list of extensions is not an extensions object" + assert exc.code is None + + @pytest.mark.malformed + def test_a_non_string_message_is_left_out_of_the_join(self) -> None: + response = auth_response(envelope={"errors": [{"message": "real"}, {"message": None}, {"message": 7}]}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "real" + + @pytest.mark.malformed + def test_a_leading_non_dict_error_does_not_govern(self) -> None: + exc = graphql_error_from_response(errors=["stray", {"extensions": {"code": "UNIQUENESS_VIOLATION"}}]) + + assert exc.code is None, "the first entry governs even when it carries nothing readable" + + +@dataclass +class CrossVersionCase: + name: str + fixture: str + expected_code: str | None + expected_http_status: int | None = None + + +CROSS_VERSION_CASES = [ + CrossVersionCase( + name="unknown-code-from-a-newer-server", + fixture="graphql_unknown_code.json", + expected_code="SOMETHING_WE_HAVE_NEVER_HEARD_OF", + expected_http_status=418, + ), + CrossVersionCase( + name="known-code-with-an-extra-payload-field", + fixture="graphql_extra_payload_field.json", + expected_code="UNIQUENESS_VIOLATION", + expected_http_status=422, + ), + CrossVersionCase( + name="error-carrying-no-extensions", + fixture="graphql_no_extensions.json", + expected_code=None, + ), + CrossVersionCase( + name="pre-catalogue-integer-code", + fixture="graphql_integer_code.json", + expected_code=None, + ), +] + + +@pytest.mark.crossversion +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in CROSS_VERSION_CASES]) +def test_cross_version_envelope_parses_onto_the_generic_class(case: CrossVersionCase) -> None: + """Any SDK version talks to any server version, and parsing never raises.""" + envelope = load_envelope(case.fixture) + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert type(exc) is GraphQLError + assert exc.code == case.expected_code + assert exc.http_status == case.expected_http_status + + +@pytest.mark.crossversion +def test_an_unknown_payload_field_is_passed_through_untouched() -> None: + """Forward compatibility is only real if the payload we cannot interpret still reaches the caller.""" + envelope = load_envelope("graphql_extra_payload_field.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.extensions is not None + assert exc.extensions["data"]["introduced_in_a_later_version"] == "ignored" + + +@dataclass +class MalformedCase: + name: str + fixture: str + + +MALFORMED_CASES = [ + MalformedCase(name="errors-as-a-bare-string", fixture="malformed_errors_as_string.json"), + MalformedCase(name="extensions-as-a-list", fixture="malformed_extensions_as_list.json"), + MalformedCase(name="code-as-a-nested-object", fixture="malformed_code_as_object.json"), +] + + +@pytest.mark.malformed +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in MALFORMED_CASES]) +def test_malformed_envelope_degrades_to_the_generic_exception(case: MalformedCase) -> None: + """A shape the parser does not recognise must not become an SDK TypeError.""" + envelope = load_envelope(case.fixture) + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert type(exc) is GraphQLError + assert exc.code is None + + +@pytest.mark.malformed +def test_malformed_envelope_keeps_the_payload_in_the_message() -> None: + """The server's failure must survive verbatim, not be replaced by the SDK's own.""" + envelope = load_envelope("malformed_errors_as_string.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert exc.message == f"An error occurred while executing the GraphQL Query query {{ x }}, {envelope['errors']}" + + +@dataclass +class NonJsonBodyCase: + name: str + text: str + + +NON_JSON_BODY_CASES = [ + NonJsonBodyCase(name="html-proxy-page", text="502 Bad Gateway"), + NonJsonBodyCase(name="empty-body", text=""), +] + + +@pytest.mark.malformed +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in NON_JSON_BODY_CASES]) +def test_authentication_factory_tolerates_a_non_json_body(case: NonJsonBodyCase) -> None: + response = auth_response(envelope=case.text) + + exc = authentication_error_from_response(response=response) + + assert isinstance(exc, AuthenticationError) + assert exc.message == "HTTP 401" + assert exc.code is None + assert exc.errors == [] + + +class TestRaisedThroughTheClient: + """The factories are only worth having if the envelope survives all the way to the caller.""" + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_graphql_error_carries_the_envelope( + self, client_type: str, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + httpx_mock.add_response(method="POST", status_code=200, json={"data": None, "errors": envelope["errors"]}) + client = getattr(clients, client_type) + query = "query { TestPerson { edges { node { name { value }}}}}" + + with pytest.raises(GraphQLError, match="already has name") as exc_info: + if client_type == "standard": + await client.execute_graphql(query=query) + else: + client.execute_graphql(query=query) + + assert exc_info.value.code == "UNIQUENESS_VIOLATION" + assert exc_info.value.http_status == 422 + assert exc_info.value.query == query + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_authentication_error_carries_the_envelope( + self, client_type: str, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response(method="POST", status_code=403, json=load_envelope("auth_two_messages.json")) + client = getattr(clients, client_type) + + with pytest.raises(AuthenticationError, match="AUTHENTICATION_REQUIRED: first problem") as exc_info: + if client_type == "standard": + await client.execute_graphql(query="query { TestPerson { edges { node { id }}}}") + else: + client.execute_graphql(query="query { TestPerson { edges { node { id }}}}") + + assert exc_info.value.code == "AUTHENTICATION_REQUIRED" + assert exc_info.value.http_status == 401, ( + "http_status is the status the envelope declares, not the one the transport observed" + ) + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + @pytest.mark.parametrize("status_code", [401, 403]) + async def test_a_rejected_file_upload_carries_the_envelope( + self, client_type: str, status_code: int, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + """The multipart path observes 401 and 403 too, so it owes the same exception as every other.""" + httpx_mock.add_response( + method="POST", + status_code=status_code, + json={"errors": [{"message": "no upload rights", "extensions": {"code": "PERMISSION_DENIED"}}]}, + ) + client = getattr(clients, client_type) + query = "mutation ($file: Upload!) { CoreFileUpload(data: {file: $file}) { ok }}" + + with pytest.raises(AuthenticationError, match="no upload rights") as exc_info: + if client_type == "standard": + await client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + else: + client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + + assert exc_info.value.code == "PERMISSION_DENIED" + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_a_file_upload_rejected_for_another_reason_still_raises_the_status_error( + self, client_type: str, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + """Only 401 and 403 are converted; every other status keeps reaching the caller as it did.""" + httpx_mock.add_response(method="POST", status_code=500, json={"errors": [{"message": "boom"}]}) + client = getattr(clients, client_type) + query = "mutation ($file: Upload!) { CoreFileUpload(data: {file: $file}) { ok }}" + + with pytest.raises(httpx.HTTPStatusError): + if client_type == "standard": + await client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + else: + client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_a_failed_token_refresh_surfaces_as_an_authentication_error( + self, client_type: str, httpx_mock: HTTPXMock + ) -> None: + """A refresh that fails on any status other than 401 still reaches the caller as an auth failure.""" + httpx_mock.add_response( + method="POST", + url="http://mock/api/auth/refresh", + status_code=503, + json={"errors": [{"message": "the auth backend is down"}]}, + ) + config = Config(address="http://mock", username="admin", password="password", insert_tracker=True) + client: InfrahubClient | InfrahubClientSync = ( + InfrahubClient(config=config) if client_type == "standard" else InfrahubClientSync(config=config) + ) + client.refresh_token = "refresh-token" + + with pytest.raises(AuthenticationError, match="the auth backend is down") as exc_info: + if isinstance(client, InfrahubClient): + await client.login(refresh=True) + else: + client.login(refresh=True) + + assert exc_info.value.http_status is None, "the server sent no catalogue status on this path" + + +@pytest.mark.crossversion +def test_cross_version_fallback_is_logged_at_debug_level(caplog: pytest.LogCaptureFixture) -> None: + """A fallback must be diagnosable in the field, not only in a test.""" + envelope = load_envelope("graphql_integer_code.json") + + with caplog.at_level("DEBUG", logger="infrahub_sdk"): + graphql_error_from_response(errors=envelope["errors"]) + + assert any("No catalogue code resolved" in record.getMessage() for record in caplog.records) + + +@pytest.mark.malformed +def test_unparseable_authentication_body_is_logged_at_debug_level(caplog: pytest.LogCaptureFixture) -> None: + response = auth_response(envelope="502") + + with caplog.at_level("DEBUG", logger="infrahub_sdk"): + authentication_error_from_response(response=response) + + assert any("could not be parsed" in record.getMessage() for record in caplog.records) diff --git a/tests/unit/sdk/test_exceptions.py b/tests/unit/sdk/test_exceptions.py new file mode 100644 index 000000000..2610ea4f2 --- /dev/null +++ b/tests/unit/sdk/test_exceptions.py @@ -0,0 +1,478 @@ +"""The shape of the hierarchy, and what each `except` clause catches once it is a tree.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import httpx +import pytest + +from infrahub_sdk import exceptions +from infrahub_sdk.exceptions import ( + ApiError, + AuthenticationError, + BranchNotFoundError, + Error, + GraphQLError, + NodeInvalidError, + NodeNotFoundError, + SchemaNotFoundError, + authentication_error_from_response, + graphql_error_from_response, +) +from tests.helpers.fixtures import read_fixture + +FIXTURE_SUBDIR = "error_catalogue" + + +def load_envelope(name: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=name, fixture_subdir=FIXTURE_SUBDIR)) + + +def auth_response(envelope: dict[str, Any], status_code: int = 401) -> httpx.Response: + """The httpx response the SDK would have observed for a rejected request.""" + request = httpx.Request("POST", "http://mock/graphql/main") + return httpx.Response(status_code=status_code, json=envelope, request=request) + + +@dataclass +class NodeNotFoundData: + """Stands in for the generated payload model, which the SDK does not carry yet.""" + + node_kind: str + identifier: str + + +@dataclass +class BranchNotFoundData: + branch_name: str + + +@dataclass +class SchemaNotFoundData: + kind: str + + +@dataclass +class UnifiedClassCase: + """One of the three classes re-rooted under `GraphQLError`.""" + + name: str + exception_class: type[GraphQLError] + code: str + build: Callable[[], GraphQLError] + expected_message: str + + +UNIFIED_CLASS_CASES = [ + UnifiedClassCase( + name="node", + exception_class=NodeNotFoundError, + code="NODE_NOT_FOUND", + build=lambda: NodeNotFoundError(identifier={"name": ["john"]}), + expected_message="Unable to find the node in the database.", + ), + UnifiedClassCase( + name="branch", + exception_class=BranchNotFoundError, + code="BRANCH_NOT_FOUND", + build=lambda: BranchNotFoundError(identifier="dev"), + expected_message="Unable to find the branch 'dev' in the Database.", + ), + UnifiedClassCase( + name="schema", + exception_class=SchemaNotFoundError, + code="SCHEMA_NOT_FOUND", + build=lambda: SchemaNotFoundError(identifier="TestPerson"), + expected_message="Unable to find the schema 'TestPerson'.", + ), +] + +UNIFIED_CLASS_PARAMS = [pytest.param(case, id=case.name) for case in UNIFIED_CLASS_CASES] + + +def exported_classes() -> list[type[BaseException]]: + return [ + value + for name, value in vars(exceptions).items() + if not name.startswith("_") and isinstance(value, type) and issubclass(value, BaseException) + ] + + +class TestHierarchyIsATree: + def test_no_class_has_more_than_one_parent(self) -> None: + """Single inheritance is what keeps `except` ordering predictable for every consumer.""" + multiply_inherited = sorted(cls.__name__ for cls in exported_classes() if len(cls.__bases__) > 1) + + assert multiply_inherited == [] + + def test_graphql_and_authentication_are_siblings_under_api_error(self) -> None: + assert GraphQLError.__bases__ == (ApiError,) + assert AuthenticationError.__bases__ == (ApiError,) + assert not issubclass(GraphQLError, AuthenticationError) + assert not issubclass(AuthenticationError, GraphQLError) + + def test_api_error_is_an_infrahub_error(self) -> None: + assert issubclass(ApiError, Error) + + def test_node_invalid_error_inherits_the_re_rooting(self) -> None: + """`NodeInvalidError` is re-rooted through its parent rather than by its own declaration.""" + exc = NodeInvalidError(identifier={"name": ["john"]}, node_type="TestPerson") + + assert isinstance(exc, GraphQLError) + assert isinstance(exc, NodeNotFoundError) + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_the_unified_classes_sit_under_graphql_error(self, case: UnifiedClassCase) -> None: + assert case.exception_class.__bases__ == (GraphQLError,) + + def test_one_clause_catches_both_transports(self) -> None: + """`except ApiError` is the clause that spans a failure however the server reported it.""" + envelope = load_envelope("graphql_uniqueness_violation.json") + graphql_failure = graphql_error_from_response(errors=envelope["errors"]) + auth_failure = authentication_error_from_response( + response=auth_response(envelope=load_envelope("auth_token_expired.json")) + ) + + assert isinstance(graphql_failure, ApiError) + assert isinstance(auth_failure, ApiError) + + +class TestCatchingAcrossTransports: + def test_a_real_401_is_caught_as_an_authentication_error(self) -> None: + response = auth_response(envelope=load_envelope("auth_token_expired.json")) + exc = authentication_error_from_response(response=response) + + with pytest.raises(AuthenticationError, match="TOKEN_EXPIRED") as exc_info: + raise exc + + assert exc_info.value.code == "TOKEN_EXPIRED" + + def test_the_same_code_inside_a_200_body_is_caught_as_a_graphql_error(self) -> None: + """A resolver-raised auth failure arrives on the GraphQL path and keeps that class.""" + envelope = load_envelope("graphql_permission_denied.json") + exc = graphql_error_from_response(errors=envelope["errors"], query="mutation { x }") + + with pytest.raises(GraphQLError, match="PERMISSION_DENIED") as exc_info: + raise exc + + assert exc_info.value.code == "PERMISSION_DENIED" + assert not isinstance(exc_info.value, AuthenticationError), ( + "the transport the SDK observed decides the class, never the code's declared status" + ) + + +class TestTheEnvelopeIsReadableOnAClientSideRaise: + def test_envelope_attributes_exist_on_a_client_side_node_not_found(self) -> None: + """Re-rooting is only safe if `except GraphQLError` can read the envelope unconditionally.""" + exc = NodeNotFoundError(identifier={"name": ["john"]}, node_type="TestPerson") + + assert exc.errors == [] + assert exc.query is None + assert exc.variables is None + assert exc.code is None, "no server reported this, so there is no catalogue code" + + def test_errors_is_a_list_rather_than_the_base_default(self) -> None: + """The base's tuple is a can't-crash floor; a `GraphQLError` documents `errors` as a list.""" + exc = NodeNotFoundError(identifier="missing-file.py") + + assert isinstance(exc.errors, list) + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_every_unified_class_sets_its_envelope_through_the_constructor(self, case: UnifiedClassCase) -> None: + exc = case.build() + + assert exc.errors == [] + assert isinstance(exc.errors, list) + + +class TestTheBroadening: + def test_except_graphql_error_now_catches_a_client_side_lookup_miss(self) -> None: + # An accepted behaviour change, asserted rather than worked around so that reverting the + # re-rooting fails here rather than silently narrowing what the clause catches. + with pytest.raises(GraphQLError, match="Unable to find the node in the database"): + raise NodeNotFoundError(identifier={"name": ["john"]}, node_type="TestPerson") + + def test_the_existing_clauses_still_catch_what_they_caught(self) -> None: + with pytest.raises(NodeNotFoundError, match="Unable to find the node in the database"): + raise NodeNotFoundError(identifier={"name": ["john"]}) + with pytest.raises(BranchNotFoundError, match="Unable to find the branch"): + raise BranchNotFoundError(identifier="does-not-exist") + with pytest.raises(SchemaNotFoundError, match="Unable to find the schema"): + raise SchemaNotFoundError(identifier="TestPerson") + + +class TestAdoptionMarkers: + """The adoption markers the generator reads. + + Infrahub derives its bindings by parsing `base.py`, so these strings are load-bearing outside + this repository and have no reader inside it. Only a test keeps them honest. + """ + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_the_adopted_code_is_declared(self, case: UnifiedClassCase) -> None: + assert case.code == case.exception_class.CODE + + def test_declaring_a_code_does_not_make_it_a_raised_code(self) -> None: + """`CODE` says which code the class represents; `code` says which one the server reported.""" + assert NodeNotFoundError(identifier="anything").code is None + + def test_a_subclass_of_an_adopted_class_claims_no_code(self) -> None: + """A wrong-kind result is not a lookup miss, and must not be labelled as one. + + `NodeInvalidError` would otherwise inherit `NODE_NOT_FOUND`, so anything reading the marker + would file it under a code that describes a different failure. + """ + assert NodeInvalidError.CODE is None + assert NodeNotFoundError.CODE == "NODE_NOT_FOUND", "clearing it on the subclass leaves the parent alone" + + +class TestPromotingAServerPayload: + def test_node_not_found_maps_node_kind_onto_node_type(self) -> None: + exc = NodeNotFoundError.from_payload(payload=NodeNotFoundData(node_kind="TestPerson", identifier="john")) + + assert exc.node_type == "TestPerson" + assert exc.identifier == "john" + + def test_branch_not_found_maps_branch_name_onto_identifier(self) -> None: + exc = BranchNotFoundError.from_payload(payload=BranchNotFoundData(branch_name="does-not-exist")) + + assert exc.identifier == "does-not-exist" + + def test_schema_not_found_maps_kind_onto_identifier(self) -> None: + exc = SchemaNotFoundError.from_payload(payload=SchemaNotFoundData(kind="TestPerson")) + + assert exc.identifier == "TestPerson" + + +class TestAnAdoptedCodeRaisesItsOwnClass: + """A server-reported failure under an adopted code reaches the class that adopted it. + + Without this the codes are adopted in name only: `from_payload` has no caller, and a consumer has + to read words out of a message to tell a lookup miss from any other GraphQL failure. + """ + + def test_a_server_reported_node_not_found_raises_node_not_found_error(self) -> None: + errors = [ + { + "message": "Unable to find the node TestPerson/john in the database", + "extensions": { + "code": "NODE_NOT_FOUND", + "http_status": 404, + "data": {"node_kind": "TestPerson", "identifier": "john"}, + }, + } + ] + + exc = graphql_error_from_response(errors=errors, query="mutation { TestPersonDelete }") + + assert isinstance(exc, NodeNotFoundError) + assert exc.node_type == "TestPerson" + assert exc.identifier == "john" + assert exc.code == "NODE_NOT_FOUND" + + def test_the_dispatched_class_still_carries_the_whole_envelope(self) -> None: + """The class builds itself from its payload alone, so the envelope has to be put on it.""" + errors = [ + { + "message": "Unable to find the branch 'dev'", + "extensions": { + "code": "BRANCH_NOT_FOUND", + "http_status": 404, + "data": {"branch_name": "dev"}, + }, + }, + {"message": "and a second problem"}, + ] + + exc = graphql_error_from_response(errors=errors, query="query { x }", variables={"a": 1}) + + assert isinstance(exc, BranchNotFoundError) + assert exc.identifier == "dev" + assert exc.query == "query { x }" + assert exc.variables == {"a": 1} + assert [error["message"] for error in exc.errors] == ["Unable to find the branch 'dev'", "and a second problem"] + + def test_the_dispatched_class_names_its_code_and_the_server_message(self) -> None: + errors = [ + { + "message": "No schema TestWidget on branch main", + "extensions": {"code": "SCHEMA_NOT_FOUND", "http_status": 422, "data": {"kind": "TestWidget"}}, + } + ] + + exc = graphql_error_from_response(errors=errors) + + assert exc.message == "SCHEMA_NOT_FOUND: No schema TestWidget on branch main" + assert str(exc) == "SCHEMA_NOT_FOUND: No schema TestWidget on branch main", ( + "reassigning the message must reassign `args`, which is what `str()` reads" + ) + + @pytest.mark.malformed + def test_a_payload_missing_the_fields_its_class_needs_falls_back(self) -> None: + """The generic class still reaches the caller, rather than a TypeError raised inside the SDK.""" + errors = [ + { + "message": "Unable to find the node", + "extensions": {"code": "NODE_NOT_FOUND", "data": {"node_kind": "TestPerson"}}, + } + ] + + exc = graphql_error_from_response(errors=errors) + + assert not isinstance(exc, NodeNotFoundError) + assert exc.code == "NODE_NOT_FOUND", "the code stays readable even where its payload did not" + + @pytest.mark.crossversion + def test_a_pre_catalogue_node_not_found_still_raises_the_generic_class(self) -> None: + """A server that sends no `extensions` has no payload to build the class from.""" + exc = graphql_error_from_response(errors=[{"message": "Unable to find the node in the database."}]) + + assert not isinstance(exc, NodeNotFoundError) + assert exc.code is None + + +@pytest.mark.message +class TestMessages: + def test_a_catalogued_failure_names_the_code_and_the_server_message(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="mutation { TestPersonCreate }") + + assert exc.message == "UNIQUENESS_VIOLATION: Node of kind TestPerson already has name 'John'" + + def test_a_catalogued_failure_carries_no_query_text(self) -> None: + query = "mutation { TestPersonCreate(data: {name: {value: 'John'}}) { ok }}" + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query=query) + + assert query not in str(exc) + assert "An error occurred while executing the GraphQL Query" not in str(exc) + + def test_an_uncatalogued_failure_keeps_todays_message_byte_for_byte(self) -> None: + errors = [{"message": "Source node not found: a"}] + query = "query { InfrahubPathTraversal }" + + exc = graphql_error_from_response(errors=errors, query=query) + + assert exc.message == f"An error occurred while executing the GraphQL Query {query}, {errors}" + + def test_a_failure_the_server_could_not_describe_keeps_todays_message_byte_for_byte(self) -> None: + """A current server codes every error, so `UNDEFINED_ERROR` is what most failures arrive as. + + Letting it name the failure would apply the short message universally, dropping the query + text and every error after the first from what reaches a log or a traceback. + """ + errors = [ + { + "message": "Cannot query field 'nope' on type 'Query'.", + "extensions": {"code": "UNDEFINED_ERROR", "http_status": 500, "data": {}}, + }, + {"message": "Cannot query field 'alsonope' on type 'Query'."}, + ] + query = "query { nope alsonope }" + + exc = graphql_error_from_response(errors=errors, query=query) + + assert exc.message == f"An error occurred while executing the GraphQL Query {query}, {errors}" + assert exc.code == "UNDEFINED_ERROR", "the code is still readable, it is just not the headline" + + def test_a_silent_governing_error_does_not_reduce_the_message_to_a_bare_code(self) -> None: + """A code with nothing beside it is a worse headline than what the call site would produce. + + The governing error carries the code but no words, so the reasons live in the later errors; + naming the code alone would drop them from the message entirely. + """ + errors = [ + {"extensions": {"code": "UNIQUENESS_VIOLATION", "http_status": 422}}, + {"message": "the reason the caller actually needs"}, + ] + query = "mutation { TestPersonCreate }" + + exc = graphql_error_from_response(errors=errors, query=query) + + assert exc.message == f"An error occurred while executing the GraphQL Query {query}, {errors}" + assert exc.code == "UNIQUENESS_VIOLATION" + + def test_a_silent_governing_error_leaves_an_adopted_class_its_own_sentence(self) -> None: + errors = [ + { + "extensions": { + "code": "NODE_NOT_FOUND", + "data": {"node_kind": "TestPerson", "identifier": "john"}, + } + } + ] + + exc = graphql_error_from_response(errors=errors) + + assert isinstance(exc, NodeNotFoundError) + assert exc.message == "Unable to find the node in the database." + assert exc.code == "NODE_NOT_FOUND" + + def test_a_silent_governing_error_keeps_the_joined_authentication_reasons(self) -> None: + response = auth_response( + envelope={ + "errors": [ + {"extensions": {"code": "AUTHENTICATION_REQUIRED"}}, + {"message": "the reason the caller actually needs"}, + ] + } + ) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "the reason the caller actually needs" + assert exc.code == "AUTHENTICATION_REQUIRED" + + def test_a_described_authentication_failure_names_its_code(self) -> None: + response = auth_response(envelope=load_envelope("auth_two_messages.json")) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "AUTHENTICATION_REQUIRED: first problem" + + def test_an_uncatalogued_authentication_failure_keeps_the_joined_messages(self) -> None: + response = auth_response(envelope={"errors": [{"message": "first problem"}, {"message": "second problem"}]}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "first problem | second problem" + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_a_unified_class_raised_with_no_code_behind_it_keeps_todays_message(self, case: UnifiedClassCase) -> None: + """Re-rooting must not put the GraphQL default message on a client-side lookup miss.""" + assert case.build().message == case.expected_message + + def test_a_deliberately_empty_message_is_not_replaced_by_the_graphql_default(self) -> None: + """Re-rooting must not put the GraphQL placeholder in a caller's mouth either.""" + exc = NodeNotFoundError(identifier="missing-file.py", message="") + + assert not exc.message + assert "An error occurred while executing the GraphQL Query" not in str(exc) + + def test_only_the_governing_error_message_is_named_beside_the_code(self) -> None: + """Joining the whole list would file every later error under a code that is not theirs.""" + envelope = load_envelope("graphql_multiple_errors.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.message == "SCHEMA_NOT_FOUND: first failure" + assert "second failure" not in str(exc) + assert [error["message"] for error in exc.errors] == ["first failure", "second failure", "third failure"], ( + "naming one message must not discard the rest, which stay on the exception" + ) + + def test_the_query_and_variables_survive_a_catalogued_failure(self) -> None: + """The query leaves the message but stays readable, which is the whole trade.""" + envelope = load_envelope("graphql_uniqueness_violation.json") + query = "mutation { TestPersonCreate }" + + exc = graphql_error_from_response(errors=envelope["errors"], query=query, variables={"name": "John"}) + + assert exc.query == query + assert exc.variables == {"name": "John"} diff --git a/tests/unit/sdk/test_exceptions_layering.py b/tests/unit/sdk/test_exceptions_layering.py new file mode 100644 index 000000000..e6f71bb4d --- /dev/null +++ b/tests/unit/sdk/test_exceptions_layering.py @@ -0,0 +1,187 @@ +"""The exceptions package is strictly layered, and imports may only point downward or outside the SDK. + +Parsed rather than imported, so the property is checked against the source of every module in the +package and cannot decay silently. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +import infrahub_sdk.exceptions as exceptions_package + +ROOT = "infrahub_sdk" +PACKAGE = f"{ROOT}.exceptions" +PACKAGE_DIR = Path(exceptions_package.__file__).parent + +# base.py sits at the bottom, which is what keeps the hand-written hierarchy independent of anything +# built on top of it. Each layer above may import only from below it. +LAYERS = { + "base": 0, + "factory": 1, + "__init__": 2, +} + + +def module_paths() -> list[Path]: + return sorted(PACKAGE_DIR.glob("*.py")) + + +def _submodule_of(dotted: str) -> str | None: + """The package submodule an absolute dotted path names, or None if it points outside.""" + prefix = f"{PACKAGE}." + if not dotted.startswith(prefix): + return None + return dotted[len(prefix) :].split(".", maxsplit=1)[0] + + +def intra_package_targets(tree: ast.AST, own_module: str) -> list[tuple[str, int]]: + """Return (module, lineno) for every import that resolves inside the exceptions package. + + Both the relative and the absolute spelling are recognised, so neither form can be used to slip + past the ordering. Walks the whole tree, so an import inside a function body or a TYPE_CHECKING + block is caught exactly like a module-level one. + """ + module_names = {path.stem for path in module_paths()} + targets: list[tuple[str, int]] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level == 1 and node.module: + # `from .factory import x` + targets.append((node.module.split(".")[0], node.lineno)) + 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` + submodule = _submodule_of(node.module) + if submodule: + targets.append((submodule, node.lineno)) + elif isinstance(node, ast.Import): + for alias in node.names: + submodule = _submodule_of(alias.name) + if submodule: + targets.append((submodule, node.lineno)) + + return [(module, lineno) for module, lineno in targets if module != own_module] + + +def _points_outside_package(dotted: str) -> bool: + """Whether a dotted name reaches the SDK outside this package. + + The root itself counts: `import infrahub_sdk` and `from infrahub_sdk import utils` pull in the + façade, which imports the client, so they are dependencies like any other. + """ + if dotted != ROOT and not dotted.startswith(f"{ROOT}."): + return False + return dotted != PACKAGE and not dotted.startswith(f"{PACKAGE}.") + + +def outward_targets(tree: ast.AST) -> list[tuple[str, int]]: + """Return (module, lineno) for every import reaching another part of the SDK. + + A relative import climbing out of the package counts, and so does the absolute spelling of the + same module. Imports of the standard library and of third-party packages do not: the package may + depend on those freely. + """ + targets: list[tuple[str, int]] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level >= 2: + # `from ..utils import x` — one level up is the package itself, two is out of it. + targets.append((f"{'.' * node.level}{node.module or ''}", node.lineno)) + elif node.level == 0 and node.module and _points_outside_package(node.module): + targets.append((node.module, node.lineno)) + elif isinstance(node, ast.Import): + targets.extend((alias.name, node.lineno) for alias in node.names if _points_outside_package(alias.name)) + + return targets + + +@pytest.mark.parametrize("path", module_paths(), ids=lambda p: p.name) +def test_the_package_depends_on_no_other_part_of_the_sdk(path: Path) -> None: + """Nothing in the SDK may sit below the exceptions package. + + Every other module is free to raise, so a dependency in this direction is a cycle waiting to be + discovered — and the workaround for one is a deferred import inside a function body, which the + walk below catches exactly like a module-level one. + """ + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + assert outward_targets(tree=tree) == [], ( + f"{path.name} imports from elsewhere in the SDK; keep the package self-contained instead" + ) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from ..utils import decode_json", id="relative-parent"), + pytest.param("from ...infrahub_sdk import utils", id="relative-grandparent"), + pytest.param("from infrahub_sdk.utils import decode_json", id="absolute-from"), + pytest.param("import infrahub_sdk.utils", id="absolute-import"), + pytest.param("from infrahub_sdk import utils", id="root-from"), + pytest.param("import infrahub_sdk", id="root-import"), + ], +) +def test_an_outward_import_is_detected_however_it_is_spelled(source: str) -> None: + assert outward_targets(tree=ast.parse(source)) != [] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from .base import Error", id="intra-package-relative"), + pytest.param("from infrahub_sdk.exceptions.base import Error", id="intra-package-absolute"), + pytest.param("from infrahub_sdk.exceptions import Error", id="package-facade"), + pytest.param("import httpx", id="third-party"), + pytest.param("from collections.abc import Mapping", id="standard-library"), + ], +) +def test_an_allowed_import_is_not_mistaken_for_an_outward_one(source: str) -> None: + assert outward_targets(tree=ast.parse(source)) == [] + + +@pytest.mark.parametrize("path", module_paths(), ids=lambda p: p.name) +def test_imports_point_strictly_downward(path: Path) -> None: + own_module = path.stem + assert own_module in LAYERS, f"{path.name} is not assigned a layer in this test" + own_layer = LAYERS[own_module] + + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + for module, lineno in intra_package_targets(tree=tree, own_module=own_module): + assert module in LAYERS, f"{path.name}:{lineno} imports unknown package module '{module}'" + assert LAYERS[module] < own_layer, ( + f"{path.name}:{lineno} imports '{module}' (layer {LAYERS[module]}) " + f"from layer {own_layer}; imports must point strictly downward" + ) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from .factory import graphql_error_from_response", id="relative-from"), + pytest.param("from . import factory", id="relative-package"), + pytest.param("from infrahub_sdk.exceptions.factory import graphql_error_from_response", id="absolute-from"), + pytest.param("from infrahub_sdk.exceptions import factory", id="absolute-package"), + pytest.param("import infrahub_sdk.exceptions.factory", id="absolute-import"), + ], +) +def test_every_spelling_of_an_intra_package_import_is_detected(source: str) -> None: + """The check is only worth having if it cannot be sidestepped by rewording the import.""" + targets = intra_package_targets(tree=ast.parse(source), own_module="base") + + assert targets == [("factory", 1)] + + +def test_re_exported_class_names_are_not_mistaken_for_modules() -> None: + source = "from infrahub_sdk.exceptions import AuthenticationError" + + assert intra_package_targets(tree=ast.parse(source), own_module="base") == [] diff --git a/tests/unit/sdk/test_exceptions_public_names.py b/tests/unit/sdk/test_exceptions_public_names.py new file mode 100644 index 000000000..5d100e191 --- /dev/null +++ b/tests/unit/sdk/test_exceptions_public_names.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json + +from infrahub_sdk import exceptions +from infrahub_sdk.exceptions import authentication_error_from_response, base, graphql_error_from_response +from tests.helpers.fixtures import read_fixture + + +def load_snapshot() -> set[str]: + return set(json.loads(read_fixture(file_name="public_names.json", fixture_subdir="error_catalogue"))) + + +def current_names() -> set[str]: + return { + name + for name in dir(exceptions) + if not name.startswith("_") + and isinstance(getattr(exceptions, name), type) + and issubclass(getattr(exceptions, name), BaseException) + } + + +def star_imported_names() -> dict[str, object]: + namespace: dict[str, object] = {} + exec("from infrahub_sdk.exceptions import *", namespace) # noqa: S102 + return {name: value for name, value in namespace.items() if not name.startswith("__")} + + +def classes_defined_in(module: object) -> set[str]: + """The exception classes a module defines itself, ignoring any it merely imported.""" + return { + name + for name, value in vars(module).items() + if isinstance(value, type) and issubclass(value, BaseException) and value.__module__ == module.__name__ # type: ignore[attr-defined] + } + + +def test_the_facade_lists_every_class_base_declares() -> None: + """The façade writes its exports out by hand, so nothing may drift out of step with `base`. + + A class added to `base.__all__` has to be added to both lists in + `infrahub_sdk/exceptions/__init__.py`: the import block and `__all__`. + """ + assert set(exceptions.__all__) == set(base.__all__) + + +def test_every_name_the_facade_declares_is_bound() -> None: + """A name in `__all__` that the import block omits would only fail at a caller's `import *`.""" + missing = sorted(name for name in exceptions.__all__ if not hasattr(exceptions, name)) + + assert missing == [], f"declared in __all__ but never imported: {missing}" + + +def test_base_declares_every_exception_it_defines() -> None: + """An exception class left out of `base.__all__` never reaches the façade at all. + + Without this the omission is invisible: the class is simply absent everywhere downstream, so the + snapshot below still matches and nothing else notices. + """ + undeclared = sorted(classes_defined_in(base) - set(base.__all__)) + + assert undeclared == [], f"defined in base.py but missing from base.__all__: {undeclared}" + + +def test_snapshot_matches_the_exported_exceptions() -> None: + """infrahub_sdk.exceptions is the supported import path. + + Nothing may drop out of it, and a new exception must be added to the snapshot, so the surface + stays a deliberate choice rather than a side effect. + """ + assert current_names() == load_snapshot() + + +def test_every_snapshot_name_still_descends_from_the_root() -> None: + """Re-rooting moves classes around inside the tree; none may leave it. + + `except Error` is the clause a consumer reaches for to catch anything the SDK raises, so a class + that ends up outside that root is as invisible to them as one that stopped being importable. + """ + strays = sorted(name for name in load_snapshot() if not issubclass(getattr(exceptions, name), exceptions.Error)) + + assert strays == [], f"importable but no longer under Error: {strays}" + + +def test_star_import_gives_the_exception_classes_and_nothing_else() -> None: + """What `import *` hands an end user is the classes they catch. + + Without a declared `__all__` the wildcard also carries the `base` and `factory` submodule names, + which are an artefact of the package layout and shadow those names in the caller's scope. The + raise-time factories stay importable by name; they are simply not part of the wildcard. + """ + assert set(star_imported_names()) == load_snapshot() + + +def test_the_raise_time_factories_are_still_importable_by_name() -> None: + """Narrowing the wildcard must not take the supported explicit imports with it. + + The import at the top of this module is the assertion; these calls confirm the names resolve to + the factories rather than to something else the façade happens to bind. + """ + assert callable(authentication_error_from_response) + assert callable(graphql_error_from_response) diff --git a/tests/unit/sdk/test_file_handler.py b/tests/unit/sdk/test_file_handler.py index f8ffacfab..09ae45e73 100644 --- a/tests/unit/sdk/test_file_handler.py +++ b/tests/unit/sdk/test_file_handler.py @@ -4,7 +4,7 @@ import tempfile from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import anyio import httpx @@ -200,6 +200,24 @@ def test_handle_error_response_404() -> None: assert "File not found with ID abc123" in str(excinfo.value) +@pytest.mark.parametrize( + "body", + [ + pytest.param({"text": "404 Not Found"}, id="html-page"), + pytest.param({"text": ""}, id="empty-body"), + pytest.param({"json": ["not an object"]}, id="json-array"), + pytest.param({"json": {"detail": None}}, id="detail-that-is-not-a-string"), + ], +) +def test_handle_error_response_404_with_a_body_carrying_no_detail(body: dict[str, Any]) -> None: + """A 404 body the SDK cannot read must still surface as NodeNotFoundError, never as a decode error.""" + response = httpx.Response(status_code=404, **body) + exc = httpx.HTTPStatusError(message="Not Found", request=httpx.Request("GET", "http://test"), response=response) + + with pytest.raises(NodeNotFoundError, match="File not found"): + FileHandlerBase.handle_error_response(exc=exc) + + def test_handle_error_response_500() -> None: """Test handling 500 server error (re-raises).""" response = httpx.Response(status_code=500, json={"error": "Internal server error"}) diff --git a/tests/unit/sdk/test_object_store.py b/tests/unit/sdk/test_object_store.py index 5e47f3d54..9e5f6e110 100644 --- a/tests/unit/sdk/test_object_store.py +++ b/tests/unit/sdk/test_object_store.py @@ -107,16 +107,60 @@ async def test_object_store_get_raises_authentication_error( httpx_mock.add_response( method="GET", status_code=status_code, - json={"errors": [{"message": "forbidden"}]}, + json={"errors": [{"message": "forbidden"}, {"message": "and stay out"}]}, ) client = getattr(clients, client_type) - with pytest.raises(AuthenticationError): + with pytest.raises(AuthenticationError, match=r"forbidden \| and stay out") as exc_info: if client_type == "standard": await client.object_store.get(identifier="whatever") else: client.object_store.get(identifier="whatever") + assert exc_info.value.http_status is None + assert [error["message"] for error in exc_info.value.errors] == ["forbidden", "and stay out"] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("status_code", [401, 403]) +async def test_object_store_upload_raises_authentication_error( + client_type: str, status_code: int, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response( + method="POST", + status_code=status_code, + json={"errors": [{"message": "no write access", "extensions": {"code": "PERMISSION_DENIED"}}]}, + ) + client = getattr(clients, client_type) + + with pytest.raises(AuthenticationError, match="no write access") as exc_info: + if client_type == "standard": + await client.object_store.upload(content=FILE_CONTENT_01) + else: + client.object_store.upload(content=FILE_CONTENT_01) + + assert exc_info.value.code == "PERMISSION_DENIED" + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("status_code", [401, 403]) +async def test_object_store_get_file_raises_authentication_error( + client_type: str, status_code: int, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """The file lookups reach the same factory through their own request helper.""" + httpx_mock.add_response( + method="GET", + status_code=status_code, + json={"errors": [{"message": "forbidden"}]}, + ) + client = getattr(clients, client_type) + + with pytest.raises(AuthenticationError, match="forbidden"): + if client_type == "standard": + await client.object_store.get_file_by_storage_id(storage_id="whatever") + else: + client.object_store.get_file_by_storage_id(storage_id="whatever") + @pytest.mark.parametrize("client_type", client_types) async def test_object_store_upload_raises_on_500(client_type: str, clients: BothClients, httpx_mock: HTTPXMock) -> None: diff --git a/tests/unit/sdk/test_relogin_headers.py b/tests/unit/sdk/test_relogin_headers.py index 76dd913c3..f614ae9c6 100644 --- a/tests/unit/sdk/test_relogin_headers.py +++ b/tests/unit/sdk/test_relogin_headers.py @@ -1,11 +1,13 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING import pytest from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync from infrahub_sdk.constants import Priority +from infrahub_sdk.exceptions import AuthenticationError if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -71,6 +73,132 @@ async def test_relogin_retry_uses_refreshed_auth_header(client_type: str, httpx_ assert all(r.headers["x-priority"] == "high" for r in graphql_requests) +@dataclass +class RefreshCase: + name: str + body: dict | str + expected_attempts: int + expected_message: str + + +REFRESH_CASES = [ + RefreshCase( + name="catalogued-token-expired-refreshes", + body={"errors": [{"message": "Token has expired", "extensions": {"code": "TOKEN_EXPIRED"}}]}, + expected_attempts=2, + expected_message="Token has expired", + ), + RefreshCase( + name="legacy-expired-signature-refreshes", + body={"errors": [{"message": "Expired Signature"}]}, + expected_attempts=2, + expected_message="Expired Signature", + ), + RefreshCase( + name="unrelated-401-does-not-refresh", + body={"errors": [{"message": "Invalid credentials", "extensions": {"code": "AUTHENTICATION_REQUIRED"}}]}, + expected_attempts=1, + expected_message="Invalid credentials", + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REFRESH_CASES]) +@pytest.mark.parametrize("client_type", client_types) +async def test_refresh_decision_reads_the_code_then_falls_back_to_the_message( + client_type: str, case: RefreshCase, httpx_mock: HTTPXMock +) -> None: + """The silent refresh is decided by the catalogue code, with the legacy string as the fallback.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", status_code=401, json=case.body, is_reusable=True + ) + if case.expected_attempts > 1: + httpx_mock.add_response(method="POST", url="http://mock/api/auth/refresh", json={"access_token": "NEW"}) + + client = _build_password_client(client_type) + query = "query { InfrahubInfo { version }}" + + with pytest.raises(AuthenticationError, match=case.expected_message): + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main") + else: + client.execute_graphql(query=query, branch_name="main") + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == case.expected_attempts + + +@pytest.mark.parametrize("client_type", client_types) +async def test_an_api_token_client_does_not_retry_a_stale_token_401(client_type: str, httpx_mock: HTTPXMock) -> None: + """`login(refresh=True)` cannot mint a token for an API-token client, so the retry is skipped. + + Retrying would replay the same token the server just rejected and earn a second 401 for nothing. + """ + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + status_code=401, + json={"errors": [{"message": "Token has expired", "extensions": {"code": "TOKEN_EXPIRED"}}]}, + is_reusable=True, + ) + config = Config(address="http://mock", api_token="static-token", insert_tracker=True) + client: InfrahubClient | InfrahubClientSync = ( + InfrahubClient(config=config) if client_type == "standard" else InfrahubClientSync(config=config) + ) + query = "query { InfrahubInfo { version }}" + + with pytest.raises(AuthenticationError, match="Token has expired"): + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main") + else: + client.execute_graphql(query=query, branch_name="main") + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == 1 + assert not [r for r in httpx_mock.get_requests() if "auth/refresh" in str(r.url)] + + +@dataclass +class UnreadableBodyCase: + name: str + text: str + + +UNREADABLE_BODY_CASES = [ + UnreadableBodyCase(name="html-proxy-error-page", text="

502 Bad Gateway

"), + UnreadableBodyCase(name="empty-body", text=""), + # Valid JSON, but not the object the envelope is supposed to be. A gateway that answers in its + # own format reaches the same code path, and reading `errors` off it must not throw. + UnreadableBodyCase(name="json-array", text='[{"message": "denied"}]'), + UnreadableBodyCase(name="json-null", text="null"), + UnreadableBodyCase(name="json-string", text='"denied"'), + UnreadableBodyCase(name="json-number", text="401"), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in UNREADABLE_BODY_CASES]) +@pytest.mark.parametrize("client_type", client_types) +async def test_refresh_decision_tolerates_a_body_it_cannot_read( + client_type: str, case: UnreadableBodyCase, httpx_mock: HTTPXMock +) -> None: + """A 401 body the SDK cannot read must surface as an AuthenticationError, never as an SDK error.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", status_code=401, text=case.text, is_reusable=True + ) + + client = _build_password_client(client_type) + query = "query { InfrahubInfo { version }}" + + with pytest.raises(AuthenticationError, match="HTTP 401"): + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main") + else: + client.execute_graphql(query=query, branch_name="main") + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == 1, "a body carrying no refresh signal means no retry" + + @pytest.mark.parametrize("client_type", client_types) def test_merge_request_headers_layers_delta_over_live_base(client_type: str) -> None: """The merge helper layers the per-request delta over the current base headers. diff --git a/tests/unit/sdk/test_retry.py b/tests/unit/sdk/test_retry.py index dcd83c629..f5a4e882f 100644 --- a/tests/unit/sdk/test_retry.py +++ b/tests/unit/sdk/test_retry.py @@ -12,6 +12,7 @@ import errno import io import logging +import re import tempfile import threading import time @@ -605,15 +606,27 @@ async def test_graphql_and_transport_retries_share_one_attempt_counter( class NonTransientCase: name: str errors: list[dict] + expected_message: str +# A governing error carrying a catalogue code names the failure with it; one the catalogue could not +# describe keeps the message this call site has always produced. NON_TRANSIENT_CASES = [ NonTransientCase( name="non-transient-status", errors=[{"message": "Unknown field", "extensions": {"code": "GRAPHQL_VALIDATION", "http_status": 400}}], + expected_message="GRAPHQL_VALIDATION: Unknown field", + ), + NonTransientCase( + name="mixed", + errors=[_transient_error(503), {"message": "Unknown field"}], + expected_message="DATABASE_UNAVAILABLE: Unable to connect to the database", + ), + NonTransientCase( + name="unclassified", + errors=[{"message": "legacy error without extensions"}], + expected_message="An error occurred while executing the GraphQL Query", ), - NonTransientCase(name="mixed", errors=[_transient_error(503), {"message": "Unknown field"}]), - NonTransientCase(name="unclassified", errors=[{"message": "legacy error without extensions"}]), ] @@ -626,7 +639,7 @@ async def test_non_transient_graphql_errors_raise_immediately( requester = ScriptedRequester([_graphql_errors(*case.errors), _ok()]) client = _build_client(client_type, requester, retry_on_failure=True, max_retry_duration=0) - with pytest.raises(GraphQLError, match="An error occurred while executing the GraphQL Query") as exc: + with pytest.raises(GraphQLError, match=re.escape(case.expected_message)) as exc: await _execute_graphql(client) assert exc.value.errors == case.errors @@ -926,7 +939,7 @@ async def test_multipart_mutation_raises_non_transient_graphql_errors_immediatel httpx_mock.add_response(status_code=200, json={"data": None, "errors": errors}) client = _build_client_over_httpx(client_type, retry_on_failure=True, max_retry_duration=0) - with pytest.raises(GraphQLError, match="An error occurred while executing the GraphQL Query") as exc: + with pytest.raises(GraphQLError, match="GRAPHQL_VALIDATION: Unknown field") as exc: await _upload(client, io.BytesIO(MULTIPART_FILE_CONTENT)) assert exc.value.errors == errors