Skip to content
Closed
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
2 changes: 1 addition & 1 deletion changelog/1151.added.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Added support for tagging SDK requests with a priority via a new `X-Priority` header. A `Priority` enum (`high`, `normal`, `low`) is available from `infrahub_sdk.constants`; set `Config.priority` (env var `INFRAHUB_PRIORITY`) for a client-wide default emitted on every request, or pass `priority=` to individual operations to override it per request. When unset, no header is sent. Works identically on `InfrahubClient` and `InfrahubClientSync`.
Added support for tagging SDK requests with a priority via a new `X-Priority` header. A `Priority` enum (`high`, `medium`, `low`) is available from `infrahub_sdk.constants`; set `Config.priority` (env var `INFRAHUB_PRIORITY`) for a client-wide default emitted on every request, or pass `priority=` to individual operations to override it per request. When unset, no header is sent. Works identically on `InfrahubClient` and `InfrahubClientSync`.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ from infrahub_sdk.constants import Priority

class Priority(str, enum.Enum):
HIGH = "high"
NORMAL = "normal"
MEDIUM = "medium"
LOW = "low"
```

Expand All @@ -25,7 +25,7 @@ class ConfigBase(BaseSettings):
priority: Priority | None = Field(
default=None,
description="Default request priority emitted as the X-Priority header on every request. "
"One of high|normal|low (case-insensitive). When unset, no header is sent.",
"One of high|medium|low (case-insensitive). When unset, no header is sent.",
)
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
| Property | Value |
|----------|-------|
| Name | `X-Priority` (exact, case-insensitive on the server per HTTP header rules) |
| Values | `high`, `normal`, `low` (lowercase emitted by the SDK) |
| Values | `high`, `medium`, `low` (lowercase emitted by the SDK) |
| Cardinality | 0 or 1 per request |

## Emission rules (SDK side)
Expand All @@ -20,8 +20,8 @@
## Server semantics (assumed, per INFP-636 — not implemented here)

- The server treats the value case-insensitively.
- An **absent** header and an **unknown** value are both treated as `normal`.
- Consequently, "omit the header" and "send `normal`" are server-equivalent, which is what makes omitting-when-unconfigured a safe, non-breaking rollout.
- An **absent** header and an **unknown** value are both treated as `medium`.
- Consequently, "omit the header" and "send `medium`" are server-equivalent, which is what makes omitting-when-unconfigured a safe, non-breaking rollout.

## Non-goals (this contract)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ This is a small, well-scoped, low-risk feature backed by a detailed PRD (IHS-259

| ID | Severity | Finding | Suggestion |
|----|----------|---------|------------|
| P3 | 🤔 | A `Literal["high","normal","low"]` would be simpler than an enum. | Rejected by design — FR-001/PRD mandate a `Priority` enum for typo-safety and intent expression. No action. |
| P3 | 🤔 | A `Literal["high","medium","low"]` would be simpler than an enum. | Rejected by design — FR-001/PRD mandate a `Priority` enum for typo-safety and intent expression. No action. |

### Edge Cases & UX

Expand Down
16 changes: 8 additions & 8 deletions dev/specs/ihs-259-sdk-x-priority-header/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A closed enumeration representing request priority. Owned by the SDK; no lifecyc
| Member | Wire value | Meaning |
|----------|-----------|----------------------------------------------------------------|
| `HIGH` | `high` | Prefer this request; server should protect it under load. |
| `NORMAL` | `normal` | Default server treatment; equivalent to absent header. |
| `MEDIUM` | `medium` | Default server treatment; equivalent to absent header. |
| `LOW` | `low` | Sheddable first; intended for background/bulk workloads. |

- **Base type**: `str, enum.Enum` — each member *is* its lowercase wire token, so it drops directly into a header dict.
Expand All @@ -32,7 +32,7 @@ Extends `ConfigBase` (`infrahub_sdk/config.py`).
| Type | `Priority \| None` |
| Default | `None` (no client-wide default → header omitted) |
| Env var | `INFRAHUB_PRIORITY` (via `env_prefix="INFRAHUB_"`) |
| Accepts | a `Priority` value, or a case-insensitive string (`"high"/"normal"/"low"`, any case) |
| Accepts | a `Priority` value, or a case-insensitive string (`"high"/"medium"/"low"`, any case) |
| Validation | pydantic + `Priority` enum; unknown value → `ValidationError` at load time |
| Carried by `clone()` | Yes (automatic — `clone()` iterates `Config.model_fields`) |

Expand All @@ -54,9 +54,9 @@ The client's base header dict, built once in `BaseClient.__init__` (`infrahub_sd
| Attribute | Value |
|-----------|-------|
| Header name | `X-Priority` (exact) |
| Value | one of `high` / `normal` / `low` (lowercase) |
| Value | one of `high` / `medium` / `low` (lowercase) |
| Presence | present only when the resolved priority is non-`None` |
| Server semantics (per INFP-636) | case-insensitive; absent or unknown treated as `normal` |
| Server semantics (per INFP-636) | case-insensitive; absent or unknown treated as `medium` |

