Skip to content

13 min read

Request headers and wire capture

On this page

Reproducing a call outside your service is the first move in almost every provider incident: you have a failing request in a log, and you want to run it by hand and see the error for yourself. The obstacle is that a request is not a body. A provider client also decides the URL and the method, and it attaches a set of headers — a credential, an API version, a content type, sometimes a flag naming a capability that is still in testing — none of which appear in a body you logged at the call site. The part you have to supply on a replay is exactly the part you never wrote.

The response headers matter as much as the request ones, because two of the most useful things in a provider response — a request identifier the vendor can look up, and the current state of your rate-limit budget — never appear on the typed object the client hands back.

What the client adds on your behalf

Rather than reading the client's source to find out, ask it. Both major Python clients are built on httpx, the HTTP library they use underneath, and both accept a custom instance of it, which gives you an event hook that fires on every outbound request with the fully-formed object in hand.

python
import httpx, json
from anthropic import Anthropic

def dump(request: httpx.Request) -> None:
    print(request.method, request.url)
    for k, v in request.headers.items():
        print(f"  {k}: {v}")
    print(json.dumps(json.loads(request.content), indent=2))

client = Anthropic(http_client=httpx.Client(event_hooks={"request": [dump]}))
client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=64,
    messages=[{"role": "user", "content": "ping"}],
)

Both clients also honour an environment variable that turns on debug logging of requests and responses — ANTHROPIC_LOG=debug and OPENAI_LOG=debug — which is the fastest option when you cannot change the construction of the client, for instance inside a framework that builds it for you. A local proxy such as mitmproxy pointed at by HTTPS_PROXY is the third option and the only one that also shows you the TLS-level and connection-level behaviour, at the cost of having to trust its certificate.

Run the hook and the header list is longer than most people expect.

HeaderSet byWhat happens without it
x-api-key or authorization: Bearer …Client, from the environment401
anthropic-version: 2023-06-01Client, hardcoded to the version it targets400 naming the header
content-type: application/jsonClient400 or 415, depending on endpoint
accept: application/jsonClientUsually harmless
anthropic-beta: …Client, only when you use a beta featureThe feature is silently unavailable or 400s
user-agent, x-stainless-*Client, for telemetryNothing functional
idempotency-keyClient, on some retried attemptsDuplicate work is not deduplicated
accept-encoding: gziphttpxLarger responses

The rows split into three kinds. The first four are load-bearing and are the usual cause of a failing replay; a perfectly well-formed Messages request sent with nothing but x-api-key and content-type comes back as this:

json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "anthropic-version header is required"
  }
}

The endpoint is versioned in the header rather than in the path, the client sets the version it was built against, and a replay that omits it is rejected before the body is looked at. The telemetry headers, which carry the client library name, its version, the language runtime and the operating system, are functionally inert and are worth knowing about mainly so you do not waste time wondering whether they matter.

That version header is the most arbitrary-looking of the four and the one with the clearest reason behind it, so it is worth reconstructing rather than memorising. A provider needs to be able to change the shape of what it returns — add a field, split one into two, rename something that was named badly — without breaking code that has been running for two years. There are two places to put a version that lets it do that. In the path, which forks the URL and means every caller edits a string to move; or in a header, which leaves one URL serving every contract version and lets the provider pick the serialiser per request. The second is what these APIs chose, and everything odd about the header follows from it.

Including the part that catches replays. The header has no default, and the temptation is to read that as strictness for its own sake. Consider what a default would have to be. "Newest" would mean that every contract change silently altered the responses of every caller who had not specified one, which is the exact failure the header exists to prevent. "Oldest" would freeze new integrations on a shape from years ago. There is no third answer, because the provider cannot know which contract your parser was written against unless you say. So the field is required, and a request without it is not an incomplete request — it is a request that has not said which API it is calling.

The beta header is the interesting one, because it is conditional. A beta header is an opt-in flag naming a capability the provider has released for testing but not yet made standard, and the endpoint enables that capability only for requests that carry it. The client attaches one only when you use a feature that requires it, so a replay copied from a call that used no beta feature will not show it, and a replay of a call that did will fail in a way that looks like the feature does not exist.

