diff --git a/README.md b/README.md index 47e7e24..76ea141 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ All are `x-routeplane-*` and all are optional except the API key. Build them wit | --- | --- | --- | | `provider` | `x-routeplane-provider` | Provider or comma-separated fallback chain | | `residency` | `x-routeplane-residency` | Data-residency region (e.g. `IN`) | -| `strategy` | `x-routeplane-strategy` | `priority` \| `weighted` \| `cost` \| `latency` | +| `strategy` | `x-routeplane-strategy` | `priority` \| `weighted` \| `cost` \| `latency` \| `round_robin` \| `least_busy` | | `config` | `x-routeplane-config` | Inline routing config (JSON) | | `timeout_ms` | `x-routeplane-timeout-ms` | Upstream timeout, ms | | `use_case` | `x-routeplane-use-case` | Analytics/FinOps label | diff --git a/src/routeplane/headers.py b/src/routeplane/headers.py index 12b5dd1..1ec30a7 100644 --- a/src/routeplane/headers.py +++ b/src/routeplane/headers.py @@ -43,7 +43,8 @@ def headers( *, provider: str | None = None, residency: str | None = None, - strategy: Literal["priority", "weighted", "cost", "latency"] | None = None, + strategy: Literal["priority", "weighted", "cost", "latency", "round_robin", "least_busy"] + | None = None, config: dict[str, Any] | None = None, timeout_ms: int | None = None, use_case: str | None = None, @@ -74,7 +75,10 @@ def headers( request carries personal data and a residency region. residency: Requested data-residency region (e.g. ``"IN"``). Only enforced when the request also carries personal data. - strategy: Provider-ordering strategy. + strategy: Provider-ordering strategy; defaults to ``"priority"``. The + gateway matches case-insensitively and falls back to ``"priority"`` + for an unknown or empty value rather than erroring, so a typo + silently routes by priority. Superseded by a routing config. config: Inline routing/policy config, JSON-serialized onto the wire. timeout_ms: Per-request upstream timeout in milliseconds. use_case: Free-form use-case label for analytics/FinOps attribution. diff --git a/tests/test_headers.py b/tests/test_headers.py index a6eaa84..292a7b2 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -1,4 +1,5 @@ import json +import re from routeplane import headers from routeplane.headers import HEADER_NAMES @@ -77,3 +78,26 @@ def test_literal_values_pass_through(): assert headers(log_level="none")["x-routeplane-log-level"] == "none" assert headers(pii_mode="tokenize")["x-routeplane-pii-mode"] == "tokenize" assert headers(cache_control="no-store")["x-routeplane-cache-control"] == "no-store" + + +def test_strategy_hint_covers_every_gateway_strategy(): + # Asserted against the annotation, not a call: a Literal is not enforced at + # runtime, so passing "round_robin" would "work" even with the hint wrong. + # Type-checked callers are the ones a stale hint breaks, so pin the hint. + # + # Read as raw source (the module uses `from __future__ import annotations`) + # rather than via get_type_hints: resolving it would evaluate `... | None`, + # which is a TypeError on the 3.9 leg of the support matrix. + # PEP 563 stores the unparsed AST, so quoting is normalized, not verbatim + # source — match either style. + annotation = headers.__annotations__["strategy"] + literal = re.search(r"Literal\[(.*?)\]", annotation, re.S) + assert literal, f"strategy hint is no longer a Literal: {annotation}" + assert set(re.findall(r"['\"]([^'\"]+)['\"]", literal.group(1))) == { + "priority", + "weighted", + "cost", + "latency", + "round_robin", + "least_busy", + }