Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

Before you put a reverse proxy in front of an application, you need to be able to read what that proxy (an intermediate server that receives requests in place of the application and forwards them to it) is going to relay: a method, an authority, headers, a body, a response code, and to foresee what becomes of a request that is replayed or redirected. This lesson follows What a web service is and what publishing it means and prepares the "Names and exposure" foundation. It does not repeat the whole HTTP mechanism, already detailed in HTTP and HTTPS (in French): it focuses on what, in a request, decides the behaviour of a proxy or of a client that retries, two traps you will meet with every reverse proxy you configure.

What you will learn

  • Read the method, authority, path, headers, body and response code in an HTTP exchange
  • Predict the effect of a redirect or a retry on a request, depending on whether it is idempotent (can be replayed without changing the state of the service) or not
  • Explain why an HTTP status code alone never identifies the component that actually issued it
  • Write the gestures of a careful client: read the HTTP code and the exit code of curl, and replay a write with an idempotency key

Prerequisites

This lesson assumes the previous one, What a web service is and what publishing it means. For the full mechanics of the protocol (all the methods, the detail of the TLS handshake, reading with curl -v), refer to HTTP and HTTPS (in French): this lesson assumes its basics and takes up only what directly conditions the behaviour of a future reverse proxy.

What does a request contain, and what should you read first?

An HTTP request boils down to five pieces of information that are enough to predict what a server or a proxy will do with it: the method, the targeted authority, the path, the headers and the body. The method (GET, POST, PUT...) states the intent; the path designates the resource; the headers carry metadata (expected format, body length, session information); the body, optional, carries the data sent, for example the JSON of a form.

The authority deserves particular attention: in HTTP/1.1 it is the Host header (Host: app.exemple.fr); in HTTP/2 and HTTP/3 it is the :authority pseudo-header, which carries the same information under another name. In both cases the authority says which service name the request is addressed to, which becomes decisive as soon as a single entry point receives several names: it, and not the destination IP address, lets you choose the right application. The lesson Publish a web service shows how a proxy uses it to route, alongside another decision taken earlier in the connection, the SNI (the server name the client announces during the TLS negotiation, before sending any HTTP request).

How do you read a complete exchange, header by header?

An HTTP exchange reads like a form: each line of the request asks a question, each line of the response answers it, and curl -v displays both side by side. Here is the creation of an order on the lab's API (the small demonstration API supplied by this lesson's lab). Lines starting with > are sent by the client, lines starting with < are received from the server; connection lines (prefixed with *) are removed for readability. Port, date, versions and durations are the values of my capture of 2026-10-25: my test runs on port 18412, so adapt the URL to your API's and expect other values. The first line is a warning from curl: -d is enough to select POST, so the -X is superfluous here, as long as no redirect comes into play (the section on redirects explains why we keep it).