See [contracts/x-priority-header.md](./contracts/x-priority-header.md).

Expand All @@ -78,14 +78,14 @@ Realised in code as: the client default is already in the copied `self.headers`;
|----------------|-----------------|-----------------------|
| `None` | `None` | *(none)* |
| `None` | `HIGH` | `X-Priority: high` |
| `None` | `NORMAL` | `X-Priority: normal` |
| `None` | `MEDIUM` | `X-Priority: medium` |
| `LOW` | `None` | `X-Priority: low` |
| `LOW` | `HIGH` | `X-Priority: high` |
| `LOW` | `NORMAL` | `X-Priority: normal` (explicit step-up wins) |
| `NORMAL` | `None` | `X-Priority: normal` |
| `LOW` | `MEDIUM` | `X-Priority: medium` (explicit step-up wins) |
| `MEDIUM` | `None` | `X-Priority: medium` |
| `HIGH` | `LOW` | `X-Priority: low` |

- There is no per-request way to force "send no header" once a default is set; passing `NORMAL` explicitly is the accepted equivalent (spec Edge Cases).
- There is no per-request way to force "send no header" once a default is set; passing `MEDIUM` explicitly is the accepted equivalent (spec Edge Cases).
- A per-request value never mutates client state — the next un-annotated call reverts to the client default (SC-003).

## Coverage of the per-request override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ All tests added/modified by this run, observed passing locally (unit; project ha
| `test_priority.py::test_priority_header_on_blob_upload` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_priority_header_on_multipart_upload` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_priority_header_on_batched_requests` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_priority_normal_is_always_emitted` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_priority_medium_is_always_emitted` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_no_priority_header_on_graphql_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_no_priority_header_on_blob_download_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_no_priority_header_on_blob_upload_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_no_priority_header_on_multipart_upload_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_unconfigured_headers_unchanged_versus_baseline` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_on_no_default_client_then_no_leak` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_beats_default_then_reverts` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_normal_beats_low_default` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_medium_beats_low_default` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_on_get` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_on_all_carries_on_every_page` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
| `test_priority.py::test_override_on_save_create_path` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` |
Expand Down
8 changes: 4 additions & 4 deletions dev/specs/ihs-259-sdk-x-priority-header/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@

## Summary

