-
Notifications
You must be signed in to change notification settings - Fork 13
docs: add specification for the error catalogue in the SDK #1266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: infrahub-develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| # 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, including catalogued permission and token failures | `except GraphQLError` | | ||
| | Any authentication or permission failure, either transport | `except AuthenticationError` | | ||
| | One specific catalogued failure | `except UniquenessViolationError` (and so on per code) | | ||
| | Anything the SDK raises | `except Error` | | ||
|
|
||
| The catalogued 401/403 classes deliberately satisfy both `except GraphQLError` and | ||
| `except AuthenticationError`, because they reach the SDK on the GraphQL transport two different ways: | ||
|
|
||
| - **Inside a 200 response's `errors` array**, when the failure was raised from within a resolver. | ||
| `except GraphQLError` catches such a response today; the dual base is what keeps that true while also | ||
| making `except AuthenticationError` catch it. | ||
| - **As a real 401 or 403**, when the failure escapes before query execution. | ||
| `except AuthenticationError` catches this today. `except GraphQLError` does not, because the SDK | ||
| raises before reading the body — under this change it will, since the authentication path now resolves | ||
| the catalogue code and raises the specific class. That is a broadening, listed below. | ||
|
|
||
| Every clause that worked before the change still catches what it caught before (FR-018). Three | ||
| broadenings are deliberate: | ||
|
|
||
| - `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. | ||
| - `except GraphQLError` now also catches a real 401 or 403 **whenever the SDK raises a per-code class | ||
| for it**, where it previously raised a plain `AuthenticationError`. Only those classes carry both | ||
| parents, so the condition is exactly the condition for reaching one: the code is recognised by this | ||
| SDK's bindings *and* its payload validates. If either fails, the fallback raises the generic | ||
| `AuthenticationError` for the observed transport, which is not a `GraphQLError` — unchanged from | ||
| today. | ||
| - Code that catches the generic error to inspect its message will now sometimes receive a subclass | ||
| whose message names the code instead of embedding the query. | ||
|
|
||
| ## 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 code's catalogue-declared status, or `None`. This is metadata about the failure, not the HTTP status of the response — a catalogued data error arrives as HTTP 200. Where the error carried an `extensions` mapping, the status the server actually returned is available as `exc.extensions["http_status"]` — guard on `exc.extensions` first, since it is `None` when the error carried none. The two can legitimately differ: the server replaces a declared 500 with the real HTTP status when it has a more accurate one. | | ||
| | 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 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 a 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`. | ||
|
|
||
| ## Cross-version behaviour | ||
|
|
||
| Any SDK version talks to any server version. Parsing never raises. | ||
|
|
||
| | Situation | Behaviour | | ||
| |-----------|-----------| | ||
| | 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** 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, query text included. The | ||
| query is available as an attribute in both cases. | ||
|
|
||
| The qualifier matters because three catalogued classes can also be raised client-side, with no server | ||
| response behind them: `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError`. Those | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The new sentence says these client-side raises happen 'with no server response behind them,' but the same document's broadenings section and infrahub_sdk/file_handler.py:168 raise NodeNotFoundError from an actual REST 404 that does carry a server response and message (the Prompt for AI agents |
||
| raises keep the message they produce today, since there is no code and no server message 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. | ||
Uh oh!
There was an error while loading. Please reload this page.