diff --git a/changelog/1151.added.md b/changelog/1151.added.md index d4cba33c6..3180fe0d1 100644 --- a/changelog/1151.added.md +++ b/changelog/1151.added.md @@ -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`. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md b/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md index 9d4b8206b..5181aa9d9 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md @@ -9,7 +9,7 @@ from infrahub_sdk.constants import Priority class Priority(str, enum.Enum): HIGH = "high" - NORMAL = "normal" + MEDIUM = "medium" LOW = "low" ``` @@ -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.", ) ``` diff --git a/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md b/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md index f9acfc0c1..e6eb99e64 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md @@ -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) @@ -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) diff --git a/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md b/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md index f33b9afbb..66d084e10 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md @@ -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 diff --git a/dev/specs/ihs-259-sdk-x-priority-header/data-model.md b/dev/specs/ihs-259-sdk-x-priority-header/data-model.md index 47784bff7..d2f7cfb3e 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/data-model.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/data-model.md @@ -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. @@ -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`) | @@ -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). @@ -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 diff --git a/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md b/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md index 6e82882d1..2c2fb634e 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md @@ -49,7 +49,7 @@ 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` | @@ -57,7 +57,7 @@ All tests added/modified by this run, observed passing locally (unit; project ha | `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` | diff --git a/dev/specs/ihs-259-sdk-x-priority-header/plan.md b/dev/specs/ihs-259-sdk-x-priority-header/plan.md index 23fae45f2..c24b35bcb 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/plan.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/plan.md @@ -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/)): @@ -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 diff --git a/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md b/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md index 241b370dc..f2f76b34f 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md @@ -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) @@ -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 | diff --git a/dev/specs/ihs-259-sdk-x-priority-header/research.md b/dev/specs/ihs-259-sdk-x-priority-header/research.md index e4b1ea6ee..37e062717 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/research.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/research.md @@ -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 @@ -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 diff --git a/dev/specs/ihs-259-sdk-x-priority-header/spec.md b/dev/specs/ihs-259-sdk-x-priority-header/spec.md index 108bd61fb..753690f55 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/spec.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/spec.md @@ -12,7 +12,7 @@ Infrahub's background systems (generators, artifacts, diffs, syncs, computed attributes) call back into the same API servers through this SDK, competing on equal footing with human/frontend traffic for a shared worker pool and database connections. Today the API layer cannot distinguish interactive from background traffic, so under heavy background load it cannot preferentially protect the frontend. -This feature gives the SDK a first-class notion of request **priority** (`high | normal | low`) that it emits as an `X-Priority` HTTP header. The originator of each request — the SDK caller — declares how important the request is, in a form the server can act on. Priority is set two ways: a **client-wide default** (a client dedicated to background work tags everything `low` with no call-site changes) and a **per-request override** on individual operations. When nothing is configured, the SDK sends no header and behaves exactly as today; the server treats absent/unknown values as `normal`, so rollout is safe and incremental. +This feature gives the SDK a first-class notion of request **priority** (`high | medium | low`) that it emits as an `X-Priority` HTTP header. The originator of each request — the SDK caller — declares how important the request is, in a form the server can act on. Priority is set two ways: a **client-wide default** (a client dedicated to background work tags everything `low` with no call-site changes) and a **per-request override** on individual operations. When nothing is configured, the SDK sends no header and behaves exactly as today; the server treats absent/unknown values as `medium`, so rollout is safe and incremental. This is the SDK's contribution to the server-side prioritization effort in INFP-636. It does **not** implement server-side admission control or 429 handling. @@ -31,7 +31,7 @@ An operator running background workloads constructs one client with a default pr 1. **Given** a client built with a default priority of `low`, **When** it issues a GraphQL query or mutation, **Then** that request carries `X-Priority: low`. 2. **Given** a client built with a default priority of `low`, **When** it issues a multipart file upload, **Then** that request carries `X-Priority: low`. 3. **Given** a client built with a default priority of `low`, **When** it issues a raw blob transfer (`_get`/`_post`), **Then** that request carries `X-Priority: low`. -4. **Given** a client built with a default priority of `normal`, **When** it issues any request, **Then** that request carries `X-Priority: normal` (an explicitly configured default is always emitted). +4. **Given** a client built with a default priority of `medium`, **When** it issues any request, **Then** that request carries `X-Priority: medium` (an explicitly configured default is always emitted). --- @@ -97,30 +97,30 @@ A developer using the synchronous client (`InfrahubClientSync`) gets behaviour i ### Edge Cases -- **Explicit step-up on a low-default client**: a per-request `NORMAL` on a client whose default is `low` sends `X-Priority: normal` for that call — explicit intent wins, even when stepping *up* from the default. -- **No "send no header" per-request escape once a default is set**: once a client default is configured there is no per-request way to suppress the header; the accepted equivalent is passing `NORMAL` explicitly. +- **Explicit step-up on a low-default client**: a per-request `MEDIUM` on a client whose default is `low` sends `X-Priority: medium` for that call — explicit intent wins, even when stepping *up* from the default. +- **No "send no header" per-request escape once a default is set**: once a client default is configured there is no per-request way to suppress the header; the accepted equivalent is passing `MEDIUM` explicitly. - **Batch mode and raw blob transfers**: these inherit the client default but expose **no** per-call override in v1. - **Invalid configured value**: errors at configuration load, never at request time. - **Caller manually pre-populates an `X-Priority` header**: only reachable via the low-level `_get`/`_post` transport methods, which accept a raw `headers=` argument (the covered public methods do not). The resolution rule is the single source of truth (documented behaviour); manually injecting the header at that low level is not a supported side channel and its interaction with resolution is not guaranteed. -- **Explicit per-request `NORMAL` vs. no argument**: an explicit `NORMAL` always emits `X-Priority: normal`; a `None`/absent per-request value falls through to the client default (which may itself be absent, in which case no header is sent). +- **Explicit per-request `MEDIUM` vs. no argument**: an explicit `MEDIUM` always emits `X-Priority: medium`; a `None`/absent per-request value falls through to the client default (which may itself be absent, in which case no header is sent). ## Requirements *(mandatory)* ### Functional Requirements -- **FR-001**: System MUST expose a closed set of priority values `high | normal | low` as a `Priority` enum, so callers express intent as a typed value rather than a raw header string. +- **FR-001**: System MUST expose a closed set of priority values `high | medium | low` as a `Priority` enum, so callers express intent as a typed value rather than a raw header string. - **FR-002**: Users MUST be able to configure a client-wide default priority via configuration, accepting either a `Priority` enum value or a case-insensitive string (through environment or file configuration). - **FR-003**: When a default priority is configured, System MUST attach the `X-Priority` header to **every** outgoing request across all transports — GraphQL queries/mutations, multipart uploads, and raw blob `_get`/`_post`. - **FR-004**: When no priority is configured and none is supplied per request, System MUST omit the `X-Priority` header entirely, producing outgoing requests byte-for-byte identical to current (pre-feature) behaviour. - **FR-005**: Users MUST be able to override priority per request via a `priority` argument (accepting a `Priority` value or `None`, default `None`) on the covered public methods: `get`, `all`, `create`, `save`, the diff methods, `execute_graphql`, and its file variant. -- **FR-006**: Priority resolution MUST be `resolved = per_request if per_request is not None else client_default`. A resolved value of `None` MUST omit the header; a resolved explicit value MUST be sent, including an explicit `NORMAL`, which MUST send `X-Priority: normal`. +- **FR-006**: Priority resolution MUST be `resolved = per_request if per_request is not None else client_default`. A resolved value of `None` MUST omit the header; a resolved explicit value MUST be sent, including an explicit `MEDIUM`, which MUST send `X-Priority: medium`. - **FR-007**: System MUST reject an invalid or unknown configured priority value at configuration-load time (a validation/type error) rather than coercing it or silently sending it. - **FR-008**: The asynchronous client (`InfrahubClient`) and the synchronous client (`InfrahubClientSync`) MUST behave identically for every aspect of this feature. -- **FR-009**: The emitted header MUST be named exactly `X-Priority`, carrying the lowercase value string (`high`, `normal`, `low`) that corresponds to the resolved priority. +- **FR-009**: The emitted header MUST be named exactly `X-Priority`, carrying the lowercase value string (`high`, `medium`, `low`) that corresponds to the resolved priority. ### Key Entities *(include if feature involves data)* -- **Priority** *(new)*: a closed enumeration with members `HIGH`, `NORMAL`, `LOW`. It is the single in-code representation of request priority, owned by the SDK, with no lifecycle beyond its value. Each member maps to a lowercase wire value (`high`/`normal`/`low`). +- **Priority** *(new)*: a closed enumeration with members `HIGH`, `MEDIUM`, `LOW`. It is the single in-code representation of request priority, owned by the SDK, with no lifecycle beyond its value. Each member maps to a lowercase wire value (`high`/`medium`/`low`). - **Configuration** *(extended)*: gains a `priority` field defaulting to `None` (meaning "no default; omit the header"). Accepts a `Priority` value or a case-insensitive string, validated at load time. - **Base request headers** *(extended)*: the client's base header set is extended so a configured default is injected once and rides every transport, rather than being added at each call site. @@ -137,7 +137,7 @@ A developer using the synchronous client (`InfrahubClientSync`) gets behaviour i ## Assumptions -- **Server contract (per INFP-636)**: the header is exactly `X-Priority`; values are case-insensitive on the server; absent or unknown values are treated as `normal` server-side. This makes an absent header and a `normal` value semantically equivalent to the server, which is why omitting the header when unconfigured is safe. +- **Server contract (per INFP-636)**: the header is exactly `X-Priority`; values are case-insensitive on the server; absent or unknown values are treated as `medium` server-side. This makes an absent header and a `medium` value semantically equivalent to the server, which is why omitting the header when unconfigured is safe. - **Dual async/sync pattern is mandatory**: every change here is applied to both `InfrahubClient` and `InfrahubClientSync`, per AGENTS.md. - **Public API signature change is accepted**: this feature adds a new enum, a new configuration field, and a new `priority` keyword argument across the covered public method surface — an intentional public-API-signature change (flagged per AGENTS.md "ask first: changing public API signatures"). - **Docs regeneration is required**: the new configuration field and docstrings require `uv run invoke docs-generate`. @@ -153,4 +153,4 @@ A developer using the synchronous client (`InfrahubClientSync`) gets behaviour i ## Dependencies -- **INFP-636** — server-side API Request Prioritization (parent effort that defines and consumes the `X-Priority` contract). This SDK feature is only useful once the server acts on the header, but is safe to ship independently because absent/unknown is treated as `normal`. +- **INFP-636** — server-side API Request Prioritization (parent effort that defines and consumes the `X-Priority` contract). This SDK feature is only useful once the server acts on the header, but is safe to ship independently because absent/unknown is treated as `medium`. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/tasks.md b/dev/specs/ihs-259-sdk-x-priority-header/tasks.md index c0ad370ca..dc23e30f2 100644 --- a/dev/specs/ihs-259-sdk-x-priority-header/tasks.md +++ b/dev/specs/ihs-259-sdk-x-priority-header/tasks.md @@ -42,9 +42,9 @@ Single-project Python library. Production code under `infrahub_sdk/`; tests unde **Purpose**: The `Priority` enum and `Config.priority` field are prerequisites for every user story. MUST complete before Phase 3+. -- [X] T002 Add `class Priority(str, enum.Enum)` with members `HIGH = "high"`, `NORMAL = "normal"`, `LOW = "low"` and a case-insensitive `_missing_` classmethod (returns the member matching `value.lower()`, else `None`) to `infrahub_sdk/constants.py` (mirror `InfrahubClientMode`). Add module docstring/type hints per repo style. +- [X] T002 Add `class Priority(str, enum.Enum)` with members `HIGH = "high"`, `MEDIUM = "medium"`, `LOW = "low"` and a case-insensitive `_missing_` classmethod (returns the member matching `value.lower()`, else `None`) to `infrahub_sdk/constants.py` (mirror `InfrahubClientMode`). Add module docstring/type hints per repo style. - [X] T003 [P] Export `Priority` from the SDK public namespace: add it to `infrahub_sdk/__init__.py` imports and `__all__` (alongside other public enums), per contracts/priority-api.md. -- [X] T004 Add `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.")` to `ConfigBase` in `infrahub_sdk/config.py`; import `Priority` from `.constants`. Confirm the field auto-binds to `INFRAHUB_PRIORITY` (no custom source needed) and is carried by `Config.clone()` (it iterates `model_fields`, so no change to `clone()` required — verify only). +- [X] T004 Add `priority: Priority | None = Field(default=None, description="Default request priority emitted as the X-Priority header on every request; one of high|medium|low (case-insensitive). When unset, no header is sent.")` to `ConfigBase` in `infrahub_sdk/config.py`; import `Priority` from `.constants`. Confirm the field auto-binds to `INFRAHUB_PRIORITY` (no custom source needed) and is carried by `Config.clone()` (it iterates `model_fields`, so no change to `clone()` required — verify only). **Checkpoint**: `Priority` importable from `infrahub_sdk`; `Config(priority=...)` accepts enum/string; the SDK still imports and existing tests still pass. @@ -66,7 +66,7 @@ Single-project Python library. Production code under `infrahub_sdk/`; tests unde - [X] T007 [P] [US1] In `tests/unit/sdk/test_object_store.py` (or `test_priority.py`), assert a `priority=Priority.LOW` client emits `X-Priority: low` on a blob download (`_get_streaming`) and upload (`_post`/object-store), both clients. (SC-001, SC-006 blob) - [X] T008 [P] [US1] Add a multipart-upload test: a `priority=Priority.LOW` client emits `X-Priority: low` on `_execute_graphql_with_file`, both clients (confirm the header survives the `content-type` pop). (SC-001, FR-003) - [X] T009 [P] [US1] Add a batch-mode test: a `priority=Priority.LOW` client issues a batched operation and every batched request carries `X-Priority: low`. (SC-006 batch) -- [X] T010 [P] [US1] Add a test that a `priority=Priority.NORMAL` client emits `X-Priority: normal` on requests (an explicitly configured default is always emitted, not omitted), both clients. (US1 acceptance #4, FR-006) +- [X] T010 [P] [US1] Add a test that a `priority=Priority.MEDIUM` client emits `X-Priority: medium` on requests (an explicitly configured default is always emitted, not omitted), both clients. (US1 acceptance #4, FR-006) **Checkpoint**: US1 fully testable and green independently — this is a shippable MVP. @@ -110,7 +110,7 @@ Single-project Python library. Production code under `infrahub_sdk/`; tests unde - [X] T021 [P] [US2] Test: no-default client + `priority=Priority.HIGH` on `execute_graphql` emits `X-Priority: high`; a following un-annotated call emits no header (no leak). Both clients. (SC-003, US2 acceptance #1/#2) - [X] T022 [P] [US2] Test: `priority=Priority.LOW` default client + per-request `priority=Priority.HIGH` emits `X-Priority: high` for that call, and the next un-annotated call reverts to `X-Priority: low`. Both clients. (SC-003, US2 acceptance #3) -- [X] T023 [P] [US2] Test: `priority=Priority.LOW` default client + per-request `priority=Priority.NORMAL` emits `X-Priority: normal` (explicit step-up wins). Both clients. (spec Edge Cases, SC-003) +- [X] T023 [P] [US2] Test: `priority=Priority.LOW` default client + per-request `priority=Priority.MEDIUM` emits `X-Priority: medium` (explicit step-up wins). Both clients. (spec Edge Cases, SC-003) - [X] T024 [P] [US2] Test the override on the covered surfaces: `get`, `all` (multi-page — assert every page request carries the override), `create`, `save`, a diff method, and `_execute_graphql_with_file`. Both clients. (FR-005, critique E2/E3) **Checkpoint**: Override works and resolves correctly on every covered surface, both clients. @@ -128,7 +128,7 @@ Single-project Python library. Production code under `infrahub_sdk/`; tests unde ### Tests - [X] T025 [P] [US4] In `tests/unit/sdk/test_config.py`, assert `Config(address="http://localhost:8000", priority="lowe")` raises `pydantic.ValidationError` (use `pytest.raises(..., match=...)`); assert no request is issued. (SC-004, FR-007) -- [X] T026 [P] [US4] Assert case-insensitive acceptance: `Config(priority="LOW")`, `Config(priority="Low")`, `Config(priority="low")`, and `Config(priority=Priority.LOW)` all yield `Priority.LOW`; likewise for HIGH/NORMAL. Include the env-var path `INFRAHUB_PRIORITY=LOW` via `monkeypatch`. (SC-004, FR-002) +- [X] T026 [P] [US4] Assert case-insensitive acceptance: `Config(priority="LOW")`, `Config(priority="Low")`, `Config(priority="low")`, and `Config(priority=Priority.LOW)` all yield `Priority.LOW`; likewise for HIGH/MEDIUM. Include the env-var path `INFRAHUB_PRIORITY=LOW` via `monkeypatch`. (SC-004, FR-002) - [X] T027 [P] [US4] Assert `Config()` default → `priority is None` (no default, header omitted). (FR-004) **Checkpoint**: Misconfiguration fails loudly; valid config in any case is accepted. diff --git a/docs/docs/python-sdk/reference/config.mdx b/docs/docs/python-sdk/reference/config.mdx index cb29cc1d7..0d3b9e3f6 100644 --- a/docs/docs/python-sdk/reference/config.mdx +++ b/docs/docs/python-sdk/reference/config.mdx @@ -136,7 +136,7 @@ The following settings can be defined in the `Config` class ## priority -**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.
+**Description**: Default request priority emitted as the X-Priority header on every request; one of high|medium|low (case-insensitive). When unset, no header is sent.
**Type**: `object`
**Environment variable**: `INFRAHUB_PRIORITY`
diff --git a/infrahub_sdk/config.py b/infrahub_sdk/config.py index 73e8bd2dd..d43018bd7 100644 --- a/infrahub_sdk/config.py +++ b/infrahub_sdk/config.py @@ -60,7 +60,7 @@ class ConfigBase(BaseSettings): 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." ), ) retry_delay: int = Field(default=5, description="Number of seconds to wait until attempting a retry.") diff --git a/infrahub_sdk/constants.py b/infrahub_sdk/constants.py index 35a19e66d..785445d9a 100644 --- a/infrahub_sdk/constants.py +++ b/infrahub_sdk/constants.py @@ -17,7 +17,7 @@ class Priority(str, enum.Enum): """ HIGH = "high" - NORMAL = "normal" + MEDIUM = "medium" LOW = "low" @classmethod diff --git a/tests/unit/sdk/test_config.py b/tests/unit/sdk/test_config.py index 9a842dc7e..527411276 100644 --- a/tests/unit/sdk/test_config.py +++ b/tests/unit/sdk/test_config.py @@ -109,7 +109,7 @@ def test_invalid_priority_rejected() -> None: # statically types Priority | None but coerces strings at runtime, so the dynamic input is passed # via a dict[str, Any] rather than suppressed with a type-checker ignore. kwargs: dict[str, Any] = {"address": "http://localhost:8000", "priority": "lowe"} - with pytest.raises(ValidationError, match=r"Input should be 'high', 'normal' or 'low'"): + with pytest.raises(ValidationError, match=r"Input should be 'high', 'medium' or 'low'"): Config(**kwargs) @@ -125,10 +125,10 @@ class PriorityCase: PriorityCase(name="high-title", value="High", expected=Priority.HIGH), PriorityCase(name="high-lower", value="high", expected=Priority.HIGH), PriorityCase(name="high-enum", value=Priority.HIGH, expected=Priority.HIGH), - PriorityCase(name="normal-upper", value="NORMAL", expected=Priority.NORMAL), - PriorityCase(name="normal-title", value="Normal", expected=Priority.NORMAL), - PriorityCase(name="normal-lower", value="normal", expected=Priority.NORMAL), - PriorityCase(name="normal-enum", value=Priority.NORMAL, expected=Priority.NORMAL), + PriorityCase(name="medium-upper", value="MEDIUM", expected=Priority.MEDIUM), + PriorityCase(name="medium-title", value="Medium", expected=Priority.MEDIUM), + PriorityCase(name="medium-lower", value="medium", expected=Priority.MEDIUM), + PriorityCase(name="medium-enum", value=Priority.MEDIUM, expected=Priority.MEDIUM), PriorityCase(name="low-upper", value="LOW", expected=Priority.LOW), PriorityCase(name="low-title", value="Low", expected=Priority.LOW), PriorityCase(name="low-lower", value="low", expected=Priority.LOW), @@ -151,7 +151,7 @@ def test_priority_case_insensitive_acceptance(case: PriorityCase) -> None: [ pytest.param("LOW", Priority.LOW, id="low"), pytest.param("HIGH", Priority.HIGH, id="high"), - pytest.param("NORMAL", Priority.NORMAL, id="normal"), + pytest.param("MEDIUM", Priority.MEDIUM, id="medium"), ], ) def test_priority_from_env_var(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: Priority) -> None: diff --git a/tests/unit/sdk/test_priority.py b/tests/unit/sdk/test_priority.py index abf5935f7..2f7b9cda5 100644 --- a/tests/unit/sdk/test_priority.py +++ b/tests/unit/sdk/test_priority.py @@ -685,17 +685,17 @@ class ResolutionCase: ResolutionCase(name="no-default-no-override", client_default=None, per_request=None, expected=None), ResolutionCase(name="no-default-override-high", client_default=None, per_request=Priority.HIGH, expected="high"), ResolutionCase( - name="no-default-override-normal", client_default=None, per_request=Priority.NORMAL, expected="normal" + name="no-default-override-medium", client_default=None, per_request=Priority.MEDIUM, expected="medium" ), ResolutionCase(name="low-default-no-override", client_default=Priority.LOW, per_request=None, expected="low"), ResolutionCase( name="low-default-override-high", client_default=Priority.LOW, per_request=Priority.HIGH, expected="high" ), ResolutionCase( - name="low-default-override-normal", client_default=Priority.LOW, per_request=Priority.NORMAL, expected="normal" + name="low-default-override-medium", client_default=Priority.LOW, per_request=Priority.MEDIUM, expected="medium" ), ResolutionCase( - name="normal-default-no-override", client_default=Priority.NORMAL, per_request=None, expected="normal" + name="medium-default-no-override", client_default=Priority.MEDIUM, per_request=None, expected="medium" ), ResolutionCase( name="high-default-override-low", client_default=Priority.HIGH, per_request=Priority.LOW, expected="low" diff --git a/tests/unit/sdk/test_relogin_headers.py b/tests/unit/sdk/test_relogin_headers.py index 59d9369f2..76dd913c3 100644 --- a/tests/unit/sdk/test_relogin_headers.py +++ b/tests/unit/sdk/test_relogin_headers.py @@ -79,7 +79,7 @@ def test_merge_request_headers_layers_delta_over_live_base(client_type: str) -> an explicit per-request override — of a normal header or of auth itself — takes precedence. """ client = _build_password_client(client_type) - client.headers["X-Priority"] = "normal" + client.headers["X-Priority"] = "medium" # A delta carrying only a per-request priority override (no auth). merged = client._merge_request_headers({"X-Priority": "high"})