Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog/+api-error-carries-the-request.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
`query` and `variables` are now readable on every `ApiError`, not only on the GraphQL branch. Reading either off an `AuthenticationError` previously raised `AttributeError`:

```python
try:
await client.execute_graphql(query=query)
except ApiError as exc:
log.error("request failed", code=exc.code, query=exc.query) # no longer raises on a 401
```

That clause is the one the SDK recommends for the three authentication codes, since each of them can arrive either on a real 401 or 403 or inside a GraphQL `errors` array, so it is exactly where the attributes had to exist. They join `code`, `http_status`, `extensions` and `errors`, which were already declared there for the same reason.

`None` means the failed request was not recorded on the exception rather than that there was no query: an authentication failure is observed at the transport, which has neither to hand. Nothing that reads these attributes today changes - `GraphQLError` still populates both from the request it was raised for.
7 changes: 7 additions & 0 deletions changelog/+graphql-error-survives-serialisation.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
`GraphQLError` and every exception under it now survive `pickle` and `copy.deepcopy` intact. An exception that crosses a process boundary - a task queue, a parallel test runner, a process pool - previously came back wrong in one of two ways, because Python rebuilds an exception by calling its class with the message as a lone positional argument.

`GraphQLError` takes the server's error list first, so that call filed the message under `errors` and the message a caller read came back as the placeholder built from it: `An error occurred while executing the GraphQL Query None, UNIQUENESS_VIOLATION: ...` in place of the real one. The per-code classes take their payload fields as required keyword arguments, so the same call raised `TypeError: __init__() takes 1 positional argument but 2 were given` and the original failure was lost entirely.

Both are fixed at the root of the GraphQL branch, so the type, the message, the payload attributes, `code`, `http_status`, `extensions`, `errors`, `query` and `variables` all come back as they went in, and an `except` clause still catches a restored exception by its own class.

`NodeNotFoundError`, `BranchNotFoundError` and `SchemaNotFoundError` were affected more quietly: their attributes and `str()` came back correct, but `exc.args` was rebuilt as the class's own default sentence rather than the message the exception was raised with. `AuthenticationError`, whose constructor takes the message first, was the only one unaffected.
20 changes: 20 additions & 0 deletions changelog/+typed-per-code-exceptions.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Codes in Infrahub's error catalogue now raise an exception class of their own, importable from `infrahub_sdk.exceptions`, carrying the failure's payload as directly typed attributes. Identifying a specific failure no longer means matching words in a message:

```python
from infrahub_sdk.exceptions import ApiError, UniquenessViolationError

try:
await node.save()
except UniquenessViolationError as exc:
print(exc.node_kind, exc.fields) # "TestPerson", ["name"]
except ApiError as exc:
print("some other failure:", exc.code)
```

The new classes are `AttributeConstraintViolationError`, `AttributeInvalidTypeError`, `AttributeRequiredError`, `BranchAlreadyMergedError`, `BranchNeedsRebaseError`, `MergeInProgressError`, `MergeRecoveryRequiredError`, `UndefinedError`, and `UniquenessViolationError`. Each is typed exactly as the catalogue declares the payload, so a required field is never optional and needs no guard. They all descend from `GraphQLError`, so no `except` clause stops catching what it catches today.

`AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, and `PERMISSION_DENIED` deliberately have no class of their own: each of them reaches the SDK on two transports, and which generic class it raises follows the transport the SDK observed rather than the status the code declares. Catch `ApiError` and test `exc.code` to handle one of the three whichever way it arrived.

A server predating a code, or one whose payload does not match what the catalogue declares for it, still raises the generic class for the transport with `exc.code` readable, so an SDK of any version keeps working against a server of any version.

`docs/python-sdk/topics/error_handling` covers the hierarchy, the attributes readable on a caught error, and the cross-version guarantees.
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,17 @@ 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. |
| `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 generated class's own declared status fills this in for a code whose envelope omitted it. `NodeNotFoundError`, `BranchNotFoundError` and `SchemaNotFoundError` declare none and so stay `None` there, because they are also raised with no catalogue code behind them. 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`. |
| `query`, `variables` | The GraphQL query and variables the failed request carried, where the exception recorded them. `None` means the request was not recorded, not that there was none: an authentication failure is observed at the transport, which has neither to hand. |

`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.
`errors`, `query`, and `variables` are declared on `ApiError` itself, so they are readable on every
server-reported failure and on a purely client-side raise alike. A client-side `NodeNotFoundError` has
an empty `errors` and `None` for the rest, and so does an `AuthenticationError`, so neither
`except GraphQLError` nor the `except ApiError` clause that spans both transports 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
Expand Down
4 changes: 2 additions & 2 deletions dev/specs/ifc-3034-error-catalogue/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ documentation topic page.
| 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`. |
| VI. Format & Lint Before Commit | **Pass, with no 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. This row first planned to re-export the generated classes with `from .catalogue import *` and to record the resulting `F403` as a commented `per-file-ignores` entry. The façade lists its re-exports by explicit name instead, so no `F403` arises and no entry was added: a wildcard would also have promoted the payload models, the lookup maps and the dispatch helper onto the package surface. The hand-maintained list is held against what the source modules define by a test, which is what the wildcard was meant to buy. |
Comment thread
ogenstad marked this conversation as resolved.
| 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.
Expand Down Expand Up @@ -160,7 +160,7 @@ 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
pyproject.toml # pytest markers: catalogue, crossversion, malformed, message
tasks.py # Add `exceptions` to packages_to_ignore for API-doc generation
```

Expand Down
15 changes: 8 additions & 7 deletions dev/specs/ifc-3034-error-catalogue/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ decay into the cycle it was designed out of. No new dependency.
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.
- The façade re-exports by explicit name from both `base` and `catalogue`, rather than the wildcards
this section first proposed. A wildcard would have kept the export surface automatic as codes are
added, at the cost of an `F403` `per-file-ignores` entry; it would also have promoted `catalogue`'s
payload models, lookup maps and dispatch helper onto the package surface, where each name becomes a
stability promise the package never chose to make. Written out by hand, the file says what it
exports, and a test holds it against what the source modules define so the two cannot drift.
`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.

Expand Down
Loading
Loading