Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog/+request-context-priority.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The request priority (`X-Priority` header) can now be carried on the client's `RequestContext` via a new `priority` field, alongside the existing client-wide `Config.priority` default and per-call `priority=` override. Resolution precedence is per-call `priority=` > `request_context.priority` > `Config.priority` > no header. The priority is emitted as a header only and is never included in the mutation body. Works identically on `InfrahubClient` and `InfrahubClientSync`.
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`.
2 changes: 1 addition & 1 deletion docs/docs/python-sdk/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ The following settings can be defined in the `Config` class
## priority

<!-- vale on -->
**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.<br />
**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.<br />
**Type**: `object`<br />
**Environment variable**: `INFRAHUB_PRIORITY`<br />
<!-- vale off -->
Expand Down
7 changes: 5 additions & 2 deletions infrahub_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,11 @@ def _request_headers(self, tracker: str | None = None, priority: Priority | None
headers: dict = {}
if self.insert_tracker and tracker:
headers["X-Infrahub-Tracker"] = tracker
if priority is not None:
headers["X-Priority"] = priority.value
effective_priority = priority
if effective_priority is None and self._request_context is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: File downloads, artifact-generation calls, and password-authentication requests still omit RequestContext.priority because they bypass _request_headers; the companion admission layer classifies a missing header as MEDIUM at opsmill/infrahub#9879/backend/infrahub/api/admission/middleware.py:87-92, so high/low work on these paths can be admitted or shed at the wrong tier. Centralizing the context-priority merge in the low-level transports would cover all request paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/client.py, line 258:

<comment>File downloads, artifact-generation calls, and password-authentication requests still omit `RequestContext.priority` because they bypass `_request_headers`; the companion admission layer classifies a missing header as MEDIUM at opsmill/infrahub#9879/backend/infrahub/api/admission/middleware.py:87-92, so high/low work on these paths can be admitted or shed at the wrong tier. Centralizing the context-priority merge in the low-level transports would cover all request paths.</comment>

<file context>
@@ -254,8 +254,11 @@ def _request_headers(self, tracker: str | None = None, priority: Priority | None
-        if priority is not None:
-            headers["X-Priority"] = priority.value
+        effective_priority = priority
+        if effective_priority is None and self._request_context is not None:
+            effective_priority = self._request_context.priority
+        if effective_priority is not None:
</file context>

effective_priority = self._request_context.priority
if effective_priority is not None:
headers["X-Priority"] = effective_priority.value
return headers

def _merge_request_headers(self, headers: dict | None) -> dict:
Expand Down
2 changes: 1 addition & 1 deletion infrahub_sdk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion infrahub_sdk/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Priority(str, enum.Enum):
"""

HIGH = "high"
NORMAL = "normal"
MEDIUM = "medium"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Existing SDK callers using Priority.NORMAL will fail at runtime after upgrading, making this a public API compatibility break. Keeping NORMAL as an alias of MEDIUM preserves those callers while emitting the new medium wire value.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/constants.py, line 20:

<comment>Existing SDK callers using `Priority.NORMAL` will fail at runtime after upgrading, making this a public API compatibility break. Keeping `NORMAL` as an alias of `MEDIUM` preserves those callers while emitting the new `medium` wire value.</comment>

<file context>
@@ -17,7 +17,7 @@ class Priority(str, enum.Enum):
 
     HIGH = "high"
-    NORMAL = "normal"
+    MEDIUM = "medium"
     LOW = "low"
 
</file context>
Suggested change
MEDIUM = "medium"
MEDIUM = "medium"
NORMAL = MEDIUM

LOW = "low"

@classmethod
Expand Down
5 changes: 5 additions & 0 deletions infrahub_sdk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from pydantic import BaseModel, Field

from .constants import Priority # noqa: TC001 (pydantic needs this at runtime to build the model schema)


class ContextAccount(BaseModel):
id: str = Field(..., description="The ID of the account")
Expand All @@ -11,3 +13,6 @@ class RequestContext(BaseModel):
"""The context can be used to override settings such as the account within mutations."""

account: ContextAccount | None = Field(default=None, description="Account tied to the context")
priority: Priority | None = Field(
default=None, description="Request priority emitted as the X-Priority header (not part of the mutation body)"
)
5 changes: 3 additions & 2 deletions infrahub_sdk/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,15 @@ def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)

def _get_request_context(self, request_context: RequestContext | None = None) -> dict[str, Any] | None:
# priority rides the X-Priority header, not the mutation body — the server context input has no such field
if request_context:
return request_context.model_dump(exclude_none=True)
return request_context.model_dump(exclude_none=True, exclude={"priority"}) or None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A per-call RequestContext(priority=...) is silently ignored: excluding it here removes the value from the mutation, but the subsequent transport call does not receive that per-call context and only checks client.request_context. Resolving the explicit context's priority into the effective priority argument would preserve the documented priority= > request_context.priority > Config.priority behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/node/node.py, line 287:

<comment>A per-call `RequestContext(priority=...)` is silently ignored: excluding it here removes the value from the mutation, but the subsequent transport call does not receive that per-call context and only checks `client.request_context`. Resolving the explicit context's priority into the effective `priority` argument would preserve the documented `priority=` > `request_context.priority` > `Config.priority` behavior.</comment>

<file context>
@@ -282,14 +282,15 @@ def __setattr__(self, name: str, value: Any) -> None:
+        # priority rides the X-Priority header, not the mutation body — the server context input has no such field
         if request_context:
-            return request_context.model_dump(exclude_none=True)
+            return request_context.model_dump(exclude_none=True, exclude={"priority"}) or None
 
         client: InfrahubClient | InfrahubClientSync | None = getattr(self, "_client", None)
</file context>


client: InfrahubClient | InfrahubClientSync | None = getattr(self, "_client", None)
if not client or not client.request_context:
return None

return client.request_context.model_dump(exclude_none=True)
return client.request_context.model_dump(exclude_none=True, exclude={"priority"}) or None

def _init_relationships(self, data: dict | None = None) -> None:
pass
Expand Down
12 changes: 6 additions & 6 deletions tests/unit/sdk/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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),
Expand All @@ -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:
Expand Down
167 changes: 164 additions & 3 deletions tests/unit/sdk/test_priority.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING
Expand All @@ -8,6 +9,7 @@

from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync
from infrahub_sdk.constants import Priority
from infrahub_sdk.context import ContextAccount, RequestContext
from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync
from tests.unit.sdk.conftest import BothClients

Expand Down Expand Up @@ -685,17 +687,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"
Expand Down Expand Up @@ -827,3 +829,162 @@ async def test_related_node_fetch_forwards_priority(
]
assert tag_requests
assert all(r.headers["x-priority"] == "high" for r in tag_requests)


# ---------------------------------------------------------------------------
# request_context.priority — priority carried on the client's RequestContext
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("client_type", client_types)
async def test_request_context_priority_on_graphql(
client_type: str, clients: BothClients, httpx_mock: HTTPXMock
) -> None:
"""A priority set on the client's request_context emits X-Priority on a GraphQL request."""
httpx_mock.add_response(
method="POST",
json={"data": {"InfrahubInfo": {"version": "1.0"}}},
match_headers={"X-Priority": "low"},
)

client = getattr(clients, client_type)
client.request_context = RequestContext(priority=Priority.LOW)
query = "query { InfrahubInfo { version }}"
if client_type == "standard":
await client.execute_graphql(query=query)
else:
client.execute_graphql(query=query)

requests = [r for r in httpx_mock.get_requests() if r.method == "POST"]
assert len(requests) == 1
assert requests[0].headers["x-priority"] == "low"


@pytest.mark.parametrize("client_type", client_types)
async def test_request_context_priority_on_object_store_blob(
client_type: str, clients: BothClients, httpx_mock: HTTPXMock
) -> None:
"""Blob requests (which take no per-call priority kwarg) inherit request_context.priority."""
httpx_mock.add_response(
method="GET",
text="any content",
match_headers={"X-Priority": "low"},
)

client = getattr(clients, client_type)
client.request_context = RequestContext(priority=Priority.LOW)
if client_type == "standard":
await client.object_store.get(identifier="aaaaaaaaa")
else:
client.object_store.get(identifier="aaaaaaaaa")

requests = [r for r in httpx_mock.get_requests() if r.method == "GET"]
assert len(requests) == 1
assert requests[0].headers["x-priority"] == "low"


@pytest.mark.parametrize("client_type", client_types)
async def test_request_context_priority_not_in_mutation_body(
client_type: str, clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock
) -> None:
"""Priority rides the header only — the account still reaches the mutation body, priority never does."""
httpx_mock.add_response(
method="POST",
json={
"data": {"BuiltinLocationCreate": {"ok": True, "object": {"id": "17aec828-9814-ce00-3f20-1a053670f1c8"}}}
},
is_reusable=True,
)

client = getattr(clients, client_type)
client.request_context = RequestContext(account=ContextAccount(id="acc-1"), priority=Priority.LOW)
data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}}
if client_type == "standard":
node = InfrahubNode(client=client, schema=location_schema, data=data)
await node.save()
else:
node = InfrahubNodeSync(client=client, schema=location_schema, data=data)
node.save()

create_requests = [
r
for r in httpx_mock.get_requests()
if r.method == "POST" and r.headers.get("x-infrahub-tracker") == "mutation-builtinlocation-create"
]
assert len(create_requests) == 1
assert create_requests[0].headers["x-priority"] == "low"

# the account context is rendered into the mutation, but priority must never leak into the body
query = json.loads(create_requests[0].content)["query"]
assert "acc-1" in query
assert "priority" not in query


@dataclass
class RequestContextResolutionCase:
"""One row of the request_context priority resolution truth table."""

name: str
client_default: Priority | None
request_context_priority: Priority | None
per_request: Priority | None
expected: str | None


# Precedence: per-call kwarg > request_context.priority > client default (Config.priority) > none.
REQUEST_CONTEXT_TRUTH_TABLE = [
RequestContextResolutionCase(
name="rc-only", client_default=None, request_context_priority=Priority.LOW, per_request=None, expected="low"
),
RequestContextResolutionCase(
name="rc-none-no-default", client_default=None, request_context_priority=None, per_request=None, expected=None
),
RequestContextResolutionCase(
name="rc-beats-default",
client_default=Priority.MEDIUM,
request_context_priority=Priority.LOW,
per_request=None,
expected="low",
),
RequestContextResolutionCase(
name="kwarg-beats-rc",
client_default=None,
request_context_priority=Priority.LOW,
per_request=Priority.HIGH,
expected="high",
),
RequestContextResolutionCase(
name="default-when-rc-none",
client_default=Priority.LOW,
request_context_priority=None,
per_request=None,
expected="low",
),
]


@pytest.mark.parametrize("client_type", client_types)
@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REQUEST_CONTEXT_TRUTH_TABLE])
async def test_request_context_precedence_parity(
case: RequestContextResolutionCase, client_type: str, httpx_mock: HTTPXMock
) -> None:
"""Kwarg > request_context.priority > Config.priority default > none, identically on both clients."""
httpx_mock.add_response(method="POST", json={"data": {"InfrahubInfo": {"version": "1.0"}}})

client = _client_with_default(client_type, case.client_default)
if case.request_context_priority is not None:
client.request_context = RequestContext(priority=case.request_context_priority)
query = "query { InfrahubInfo { version }}"
kwargs = {} if case.per_request is None else {"priority": case.per_request}

if client_type == "standard":
await client.execute_graphql(query=query, **kwargs) # type: ignore[misc]
else:
client.execute_graphql(query=query, **kwargs) # type: ignore[union-attr]

requests = [r for r in httpx_mock.get_requests() if r.method == "POST"]
assert len(requests) == 1
if case.expected is None:
assert "x-priority" not in requests[0].headers
else:
assert requests[0].headers["x-priority"] == case.expected
2 changes: 1 addition & 1 deletion tests/unit/sdk/test_relogin_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
Loading