Terminal window
curl -sS -v -X POST \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: cmd-A-100' \
-d '{"ref":"A-100"}' http://127.0.0.1:18412/commandes 2>&1 | grep -v '^\*'
Note: Unnecessary use of -X or --request, POST is already inferred.
> POST /commandes HTTP/1.1
> Host: 127.0.0.1:18412
> User-Agent: curl/8.18.0
> Accept: */*
> Content-Type: application/json
> Idempotency-Key: cmd-A-100
> Content-Length: 15
>
} [15 bytes data]
< HTTP/1.1 201 Created
< Server: BaseHTTP/0.6 Python/3.12.13
< Date: Sun, 25 Oct 2026 21:10:01 GMT
< Content-Type: application/json
< Content-Length: 35
<
{ [35 bytes data]
{"ref": "A-100", "statut": "creee"}

Read the request line by line, asking each time what that line decides:

LineWhat it decides
POST /commandes HTTP/1.1The method (create) and the path (the orders collection); HTTP/1.1 is the protocol version
Host: 127.0.0.1:18412The authority: which service name the request is addressed to, hence which application a proxy will choose (here an IP and a port, since we query by address; with a DNS name you would read app.exemple.fr)
User-Agent, AcceptWho is speaking and which format the client accepts; informative, but valuable in logs
Content-Type: application/jsonHow the server must interpret the body; without it, a {"ref":"A-100"} may be read as plain text
Idempotency-Key: cmd-A-100A key chosen by the client: in the lab's API, if the same key comes back, the server answers without redoing the action (an API convention, not an HTTP rule: see below)
Content-Length: 15The body size in bytes: the 15 characters of {"ref":"A-100"}, no more, no less

On the response side, the 201 Created status line says the resource was created (a plain 200 would have said "processed"). Server says who answered, the clue used below to attribute a response to its issuer. Content-Type and Content-Length: 35 describe the JSON body that follows, and the last line is that body. A check confirms the state on the server side:

Terminal window
curl -sS -i http://127.0.0.1:18412/commandes/A-100
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.12.13
Date: Sun, 25 Oct 2026 21:10:01 GMT
Content-Type: application/json
Content-Length: 16
{"ref": "A-100"}

The order exists: the earlier 201 was not an empty promise. Keep this reflex, checking with a second request what the first one claimed: it is used in the last section of this lesson.

How does a method decide whether it can be replayed safely?

A method is idempotent when replaying it several times has the same effect as replaying it once; GET and HEAD always are, POST never is by default. This distinction, detailed with its full table in HTTP and HTTPS (in French), has a direct consequence once a service is published: clients, and sometimes the proxies themselves, automatically retry a request whose response was lost (network cut, timeout exceeded). Retrying a GET does not change the state of the service: the second call leaves the resource as the first found it. That does not mean it is free: it consumes computing time, a place in the server's queue, and it leaves its trace in the logs as in the counters. Retrying a POST that the server did receive and process, but whose response never came back to you, replays the business action it describes.

This is the first of the two traps announced in the introduction, to be understood before publishing a service: replaying a POST can double a business action, a payment billed twice, a message sent twice, a record created twice. A production-ready service handles this risk either by making the operation truly idempotent on the server side (an idempotency key supplied by the client), or by accepting that the network layer must never silently replay a POST in its place.

Does a redirect change a request's method?

It depends on the code: 301 and 302 can turn a POST into a GET, while 307 and 308 guarantee that the method is kept. The full table of the five redirect codes (and their difference from 304) is in HTTP and HTTPS (in French), in the section "Famille 3xx : Redirections". What matters here is the use you will make of them once the proxy is in place: a reverse proxy very often redirects from port 80 to port 443 with a 301, which poses no problem for a browser GET, but silently breaks a client that sent a POST over plain HTTP and does not follow the redirect while keeping its method. For an API, prefer 307 or 308 whenever the redirect must change nothing in the calling client's behaviour.

Seeing it rather than believing it

The lab's API accepts a path /redir/<code>/<target> that returns the code of your choice. Send the same POST twice, changing only the code, and ask curl to follow the redirect with -L:

Terminal window
# the server answers, then curl follows the redirect (adapt the port to your API)
curl -sS -L -X POST -d '{"ref":"A-1"}' http://127.0.0.1:18412/redir/302/commandes
curl -sS -L -X POST -d '{"ref":"B-1"}' http://127.0.0.1:18412/redir/307/commandes

The redirect response is short, and it is its Location header that carries the target:

HTTP/1.1 302 Found
Server: BaseHTTP/0.6 Python/3.12.13
Location: /commandes
Content-Length: 0

But the redirect does not say what the client will send back. To find out, you have to look on the server side: the API logs each request received, method, path and body. Here is what it recorded for these two calls:

POST /redir/302/commandes corps='{"ref":"A-1"}'
POST /commandes corps=''
POST /redir/307/commandes corps='{"ref":"B-1"}'
POST /commandes corps='{"ref":"B-1"}'

The diagram follows the path of the two requests: the client receives the same redirect, but what it sends next differs.

Sequence between a curl client with the -L option and the API: in the case of a 302 redirect, the client replays the POST on /commandes with an empty body; in the case of a 307 redirect, it replays the POST on /commandes with the same body.APIClientcurl -LThe client replayswithout the bodyThe client replaysunchangedalt[302 redirect][307 redirect]POST/redir/…/commandes +body302 · Location:/commandesPOST /commandes ·empty body307 · Location:/commandesPOST /commandes ·same body
Same request, same target: the 302 loses the body, the 307 keeps it. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Read the second line of each pair, that is where everything happens. After the 302, the server does receive a POST on the target (it is -X POST that forces curl to keep the method, see the note below), but its body has disappeared: the order A-1 exists nowhere, and yet nothing failed, no error code came back. After the 307, the POST arrives with its body and the order B-1 is created.

A client that does not check what it actually sent therefore believes it posted two orders. It posted one. It is this silence that makes the case dangerous in production, far more than the change of method or the loss of the body themselves.

How do content negotiation and compression change what is transmitted?

The Content-Type says how to interpret the body, the Content-Length says its size in bytes, and content negotiation lets the client ask for a specific format or compression. The client sends an Accept header ("I prefer JSON, otherwise XML") and the server answers with the Content-Type actually chosen: the two do not always coincide, and a strict client must check what it received rather than what it asked for.

Compression (Accept-Encoding: gzip, then Content-Encoding: gzip in the response) reduces the size transmitted, but changes the number of bytes actually put on the wire: a Content-Length read before decompression does not describe the size of the final content. Once a reverse proxy is in place, it is often the one that decides whether to compress, and that recomputes this Content-Length: knowing at which stage compression happens avoids hunting for a data leak where there is only a misread header.

Why doesn't an HTTP code alone identify the responsible component?

The same code, for example 503, can be returned by the application, by an intermediate component, or be produced locally by the client without any server having answered. This point is about diagnosis more than configuration: the code alone does not say who issued it. Once a reverse proxy is introduced, this question becomes central, because the proxy can produce its own responses (502, 503, 504) without ever contacting the backend (the application behind the proxy), for example when no healthy backend is available.

Reading an HTTP response correctly therefore means looking, besides the code, at the source of the response: a Server header different from the expected one, an error page body that does not look like the application's, or the total absence of a trace of the request in the application logs are the three clues that betray a response produced upstream of the backend. This distinction is developed in more detail in Publish a web service, which associates each error code with its most probable origin.

How do you write a client that does not double a write?

A reliable client does not settle for the text displayed: after each send it reads three things, the HTTP code, the exit code of curl, and what the server actually recorded. The first two are obtained in a single gesture: -w '%{http_code}' writes the HTTP code to standard output, and the exit code remains available in $? right after the command. Here is a small Bash function that displays both, and its five real results:

Terminal window
lire() {
local code
code=$(curl -s -o /dev/null --max-time 5 -w '%{http_code}' "$@")
echo "curl sorti avec $?, code HTTP vu : $code"
}
Situationcurl exitHTTP code seen
Normal creation (POST on /commandes)0201
Unknown route0404
Unknown route with the -f option22404
Response lost: the API wrote, then cut the connection52000
Closed port, no server7000

The table holds three lessons. A 404 is not a failure for curl: it exits with 0 as long as it received a response, and it is the -f option that turns an HTTP error code into exit 22. Next, 000 means that no HTTP code arrived, and the exit code says why: 7 for an impossible connection, 52 for a connection closed without a response (Empty reply from server). Finally, the 52 case is the only one where the situation is ambiguous: the client does not know whether the server processed the request before cutting.

What happens then if you replay without precaution? The lab's fixture simulates this case: for certain references, the API writes the order then closes the connection without answering. Without a key, here is the lost response followed by the naive replay, with the number of writes the API actually made (read from its state file, here ecritures.jsonl: its location depends on your installation):

Terminal window
curl -sS -X POST -d '{"ref":"perdue-demo"}' http://127.0.0.1:18412/commandes
grep -c perdue-demo ecritures.jsonl # 1 write already made
curl -sS -X POST -d '{"ref":"perdue-demo"}' http://127.0.0.1:18412/commandes
grep -c perdue-demo ecritures.jsonl # the replay made a second one
curl: (52) Empty reply from server
1
{"ref": "perdue-demo", "statut": "creee"}
2

The client believed it had written nothing, it wrote two: this is the payment billed twice of the section on idempotency. The remedy works on both sides. On the server side, the lab's API honours the Idempotency-Key header: a replay with the same key does not redo the action and returns the original response. Careful, this behaviour (including the 200 instead of the 201) is that of this API, not an HTTP rule: Idempotency-Key is not defined by RFC 9110, and an IETF draft (draft-ietf-httpapi-idempotency-key-header, version 07 of October 2025) still describes it without being a standard. Each API chooses whether it handles it and how it answers.

Let us take the annotated exchange from above and send it a second time identically:

Terminal window
curl -sS -i -X POST -H 'Content-Type: application/json' \
-H 'Idempotency-Key: cmd-A-100' -d '{"ref":"A-100"}' \
http://127.0.0.1:18412/commandes
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.12.13
Date: Sun, 25 Oct 2026 21:10:01 GMT
Content-Type: application/json
Content-Length: 35
{"ref": "A-100", "statut": "creee"}

Compare with the initial exchange: the body is identical, but the code is 200 instead of 201, and the state file still counts a single write for A-100. The server recognised the key. For this mechanism to protect the client, it must therefore choose its key before the first send and reuse it unchanged at each replay: a new key on every attempt would make the server unable to recognise the request.

You now have the four gestures a careful client requires: distinguish three situations (a response received, an HTTP error, no response) from -w and the exit code of curl; recognise the ambiguous case (exit 52, code 000); send a stable idempotency key; check with a GET what the server recorded. Assembling these gestures into a script that decides when to replay, how many times and which exit code to return is the lab's job.

Practice

The lab has you read a real HTTP exchange and attribute each response to its issuer, with evidence. You observe the behaviour of each redirect code, you note the code returned by several routes, then you write a Bash client that creates an order exactly once, even when the response never comes back. The tests replay the redirects, send fresh references and count the writes actually made by the API.

Key points

  • A request is read in five points: method, authority (Host or :authority), path, headers, body.
  • An idempotent method (GET, HEAD) can be replayed without changing the state of the service, which makes it neither free nor invisible in the logs; replaying a POST can double a business action.
  • 301/302 can change the method of a redirected request, 307/308 always keep it; in this lesson's measurement, a 302 followed by curl -X POST kept the method but lost the body, without any error.
  • Content-Type, Content-Length and content negotiation describe what is actually transmitted, not what was asked for.
  • An exchange is read line by line: each header decides something (Host chooses the application, Content-Type the decoding of the body, Idempotency-Key the recognition of a replay).
  • curl exits with 0 on a 404 (except with -f, exit 22); a 000 code with exit 52 is the ambiguous case where the write may have been made.
  • Replaying with the same idempotency key does not redo the action: the server answers 200 instead of 201.
  • An HTTP code alone never identifies the component that issued it: you must look at the source of the response.

Knowledge check

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn