skip to content

An API endpoint starts returning HTTP 406 Not Acceptable to some clients in production. How do you diagnose and fix it?

level: seniorimportance: nice to knowfreq 24%

answer

  1. log Accept* as received at the origin
  2. compare with producible representations
  3. segment by region / SDK / route
  4. replay the exact header with curl, edge vs direct
  5. alert on 406 rate per route

basics

~20 s

Capture the exact Accept, Accept-Language and Accept-Encoding headers the server actually received, compare them with the representations the server can produce, and find what changed — usually a proxy rewriting Accept or a removed serialiser. Fix by restoring the representation or relaxing to a default.

solid answer

~1 min

406 is decided from the request's `Accept*` headers versus the representations the server can produce, so diagnosis is a two-sided comparison. 1. **Log the headers as received at the origin**, not as the client believes it sent them. `Accept`, `Accept-Language`, `Accept-Encoding` — the difference between those two views is usually the whole answer, because gateways and security appliances rewrite headers. 2. **Enumerate what the server can produce** for that route: registered serialisers/message converters, and whether a recent refactor removed one (dropping an XML converter, a media-type typo in a `produces` declaration). 3. **Segment the failures.** Which clients, SDK versions, regions, or CDN POPs? A per-region split points at an intermediary; a per-SDK split points at a client change. 4. **Reproduce with curl** using the exact received header, hitting the origin directly and then through the edge; a difference localises the rewrite. Common root causes: a new gateway appending an `Accept` value, a client requesting a retired vendor media type, a serialiser removed in a refactor, or a framework configured for strict negotiation. Fix by restoring the representation, correcting the client, or falling back to a default — and add a 406-rate alert so it is never discovered by a support ticket.

code

bash · 3 lines
bash
H='Accept: application/vnd.example+json;version=3'
curl -si -H "$H" https://api.example.com/users/42 | head -1
curl -si -H "$H" -H 'Host: api.example.com' https://origin.internal/users/42 | head -1

go deeper

for a junior

Know that 406 comes from the Accept-family headers and that the first move is to look at the headers the server actually received.

for a middle

Give the two-sided comparison — received headers versus producible representations — and reproduce with curl using the logged header.

for a senior

Segment failures by region, SDK and route to localise an intermediary versus a server change, cover the common root causes, and choose deliberately between restoring the representation and falling back.

for a principal

Treat it as an observability and contract-governance gap: representations should be described and monitored so their removal is caught in review, with alerting that makes negotiation drift visible before customers report it.

## Why 406 is confusing in production Unlike most 4xx codes, 406 blames a header the client's author probably never set deliberately and cannot see in their own code, and it depends on server-side capability that may have changed without any client change. Both sides feel innocent, which is why it burns time. ## Step 1 — see the headers the origin actually received The single highest-value action. Log, at the origin, the exact values of `Accept`, `Accept-Language` and `Accept-Encoding` on failing requests, alongside route and client identity. Headers get modified in transit constantly: API gateways add or replace `Accept`, WAFs and security appliances normalise headers, service meshes inject defaults, and some CDNs rewrite `Accept` for image optimisation. If the client swears it sends `Accept: application/json` and the origin logs `application/xml`, you have found it in one step. ## Step 2 — enumerate the server's producible representations List what this route can actually emit today: - Which serialisers/message converters are registered, and for which media types. - What the handler declares it produces — a typo like `applicaton/json` or a stray parameter such as `application/json;charset=UTF-8` on a client range that specifies no parameter can silently fail to match. - Whether a dependency upgrade dropped a converter (removing an XML or CSV serialiser is a classic). Step 1 and step 2 together are the whole decision: the intersection is empty, and one of the two sides moved. ## Step 3 — segment the failing population `406 count` grouped by client/user-agent, SDK version, route, region and CDN POP: - **By region or POP** → an intermediary in that path is rewriting headers. - **By SDK version** → a client-side change; an upgraded SDK now requests a media type you retired, or a downgraded one requests an old vendor version. - **By route only** → a server-side change to that handler. - **All clients at once, aligned to a deploy** → your deploy removed a representation. ## Step 4 — reproduce deterministically Take the header exactly as the origin logged it and replay it: ``` curl -i -H 'Accept: application/vnd.example+json;version=3' https://api.example.com/users/42 ``` Then bypass the edge and hit the origin directly with the header the *client* claims to send. Four combinations (client header vs logged header) × (through edge vs direct) localise the problem precisely. ## Frequently-found root causes - **Intermediary rewrite.** A gateway appends or replaces `Accept`. - **Retired media type.** A client still asks for `application/vnd.example+json;version=1` after you removed v1 — and the failure mode is a 406, which is arguably correct but must be a deliberate, announced decision, not a surprise. - **Removed serialiser.** A refactor deleted the XML or CSV converter while clients still ask for it. - **Over-specified parameters.** A handler that produces `application/json;charset=utf-8` versus a client range specifying `application/json` — most negotiators handle this, some strict configurations do not. - **Accept-Language or Accept-Encoding**, not `Accept`. Strict language negotiation with a narrow catalogue can 406, and a server refusing an unsupported coding rather than falling back to identity can too. Do not tunnel-vision on `Accept`. - **Framework default strictness.** Most frameworks 406 when no writer matches; some can be configured to fall back to a default representation instead. ## Fixing it In rough order of preference: 1. **Restore the missing representation** if its removal was accidental. 2. **Stop the header rewrite** at the intermediary, or make the origin tolerant of what it now receives. 3. **Fall back to a default representation** for this route, with an accurate `Content-Type` so clients can detect the substitution — the pragmatic choice for public APIs. 4. **Fix the client / publish a migration** when the client is genuinely asking for something retired, and keep the 406 as an honest signal. ## Leave the system better instrumented - Alert on **406 rate per route and per client**; it should be near zero and any step change is meaningful. - Log the received `Accept*` headers on every 406 — the incident is unsolvable without them and impossible to reconstruct after the fact. - Record which representations each route can produce as part of your API description, so removals are visible in review rather than at runtime. - Consider a synthetic check that requests each advertised media type on critical routes, so a removed serialiser fails a test rather than a customer.

  • The client insists it sends Accept: application/json but the origin returns 406. Where do you look first?
    At the header as logged by the origin, not as the client believes it sends. Gateways, WAFs, service meshes and image-optimising CDNs all rewrite or append Accept. If the logged value differs from the client's, the intermediary is the bug; if it matches, the server lost a representation.
  • Could a 406 be caused by Accept-Language or Accept-Encoding rather than Accept?
    Yes. 406 covers all proactive negotiation headers. Strict language negotiation against a narrow translation catalogue can produce it, and a server that refuses an unsupported content coding instead of falling back to identity can too. Log all three headers before concluding it is Accept.
  • What do you leave behind so this never becomes an unexplained incident again?
    An alert on 406 rate per route and per client, structured logging of the received Accept* headers on every 406, the route's producible media types recorded in the API description so removals surface in review, and a synthetic check requesting each advertised media type on critical routes.

saying these in an interview costs you the question

  • Debugging only from the client's assumed headers instead of what the origin logged.
  • Assuming 406 always concerns Accept and never Accept-Language or Accept-Encoding.
  • Blanket-disabling negotiation to make the error go away without finding what changed.
  • Confusing it with 415 and hunting through request-body handling.
  • Closing the incident without adding a 406-rate alert or header logging.

context