Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7f80f1d
docs(speckit): specify frontend request prioritization (IFC-2890)
dgarros Jul 14, 2026
b911d04
docs(speckit): plan frontend request prioritization (IFC-2890)
dgarros Jul 14, 2026
e6a40ee
docs(speckit): critique + apply recommendations (IFC-2890)
dgarros Jul 14, 2026
9b85ff8
docs(speckit): tasks for frontend request prioritization (IFC-2890)
dgarros Jul 14, 2026
4b4fa9a
docs(speckit): alignment check — ALIGNED (IFC-2890)
dgarros Jul 14, 2026
a35155e
docs(changelog): add IFC-2890 frontend X-Priority emitter fragment
dgarros Jul 14, 2026
86a2ab5
chore(speckit): tick T001-T002 (chunk 1 Setup)
dgarros Jul 14, 2026
6cfe988
feat(frontend): add shared X-Priority request contract
dgarros Jul 14, 2026
6822c29
chore(speckit): tick T003-T004 (chunk 2 Foundational)
dgarros Jul 14, 2026
8dcf100
feat(frontend): wire X-Priority: high default across all transports
dgarros Jul 14, 2026
4a27614
chore(speckit): tick T005-T012 (chunk 3 US1)
dgarros Jul 14, 2026
d477fa5
feat(frontend): make X-Priority low opt-in real, tested, documented
dgarros Jul 14, 2026
0b86aa2
chore(speckit): tick T013-T018 (chunk 4 US2)
dgarros Jul 14, 2026
54aece0
test(priority): guard watched live-status polls emit X-Priority high
dgarros Jul 14, 2026
41f5022
chore(speckit): tick T019-T022 (chunk 5 US3)
dgarros Jul 14, 2026
8de1fc2
test(priority): assert X-Priority survives 401 replay and file upload…
dgarros Jul 14, 2026
354bcb3
chore(speckit): tick T023-T026 (chunk 6 US4)
dgarros Jul 14, 2026
ed7af04
feat(api): allow x-priority CORS header and exempt preflight from adm…
dgarros Jul 14, 2026
6cf8656
chore(speckit): tick T027-T030 (chunk 7 US5)
dgarros Jul 14, 2026
76f3161
test(frontend): add X-Priority E2E scenario (IFC-2890 US6)
dgarros Jul 14, 2026
612d6bc
chore(speckit): tick T031-T032 (chunk 8 US6)
dgarros Jul 14, 2026
e5e15d5
docs(frontend): add X-Priority request-priority knowledge doc (IFC-2890)
dgarros Jul 14, 2026
c858ecd
chore(speckit): tick T033-T036 (chunk 9 Polish)
dgarros Jul 14, 2026
0bc2ebf
docs(speckit): implementation report — DONE (IFC-2890)
dgarros Jul 14, 2026
188fb3e
ci: reword IFC-2890 changelog fragment to satisfy vale spelling
dgarros Jul 15, 2026
5be38ed
review
bilalabbad Jul 15, 2026
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
15 changes: 15 additions & 0 deletions backend/infrahub/api/admission/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
)

_PRIORITY_HEADER = b"x-priority"
_CORS_REQUEST_METHOD_HEADER = b"access-control-request-method"

_SHED_MESSAGE = "Server is shedding load; retry later."

Expand Down Expand Up @@ -76,6 +77,13 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
return

# A CORS preflight carries no X-Priority and would be classified NORMAL. Shedding it under
# load would strip the CORS response and break every cross-origin request precisely when the
# backend is busy, so preflights bypass the gate and reach the downstream CORS middleware.
if _is_cors_preflight(scope):
await self.app(scope, receive, send)
return

parsed = parse_priority(_read_priority_header(scope))
if not parsed.was_explicit:
metrics.MISSING_PRIORITY_TOTAL.inc()
Expand All @@ -96,6 +104,13 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
assert_never(decision)