Add a first-class request-priority concept to the SDK, emitted as an `X-Priority: high|normal|low` HTTP header. Two configuration surfaces: a client-wide default via `Config.priority` (rides every transport by being injected into the client's base `self.headers`) and a per-request `priority=` keyword argument on the covered public methods (resolved as `per_request if per_request is not None else client_default`). When nothing is configured, no header is emitted — byte-for-byte identical to today. Both `InfrahubClient` (async) and `InfrahubClientSync` (sync) behave identically. No server-side logic, no 429 handling.
Add a first-class request-priority concept to the SDK, emitted as an `X-Priority: high|medium|low` HTTP header. Two configuration surfaces: a client-wide default via `Config.priority` (rides every transport by being injected into the client's base `self.headers`) and a per-request `priority=` keyword argument on the covered public methods (resolved as `per_request if per_request is not None else client_default`). When nothing is configured, no header is emitted — byte-for-byte identical to today. Both `InfrahubClient` (async) and `InfrahubClientSync` (sync) behave identically. No server-side logic, no 429 handling.

**Technical approach** (grounded in the existing `X-Infrahub-Tracker` prior art):

1. New `Priority(str, enum.Enum)` in `infrahub_sdk/constants.py` with members `HIGH="high"`, `NORMAL="normal"`, `LOW="low"` and a case-insensitive `_missing_` classmethod.
1. New `Priority(str, enum.Enum)` in `infrahub_sdk/constants.py` with members `HIGH="high"`, `MEDIUM="medium"`, `LOW="low"` and a case-insensitive `_missing_` classmethod.
2. New `Config.priority: Priority | None = None` field (auto-binds to `INFRAHUB_PRIORITY` via the existing `env_prefix`). Pydantic + the enum give validation and case-insensitive string coercion for free; unknown values raise at config load.
3. Inject the configured default once into `BaseClient.__init__`'s base `self.headers` (right next to the `X-INFRAHUB-KEY` line). Because every transport already merges `self.headers`, the default automatically rides GraphQL, multipart upload, and raw blob `_get`/`_post`.
4. Add `priority: Priority | None = None` to `execute_graphql` and `_execute_graphql_with_file` (async + sync). These are the single points where the per-request header is applied: `if priority is not None: headers["X-Priority"] = priority.value` on the already-copied header dict — which realises the resolution rule exactly (a `None` per-request keeps whatever the base default was; an explicit value, including `NORMAL`, overrides it).
4. Add `priority: Priority | None = None` to `execute_graphql` and `_execute_graphql_with_file` (async + sync). These are the single points where the per-request header is applied: `if priority is not None: headers["X-Priority"] = priority.value` on the already-copied header dict — which realises the resolution rule exactly (a `None` per-request keeps whatever the base default was; an explicit value, including `MEDIUM`, overrides it).
5. Thread the `priority` kwarg through the higher-level callers so they forward it to the two execute methods: client `get`, `all` (via `filters`), `create`, `create_diff`/`get_diff_summary`/`get_diff_tree`; node `save`/`create`/`update`/`delete`. Raw blob `_get`/`_post` and batch mode inherit the client default only (no per-call override in v1).

**Two load-bearing details surfaced by the critique** (see [critiques/](./critiques/)):
Expand Down Expand Up @@ -107,7 +107,7 @@ See [research.md](./research.md) for the full decision log. Highlights:
- **Config field is named `priority`, not `x_priority`** — the PRD specifies `Config.priority`; the `X-` prefix belongs to the wire header, not the config surface. Auto-binds to `INFRAHUB_PRIORITY`.
- **Default injected into base `self.headers`, not at each call site** — this is the mechanism that guarantees FR-003 (every transport) without touching blob/batch code paths, and mirrors how `X-INFRAHUB-KEY` already rides every request.
- **Per-request application lives in `execute_graphql` / `_execute_graphql_with_file` only** — every high-level method funnels through these two, so the resolution rule is implemented once per client (twice total) instead of at ~10 call sites. Higher-level methods only *forward* the kwarg.
- **Resolution is realised by override-if-present on the copied header dict** — `copy.copy(self.headers)` already carries the default; `if priority is not None: headers["X-Priority"] = priority.value` yields exactly `per_request if per_request is not None else client_default`, including the explicit-`NORMAL`-beats-`low`-default edge case.
- **Resolution is realised by override-if-present on the copied header dict** — `copy.copy(self.headers)` already carries the default; `if priority is not None: headers["X-Priority"] = priority.value` yields exactly `per_request if per_request is not None else client_default`, including the explicit-`MEDIUM`-beats-`low`-default edge case.
- **Case-insensitivity via `Priority._missing_`** — handles `LOW`/`Low`/`low` from env/file config and raises for unknown values, satisfying FR-002 and FR-007 with no bespoke validator.

## Complexity Tracking
Expand Down
6 changes: 3 additions & 3 deletions dev/specs/ihs-259-sdk-x-priority-header/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ client = InfrahubClient(config=Config(address="http://localhost:8000", priority=
# This one user-triggered call steps up to high; the rest stay low.
node = await client.get(kind="BuiltinTag", name__value="blue", priority=Priority.HIGH)

# Explicit NORMAL beats a LOW default for this call only:
await client.execute_graphql(query=MY_QUERY, priority=Priority.NORMAL) # -> X-Priority: normal
# Explicit MEDIUM beats a LOW default for this call only:
await client.execute_graphql(query=MY_QUERY, priority=Priority.MEDIUM) # -> X-Priority: medium
```

### Zero behaviour change when unconfigured (P1)
Expand Down Expand Up @@ -72,7 +72,7 @@ uv run pytest tests/unit/sdk/test_priority.py tests/unit/sdk/test_config.py \
| Default rides GraphQL, multipart, blob | `httpx_mock.add_response(match_headers={"X-Priority": "low"})` for each transport; request only matches if header present | SC-001 |
| Unconfigured client emits no header | capture request via `httpx_mock.get_requests()`, assert `"x-priority" not in request.headers` | SC-002 |
| Per-request override, then revert | override call matches `{"X-Priority": "high"}`; next un-annotated call matches the default (or no header) | SC-003 |
| Explicit `NORMAL` beats `LOW` default | override call matches `{"X-Priority": "normal"}` | SC-003 (edge) |
| Explicit `MEDIUM` beats `LOW` default | override call matches `{"X-Priority": "medium"}` | SC-003 (edge) |
| Invalid value rejected | `pytest.raises(pydantic.ValidationError, match=...)` on `Config(priority="lowe")` | SC-004 |
| Case-insensitive config accepted | `Config(priority="LOW").priority is Priority.LOW` | FR-002 |
| Async/sync parity | parametrize every wire test over `["standard", "sync"]` via the `BothClients` fixture | SC-005 |
Expand Down
8 changes: 4 additions & 4 deletions dev/specs/ihs-259-sdk-x-priority-header/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ This feature has no external unknowns — the wire contract is fixed by INFP-636

## Decision 1 — Where the `Priority` enum lives and its shape

- **Decision**: `class Priority(str, enum.Enum)` in `infrahub_sdk/constants.py`, members `HIGH = "high"`, `NORMAL = "normal"`, `LOW = "low"`, plus a case-insensitive `_missing_` classmethod.
- **Decision**: `class Priority(str, enum.Enum)` in `infrahub_sdk/constants.py`, members `HIGH = "high"`, `MEDIUM = "medium"`, `LOW = "low"`, plus a case-insensitive `_missing_` classmethod.
- **Rationale**: `constants.py` already hosts `InfrahubClientMode(str, enum.Enum)` (`constants.py:4`) and is already imported by both `config.py` (`config.py:11`) and `client.py`. A `str`-valued enum means `Priority.LOW.value == "high"`-style access gives the exact wire token, and the member *is* a `str` so it slots straight into a headers dict. `_missing_` lets `Priority("LOW")` resolve case-insensitively, which pydantic uses when coercing env/file strings.
- **Alternatives considered**:
- `infrahub_sdk/enums.py` (`OrderDirection`, `enums.py:4`) — viable, but `constants.py` is the home for *client/config-consumed* enums (`InfrahubClientMode`), which is exactly this case.
- A `Literal["high","normal","low"]` instead of an enum — rejected: FR-001 explicitly requires a `Priority` enum so callers write `Priority.LOW` and cannot typo the contract.
- A `Literal["high","medium","low"]` instead of an enum — rejected: FR-001 explicitly requires a `Priority` enum so callers write `Priority.LOW` and cannot typo the contract.

```python
# infrahub_sdk/constants.py
class Priority(str, enum.Enum):
HIGH = "high"
NORMAL = "normal"
MEDIUM = "medium"
LOW = "low"

@classmethod
Expand Down Expand Up @@ -58,7 +58,7 @@ if priority is not None:
headers["X-Priority"] = priority.value
```

- **Rationale**: these two methods are the single funnel for all GraphQL traffic. `copy.copy(self.headers)` already carries the client default, so `if priority is not None: headers["X-Priority"] = priority.value` computes exactly `resolved = per_request if per_request is not None else client_default` (FR-006): `None` keeps the default (or absence); an explicit value overrides it, including an explicit `NORMAL` stepping *up* from a `low` default (spec edge case, SC-003). Implementing it here means the rule exists twice (async + sync), not at every public method.
- **Rationale**: these two methods are the single funnel for all GraphQL traffic. `copy.copy(self.headers)` already carries the client default, so `if priority is not None: headers["X-Priority"] = priority.value` computes exactly `resolved = per_request if per_request is not None else client_default` (FR-006): `None` keeps the default (or absence); an explicit value overrides it, including an explicit `MEDIUM` stepping *up* from a `low` default (spec edge case, SC-003). Implementing it here means the rule exists twice (async + sync), not at every public method.
- **Alternatives considered**: a shared `_apply_priority(headers, priority)` helper — optional nicety; the two-line inline form mirrors the surrounding tracker code and is clearer in context. Left to implementer discretion; both satisfy the contract.

## Decision 5 — Threading the kwarg through higher-level methods
Expand Down
Loading
Loading