The opt-in is the version header's logic applied one level down, and reading it that way tells you what to expect from every flag of this kind. A capability still in testing is one whose request fields and response shapes may move, so switching it on for everybody would break callers who never asked for it — which is what the versioning arrangement exists to make impossible. Naming it on the request is the caller saying which experiment they have agreed to.

The consequence is that a missing beta header does not usually produce an error about a missing beta header. The endpoint has a perfectly good non-beta behaviour for that request, so it serves it: a field you sent is ignored because nothing is enabled to read it, or a block type you expected in the response never appears. What you get back is a valid answer to a question you did not mean to ask. That is the shape to watch for whenever a capability works in one environment and not another with identical code, and it is exactly the shape of the incident later in this chapter.

The replay that actually works

With the headers in hand, the reproduction is mechanical. Write the body to a file rather than inlining it, because shell quoting on a body containing apostrophes, newlines or non-ASCII characters is its own source of false negatives, and use --data-binary so curl sends the bytes unchanged.

bash
cat > /tmp/body.json <<'JSON'
{
  "model": "claude-sonnet-4-5",
  "max_tokens": 1024,
  "system": "Summarise support tickets in three sentences.",
  "messages": [{"role": "user", "content": "The invoice for order 88213 shows…"}]
}
JSON

curl -i https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  --data-binary @/tmp/body.json

The -i matters as much as the headers. Response headers carry the two things you most want during an incident and that the typed response object does not expose: a request identifier the provider can look up if you open a support ticket, and the current state of your rate-limit budget. A successful reply carries something like request-id: req_011CQ8kM2t9… along with anthropic-ratelimit-requests-remaining: 3941, anthropic-ratelimit-tokens-remaining: 748213 and a reset timestamp; OpenAI's equivalents are x-request-id and the x-ratelimit-remaining-requests family. Inside the SDK you can reach them without dropping to curl by asking for the raw response:

python
raw = client.messages.with_raw_response.create(
    model="claude-sonnet-4-5",
    max_tokens=64,
    messages=[{"role": "user", "content": "ping"}],
)
print(raw.headers["request-id"], raw.headers.get("anthropic-ratelimit-tokens-remaining"))
msg = raw.parse()   # the typed object you would normally have got

Capturing that identifier into your structured logs on every call, successful or not, costs one line and converts "we had errors around 02:40" into a request the provider can look up individually. It is the cheapest piece of production instrumentation in this topic. The rate-limit headers deserve the same treatment for a different reason: they are the only view you have of a budget that is being consumed by every process in your fleet at once, so a single worker's remaining-token count is a sample of a shared quantity and is far more informative in a time series than in a log line read after the fact.

Both of those facts have a mechanism behind them, and the mechanisms are what tell you how far to trust the numbers. The request identifier lives in a header rather than on the typed object because it is issued per HTTP transaction, and a transaction can fail — the responses you most want an identifier for are exactly the ones that never became a message object at all. A field on the parsed reply would be absent precisely when you need it. The remaining-budget numbers are snapshots for a related reason: the counter they report is held per account, and every worker you run is drawing it down. The value in the header was true at the instant the provider answered that request, and by the time your process reads it, other workers have spent more of it. Reading one number after an incident tells you almost nothing. Recording every number over time tells you how fast the shared counter is falling, which is the quantity that actually predicts the next chapter's failures.

There is a useful shortcut for producing the curl command in the first place. Because the event hook hands you an httpx.Request with its final headers and body, you can have it print a ready-to-run command rather than a listing you then transcribe by hand — which removes the most common cause of a failed replay, namely a header you saw and did not copy.

python
import shlex

def as_curl(request: httpx.Request) -> str:
    parts = ["curl", "-i", "-X", request.method, shlex.quote(str(request.url))]
    for k, v in request.headers.items():
        if k.lower() in {"x-api-key", "authorization"}:
            v = "$API_KEY"
        parts += ["-H", shlex.quote(f"{k}: {v}")]
    parts += ["--data-binary", shlex.quote(request.content.decode())]
    return " ".join(parts)

