The canary header that never arrived
Header-based routing is described as free with Istio. It is free for one hop. On a two-hop path it is an application contract, and mine did not implement it — which the measurements showed clearly, once I changed one variable and watched what moved.
My canary setup has two mechanisms. A weighted split for general traffic, and a header override so I can exercise the new version specifically, before anyone else sees it:
http:
- name: canary-by-header
match:
- headers:
x-canary:
exact: "true"
route:
- destination: { host: api…, subset: v2 }
- name: weighted-split
route:
- destination: { host: api…, subset: v1 }
weight: 90
- destination: { host: api…, subset: v2 }
weight: 10
The header rule is first. First match wins. Send x-canary: true and you get v2, every time.
The weighted split measured beautifully:
| Weights in git | Requests | v1 | v2 |
|---|---|---|---|
| 90 / 10 | 200 | 171 (85.5%) | 29 (14.5%) |
| 50 / 50 | 200 | 102 (51.0%) | 98 (49.0%) |
Then I ran the header test.
CANARY_HEADER=1 ./scripts/demo-canary.sh 50
==> Sending x-canary: true — expecting 100% v2
v1 46 92.0%
v2 4 8.0%
The runbook sent me to the wrong file
My own runbook has a troubleshooting entry for exactly this:
Both versions answer when you sent the canary header Rule order. The
x-canarymatch must be the first entry underhttp:.
It is first. I checked three times.
What actually gave it away was running the test again after changing the weights:
| Weights at the time | Header test result |
|---|---|
| 90 / 10 | 46 / 4 — 92% / 8% |
| 50 / 50 | 27 / 23 — 54% / 46% |
The “header” result tracks the weights.
If the header rule were matching, the weights would be irrelevant. They were not irrelevant. So the header rule was never being evaluated against a request that carried the header.
Where the header dies
web — and died there, because web makes its own request to api.The cause is a few lines of application code:
@app.get("/")
async def index(): # no Request parameter
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{API_URL}/data") # no headers forwarded
…
index() never asks for the inbound request, so it cannot read the header. And the call it makes to api is a brand-new HTTP request that carries nothing from the original.
Istio did everything right. The gateway received the header. The api VirtualService was correctly ordered. There was simply no header left by the time it mattered.
The fix is an application contract
PROPAGATE = {"x-canary", "x-request-id",
"x-b3-traceid", "x-b3-spanid", "x-b3-sampled"}
@app.get("/")
async def index(request: Request):
fwd = {k: v for k, v in request.headers.items()
if k.lower() in PROPAGATE}
r = await client.get(f"{API_URL}/data", headers=fwd)
That list is not arbitrary. It is the same set distributed tracing needs, for the same reason — Istio’s own documentation is blunt that applications “must propagate this information in HTTP headers”: a service mesh can route on context it can see, but only the application can carry that context across a call it originates.
Anyone who has wired up tracing has written this function. I had not connected it to canary routing, because header routing is usually demonstrated on a single service, where the question never comes up.
I left it broken
I did not ship the fix. The project is published with this demonstration marked as failed, the measurements showing 46/4 where they should show 0/50, and the reason written out.
Two reasons. The honest one is that the cluster had been torn down, and the fix needs a code change, a CI run and a redeploy. The better one is that a published failure with a diagnosis is worth more than a green tick — and this particular failure is the most transferable thing in the whole project. The weighted split working proves Istio was installed correctly. The header not working proves something about how distributed systems actually behave.
It also sits exactly on the line the platform is built around: deploying and releasing are different decisions.
What I took from it
- Change one variable and watch what moves. The header result tracking the weights was the entire diagnosis. One extra run at different weights beat three readings of a correct config file.
- Your own runbook can be confidently wrong. Mine named a real cause for these symptoms — just not this one. A troubleshooting list is a list of some causes, and treating it as exhaustive sends you to inspect correct files repeatedly.
- “Free with Istio” usually means free for one hop. Header routing, tracing and anything else carried in request context all need the application to participate the moment there is a second service.
- Mesh config being correct does not mean mesh behaviour is correct.
istioctl analyzewas clean. The rule was first. Everything the mesh owned was right, and the feature still did not work. - Publish the failure. The expected-versus-measured gap, with the cause, is more convincing than another passing test.
From the GitOps delivery platform build — seventeen defects found, sixteen fixed, this one left standing.
References
- Istio: VirtualService —
HTTPMatchRequestheader matching, and rules evaluated in order. - Istio: distributed tracing — trace context propagation — “Applications must propagate this information in HTTP headers.”
- FastAPI: using the Request directly — how a handler reads the inbound headers at all.
- HTTPX: headers — an outbound request carries only the headers you give it.