← Recipes

阅读中文版本

Debug a 406 Not Acceptable with curl Header Experiments

Bisect the Accept header piece by piece to find exactly which media type the server rejects, then fix the negotiation on the correct side.

~20 minapihttpdebugging

Task

Reproduce a 406 response and isolate the exact Accept header value or parameter the server refuses to honor.

When to use this

A client sends Accept: application/json; charset=utf-8, text/html and receives 406 on one route but 200 on another, and nobody knows which half of the header offends the server.

Prerequisites

  • curl installed
  • Access to the failing route and a known-good route on the same host
  • Permission to change client headers or server negotiation config

Steps

1. Establish the baseline with the simplest possible Accept header.

curl -s -o /dev/null -w "%{http_code}\n" "https://staging.example.com/api/report" -H "Accept: application/json"

Expected output: A 200 with the minimal header proves route and auth are fine and narrows the failure to negotiation of the richer header.

2. Test each media type separately to bisect the offender.

for mt in "application/json" "application/json; charset=utf-8" "text/html" "application/vnd.api+json"; do printf "%-40s" "$mt"; curl -s -o /dev/null -w "%{http_code}\n" "https://staging.example.com/api/report" -H "Accept: $mt"; done

Expected output: Status codes print per media type; typically one line shows 406 while others show 200 or 406 only for the parameterized variant.

3. Check what the server actually offers via a HEAD request or OPTIONS.

curl -s -I -X HEAD "https://staging.example.com/api/report" -H "Accept: */*" | grep -i "content-type\|vary\|allow"

Expected output: The Content-Type the server prefers is revealed, plus Vary: Accept if negotiation is intentional, distinguishing a feature from a bug.

4. Fix the correct side based on who owns the contract.

// client fix: stop asking for what you cannot consume
const res = await fetch(url, { headers: { accept: 'application/json' } });

// server fix (Express): honor parameters when comparing types
app.get('/api/report', (req, res) => {
  const accepts = req.accepts(['application/json', 'text/html']);
  if (!accepts) return res.status(406).json({ error: 'unsupported media type' });
  res.format({ json: () => res.json(report), html: () => res.render('report', report) });
});

Expected output: After the fix, the original compound Accept header returns 200 with the negotiated Content-Type echoed in the response.

Troubleshooting

Problem

Every single media type returns 200 but the compound header fails

Solution

The parser mishandles parameters or quality values in list headers; split the client request into the one type it truly needs, or fix the parser to compare MIME types before parameters.

Problem

406 occurs only behind the API gateway

Solution

The gateway rewrites or validates Accept against a stricter allow list than the origin; compare the header the origin sees with a gateway bypass request and align the lists.

Problem

A 405 is reported instead of 406

Solution

The method is wrong for the route, not the media type; fix the verb first, because servers evaluate method before negotiation.

Limitations

  • curl proves what one server does today; RFC preferences allow implementations latitude, so verify across versions after any gateway upgrade.
  • Quality values (q=) ordering is ignored by many servers; do not rely on it for priority-based negotiation.

Bisect the header, do not guess the server

A 406 means the server understood the request but refuses to represent it as any type you offered. Compound Accept headers hide which entry offended. Testing each media type in isolation converts guesswork into a one-line answer.

for mt in "application/json" \
         "application/json; charset=utf-8" \
         "text/html" \
         "application/vnd.api+json"; do
  printf "%-40s -> " "$mt"
  curl -s -o /dev/null -w "%{http_code}\n" \
    "https://staging.example.com/api/report" -H "Accept: $mt"
done
# application/json                        -> 200
# application/json; charset=utf-8         -> 406   <-- parameter mishandled
# text/html                                -> 200
# application/vnd.api+json                 -> 406   <-- type never registered

Distinguish bug from feature

Vary: Accept in the response means the server intentionally negotiates. A missing type in that negotiation is then a server-side registration gap, not a client error. Without Vary, a 406 is more likely middleware enforcing an allow list nobody remembers writing.

Fix the side that owns the contract

If the client cannot actually consume text/html, stop asking for it: the fix is one header. If the server promises JSON-with-parameters, register the parser correctly. The bisect output tells you which conversation to have, ending the client-versus-server blame cycle.