Keeping that behind a debug flag in the service means the reproduction step during an incident is copying one line out of a log rather than reconstructing a request from three sources at three in the morning.

Serialisation is part of the request too

Headers are the common cause of a failed replay; they are not the only one. The client makes choices about how your Python or TypeScript values become JSON, and three of them show up regularly.

Optional parameters that you did not pass are omitted from the body rather than sent as null. Both Python clients use a sentinel value for "not given" precisely so that temperature=None and an absent temperature remain distinguishable, and this matters when you build a body by hand: sending "temperature": null to an endpoint that expects a number is a 400, whereas omitting the key entirely gets you the default. A hand-built request assembled from a dictionary comprehension over your config object will hit this the first time a config value is unset.

The asymmetry there is not a quirk of one endpoint, and it is worth having the reason to hand because it recurs in every hand-built body you will write. An absent key and a key set to null are different statements. Absence says nothing about the field, which leaves the endpoint free to apply whatever it does when you have no opinion. Null is an opinion: it says the field's value is null, and a schema that types the field as a number is entitled to reject that, because null is not a number. Neither side is being pedantic — a validator that quietly treated null as absence would make it impossible to ever express "explicitly nothing" in an API where some field eventually needs to. The Python clients keep the two apart with a sentinel for exactly this reason, and a dictionary comprehension that emits every configured key regardless of whether it holds a value has thrown that distinction away before serialisation begins.

Numbers are the second reliable source of surprise. A cap you set as a Python integer arrives as 1024, while the same value read from a configuration file and never converted arrives as the string "1024", and endpoints differ in whether they coerce it or reject it. The clients protect you from this by validating against their typed signatures before serialising, which is a benefit you lose the moment you build the body yourself.

The third is normalisation of the message content. Some clients accept a convenience shape and expand it before sending — a plain string for content becomes a single text block on the wire, for instance — so the body you passed at the call site and the body that leaves the process are not identical. This is exactly why the event hook above prints request.content, the bytes on the wire, rather than the arguments you passed. If you log requests by serialising your own call arguments, your logs record your intent rather than the request, and during an incident those diverge in precisely the cases you care about.

A fourth choice is the base URL. Clients read an environment variable to override it — ANTHROPIC_BASE_URL, OPENAI_BASE_URL — and in an environment where a gateway or a proxy has been configured that way, the request your client makes is not going where the code suggests it goes. This is worth checking early when a replay against the vendor's public endpoint behaves differently from production, because the difference may be an entire intermediary that the code never mentions.

What a replay does and does not establish

None of these headers are secret, undocumented or a trick; they are in the provider's reference, and the client sets them because setting them by hand on every call would be tedious. The claim is not that the client hides something, it is that a replay is a different act from a call, and the part you have to supply is the part you never wrote.

Nor does a successful replay prove your production code sends the same request as your replay — the arrow only points one way. A replay that works tells you the request you constructed is acceptable; it says nothing about the request your service constructed, which may differ in a header, a normalised content block or a base URL. To establish that, you have to capture the real request from the process that made it, which is what the event hook is for.

There is also a limit to how far a single curl replay gets you. It reproduces one attempt of a non-streaming request, and reads back a complete JSON document. Two of the three things most likely to differ between your SDK path and your raw path are not visible in that exchange at all: what the client does when the first attempt fails, and how it consumes a response that arrives as a sequence of events rather than as a document.

The habit worth building from all of this is small and pays repeatedly: capture the outbound request as bytes rather than as arguments, keep the headers with the body, redact the credential, and log the request identifier from every response whether it succeeded or not. Those four things turn the most common incident question — what did we actually send, and what did they actually say — from an afternoon of inference into a query. The event hook is a dozen lines and the as_curl helper is a dozen more, and both belong in the service before you need them rather than during. The second of the two things a curl replay cannot show you — how a client consumes a response that arrives as a sequence of events — is where a handler written against one provider quietly stops working when it is pointed at another, and where the numbers you log about your own usage can come out as zero.

Provider SDKs and the differences that actually matter — read the current version at https://daviddominguez.dev/learn/en/provider-sdks-and-the-differences-that-actually-matter/request-headers-and-wire-capture

Back to top