def _is_cors_preflight(scope: Scope) -> bool:
"""Return whether the request is a CORS preflight (OPTIONS advertising a requested method)."""
if scope.get("method") != "OPTIONS":
return False
return any(name == _CORS_REQUEST_METHOD_HEADER for name, _ in scope["headers"])


def _read_priority_header(scope: Scope) -> str | None:
for name, value in scope["headers"]:
if name == _PRIORITY_HEADER:
Expand Down
2 changes: 1 addition & 1 deletion backend/infrahub/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def default_cors_allow_methods() -> list[str]:


def default_cors_allow_headers() -> list[str]:
return ["accept", "authorization", "content-type", "user-agent", "x-csrftoken", "x-requested-with"]
return ["accept", "authorization", "content-type", "user-agent", "x-csrftoken", "x-requested-with", "x-priority"]


def default_append_git_suffix_domains() -> list[str]:
Expand Down
76 changes: 76 additions & 0 deletions backend/tests/component/api/test_cors_priority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import httpx
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware

from infrahub.api.admission.controller import AdmissionController
from infrahub.api.admission.middleware import AdmissionMiddleware
from infrahub.api.admission.slot_pool import PrioritySlotPool
from infrahub.config import default_cors_allow_headers, default_cors_allow_methods

_ORIGIN = "https://frontend.example"


def _shed_everything_controller() -> AdmissionController:
"""Controller with no slots and no waiter budget: every admitted attempt is shed."""
return AdmissionController(
slot_pool=PrioritySlotPool(max_concurrency=0),
target=0.005,
interval=0.1,
high_target_multiplier=4.0,
backstop_max_waiters=0,
retry_after=1,
)


def _build_app() -> FastAPI:
"""App wired like the server: CORS from the shipped defaults, admission outermost.

A shed-everything controller proves the preflight is not gated by admission: were it not
exempt it would be classified NORMAL and shed with a 429 that carries no CORS headers.
"""
app = FastAPI()

@app.post("/work")
async def work() -> dict[str, bool]:
return {"ok": True}

app.add_middleware(
CORSMiddleware,
allow_origins=[_ORIGIN],
allow_methods=default_cors_allow_methods(),
allow_headers=default_cors_allow_headers(),
allow_credentials=True,
)
# Added last so it is outermost, mirroring the production middleware stack.
app.add_middleware(AdmissionMiddleware, controller=_shed_everything_controller(), enabled=True)
return app


async def test_cors_preflight_allows_x_priority() -> None:
"""A cross-origin preflight succeeds and x-priority is in the allow-headers, even under shedding.

The 200 proves the preflight bypasses the (shed-everything) admission gate; the allow-headers
value proves the shipped default lets a cross-origin browser send X-Priority.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_build_app()), base_url="http://test") as client:
response = await client.options(
"/work",
headers={
"Origin": _ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "x-priority",
},
)

assert response.status_code == 200
assert "x-priority" in response.headers["access-control-allow-headers"].lower()


async def test_non_preflight_request_is_still_shed() -> None:
"""The preflight exemption is narrow: a real cross-origin request is still admission-gated."""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_build_app()), base_url="http://test") as client:
response = await client.post("/work", headers={"Origin": _ORIGIN, "X-Priority": "low"})

assert response.status_code == 429
6 changes: 6 additions & 0 deletions backend/tests/unit/config/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Settings,
StorageSettings,
UserInfoMethod,
default_cors_allow_headers,
load,
)
from tests.conftest import TestHelper
Expand All @@ -44,6 +45,11 @@ def test_load_sso_config(helper: TestHelper) -> None:
assert oidc_provider2.userinfo_method == UserInfoMethod.GET


def test_default_cors_allow_headers_includes_x_priority() -> None:
"""The shipped CORS allow-list lets cross-origin browsers send the X-Priority header."""
assert "x-priority" in default_cors_allow_headers()


def test_valid_git_settings__sync_branch_names() -> None:
import_sync_branch_names = ["main", "infrahub/.*", "release/.*"]
git_settings = GitSettings(import_sync_branch_names=import_sync_branch_names)
Expand Down
1 change: 1 addition & 0 deletions changelog/+ifc-2890.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The frontend now declares request priority on every API call via the `X-Priority` header, sending `high` by default and `low` for opt-in background traffic. The API CORS allowed-headers list now includes `x-priority` so the header is accepted from the browser.
71 changes: 71 additions & 0 deletions dev/knowledge/frontend/request-priority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Request Priority (`X-Priority`)

The frontend is a first-class emitter of the `X-Priority` request header so the backend
admission layer (IFC-2886) can serve interactive users first and shed background frontend
work first under overload.

Contract module: `frontend/app/src/shared/api/priority/index.ts`. Spec:
`specs/ifc-2890-frontend-request-priority/`.

## The contract

- `type RequestPriority = 'high' | 'low'` — the only two values the frontend may emit.
`'normal'` is the backend's fallback and is deliberately unrepresentable here.
- `DEFAULT_PRIORITY = 'high'` — every transport applies this unconditionally, so no
frontend-origin request is ever emitted `normal` or unheadered.
- `PRIORITY_HEADER = 'X-Priority'`.
- `resolvePriority(value)` — normalizes an untyped per-request value: returns `'low'`
only for exactly `'low'`, everything else (`'normal'`, `undefined`, garbage) → `'high'`.
Each transport runs its value through this before writing the header, so a stray or
legacy value cannot leak an out-of-contract priority.

## Four injection points (default `high`)

The header is stamped at each transport entry, not at call sites:

1. **Apollo GraphQL** — `priorityLink` (`setContext`) in
`shared/api/graphql/graphqlClientApollo.tsx`, inserted into the link chain. Uploads
ride the same terminating `createUploadLink`, so they inherit it for free.
2. **REST (`openapi-fetch`)** — `authMiddleware.onRequest` in `shared/api/rest/client.ts`.
The header is set before the `Request` clone captured for 401 replay, so replay
preserves it.
3. **Raw fetch** — `fetchUrl` in `shared/api/rest/fetch.ts`, **origin-guarded**: the
header is stamped only when the URL's origin matches the Infrahub API server, so it
never leaks to a non-Infrahub host (FR-007).
4. **GraphiQL fetcher** — `shared/libs/graphiql/use-graphiql-fetcher.ts` sets
`X-Priority: high` on its raw sandbox fetch.

The header survives both 401-refresh replay paths (Apollo `...oldHeaders` spread; REST
stored clone) and the file-upload rebuild path.

## Opting a request down to `low` (one convention per transport)

The default is `high`; an undeclared request needs no change. To demote a single request,
declare it at the call site using its transport's idiom:

- **GraphQL** — `context: { priority: 'low' }` on the operation.
- **REST** — pre-set the header via `params: { header: { 'X-Priority': 'low' } }`
(openapi-fetch's `options` is read-only and exposes no custom field, so the header
itself is the opt-in surface).
- **Raw fetch** — the `{ priority: 'low' }` option argument to `fetchUrl`.

No helper wraps these: the v1 `low` set is empty (no production caller demotes yet), so a
helper would serve only tests (YAGNI). The mechanism plus convention is the deliverable.

## Watched status stays `high`

Watched live-status polls (task list/status, proposed-change details/events, branch action
state) are left **undeclared**, so they inherit the `high` default even though they poll on
an interval — they carry data a user is actively watching and must not be shed. Tests assert
their `high`-ness explicitly and that none is declared `low` (FR-005).

## Backend note (CORS)

Two additive backend changes let cross-origin frontends (dev/split-host) send the header:

- `backend/infrahub/config.py` adds `x-priority` to the `default_cors_allow_headers()`
default, so the CORS preflight allow-lists it.
- `backend/infrahub/api/admission/middleware.py` exempts CORS `OPTIONS` preflight from
admission. A preflight never carries a custom header, so without the exemption it would
arrive as `normal` and could be shed under load — breaking cross-origin requests exactly
when the feature matters.
40 changes: 40 additions & 0 deletions dev/specs/ifc-2890-frontend-request-priority/alignment-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Spec/Ask Alignment Check: Frontend Request Prioritization (`X-Priority`)

**Date**: 2026-07-14
**Feature dir**: `specs/ifc-2890-frontend-request-priority/`
**Verdict**: ✅ ALIGNED

## Source

- **Source PRD**: [IFC-2890](https://opsmill.atlassian.net/browse/IFC-2890) — "Frontend request prioritization via `X-Priority` header." The ticket description is a full, structured PRD (problem statement, 7 user stories, 3 prioritised journeys, 7 functional requirements, key entities, edge cases, 4 success criteria, implementation/testing decisions, governance gates, assumptions, out-of-scope, 2 open questions).
- **Resolution**: fetched via the Atlassian MCP (`getJiraIssue`, markdown format) — the Jira URL is auth-gated, so `WebFetch` would not resolve it; the MCP fetch succeeded and returned the complete description.
- **Compared against**: the current `spec.md` (post-critique).

## Findings

Comparing the source PRD to `spec.md` for **significant** drift (missing / added-not-necessary / semantically changed / dropped-softened / contradicted). Cosmetic and expansion-of-detail differences are not drift.

| Severity | Category | PRD reference | Spec reference | Description |
|----------|----------|---------------|----------------|-------------|
| ✅ none | missing | FR-001…FR-007 | spec FR-001…FR-007 | All seven functional requirements carried over verbatim, including the per-transport `Verify` clauses. |
| ✅ none | missing | 7 user stories | spec User Stories 1–6 | All 7 PRD stories represented. PRD US1 ("stay responsive") + US2 ("`high` by default, 89 sites") are consolidated into spec Story 1; PRD US3→Story 2, US4→Story 3, US5→Story 6, US6→Story 4, US7→Story 5. Consolidation, not omission. |
| ✅ none | dropped/softened | SC-001…SC-004 | spec SC-001…SC-004 | All four success criteria carried verbatim, including SC-004's explicit "joint / not a v1 blocker" framing — not softened beyond what the PRD itself states. |
| ✅ none | changed | Journeys P1–P3, Key Entities, Edge Cases, Governance Gates, Out of Scope | corresponding spec sections | Semantics preserved. Governance CORS "Ask First" gate and cooperative-trust assumption retained. |
| ✅ none | resolved | 2 Open Questions | spec Assumptions | Both PRD open questions resolved exactly as the prep input directed (unified `low` opt-in helper; possibly-empty initial `low` set) and recorded in Assumptions — the PRD explicitly deferred these "to the spec/plan step." |
| ℹ️ info (not drift) | added | — | spec Assumption on SC-001 measurement | Clarifies *how* SC-001 is validated given the backend counter is unlabeled (research §10). A necessary clarification of an existing PRD criterion, not new scope. |
| ℹ️ info (not drift) | added | FR-003 | spec Edge Case "invalid/unknown opt-in value" | Restates/defends FR-003 ("never emit anything but `high`/`low`"). A necessary clarification, not a new requirement. |
| ℹ️ info (not drift) | added | PRD Implementation Decisions ("No … GraphQL schema change") | spec Out of Scope "no GraphQL schema change" | Already stated in the PRD; making it explicit in Out of Scope is faithful, not additive. |

### Note on plan/tasks refinements (outside this spec.md comparison)

The critique introduced three risk-driven refinements that live in `plan.md` / `contracts/` / `tasks.md`, **not** in `spec.md`, so they do not affect spec↔PRD alignment:

- Per-transport value normalization to the `high`/`low` union (strengthens FR-003).
- Origin-based (not substring) external-host guard (refines FR-007's implementation).
- CORS `OPTIONS` preflight must not be shed by the outermost admission middleware (ensures FR-006 / Story 5 acceptance — "the request succeeds" — actually holds under the feature's own load premise). Scoped as a verify-first task (exempt-or-fix, record finding), not a mandated new build.

These strengthen the delivery of existing PRD requirements; none adds user-facing scope or reverses a PRD decision.

## Action

**Proceed.** `spec.md` faithfully reflects the IFC-2890 PRD: every requirement, story, journey, success criterion, entity, edge case, governance gate, assumption, and out-of-scope item is present with preserved semantics, and the two open questions were resolved as directed. The additions are necessary clarifications or implementation-layer refinements. No remediation pass required.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Specification Quality Checklist: Frontend Request Prioritization (`X-Priority`)

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-07-14
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- The `X-Priority` header name is a domain concept established by the backend contract (IFC-2886), not a frontend implementation detail — it is the observable interface this feature targets, so its presence in the spec is intentional and permitted.
- The two PRD open questions were resolved autonomously and recorded in the Assumptions section: (1) the `low` opt-in is a single unified helper spanning the GraphQL `context` declaration and a REST per-request option; (2) the initial `low` set may be empty in v1 — the deliverable is the mechanism + convention. The precise opt-in API surface is finalized in the plan step.
- All items pass. Spec is ready for `/speckit-plan`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Contract: CORS Preflight Allows `x-priority`

**Feature**: IFC-2890 | **Type**: Backend CORS contract

Covers FR-006: cross-origin frontends must be able to send `X-Priority`.

## Change

`backend/infrahub/config.py :: default_cors_allow_headers()` appends `"x-priority"` to its returned list. The value flows through `ApiSettings.cors_allow_headers` → `InfrahubCORSMiddleware` (`backend/infrahub/middleware.py`) → the `Access-Control-Allow-Headers` response.

New default list (order not significant):

```
accept, authorization, content-type, user-agent, x-csrftoken, x-requested-with, x-priority
```

## Guarantees

```
GIVEN a cross-origin browser about to send a request carrying X-Priority
WHEN it issues the CORS preflight
OPTIONS <any Infrahub API path>
Origin: https://frontend.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: x-priority
THEN the response is 200
AND `Access-Control-Allow-Headers` includes `x-priority`

GIVEN the preflight succeeded
WHEN the actual cross-origin request carrying `X-Priority: high` is sent
THEN the browser does not block it
AND the request is accepted and processed by the server

GIVEN the admission layer (IFC-2886) is at or beyond capacity
WHEN a CORS `OPTIONS` preflight (which carries no `X-Priority`) arrives
THEN it is NOT shed/rejected by admission
AND it still receives its CORS response
```

> **Preflight vs admission** (critique E2): `AdmissionMiddleware` is registered outermost (`server.py:221`) and excludes by path, not method. A preflight carries no `X-Priority`, so without an `OPTIONS`/safe-method exemption it would be classified `normal` and could be shed under saturating load — breaking cross-origin requests exactly when the feature matters. This contract requires that preflights bypass admission (short-circuit `OPTIONS`, or treat preflight/safe methods as non-sheddable). If admission already exempts `OPTIONS`, record that; otherwise the exemption is part of this feature.

## Notes

- The header value semantics are unchanged; the admission layer (IFC-2886) already parses `x-priority`. This contract only concerns the CORS allow-list.
- Same-origin production deployments do not preflight; this contract is verified explicitly precisely because same-origin cannot catch a missing allow-list entry (the "passes in prod, fails in dev" trap).
- Operators may override `cors_allow_headers` via `INFRAHUB_API_CORS_ALLOW_HEADERS`; an override that omits `x-priority` would break cross-origin priority — documented, not enforced.

## Verification

A backend component test issues the OPTIONS preflight above against a test app whose CORS config is built from the shipped default and asserts `x-priority` appears in `Access-Control-Allow-Headers`. See [quickstart.md](../quickstart.md).
Loading
Loading