Browsers Retry Non-Idempotent Requests

TL;DR

I’ve been messing around with proxies and Chromium lately, and noticed something I thought was odd: browsers may silently retry any request, even POSTs which are not idempotent.

This is likely obvious to anyone experienced with browsers and the HTTP spec, but it surprised me!

Why

This behaviour is originally specified in RFC 2616 §8.2.4 as:

[…] if the client sees the connection close before receiving any status from the server, the client SHOULD retry the request.

I thought this was interesting as I expected that browsers would only retry idempotent requests such as GET. Indeed, the wording was changed in the revised RFC 7230 §6.3.1, but it doesn’t completely rule out non-idempotent retries:

A user agent MUST NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.

In other words, the specification allows browsers to retry any request if they use a measure to detect whether the request was received and processed. What’s interesting is that it doesn’t define what “some means” are or examples of how a browser may go about this! So, what do browsers actually do?

Using Chromium as an example, since it’s the one I know best, the logic for this lives inside http_network_transaction.cc:

2106  switch (*retry_reason) {
2107    case RetryReason::kConnectionReset:
2108    case RetryReason::kConnectionClosed:
2109    case RetryReason::kConnectionAborted:
2110    case RetryReason::kSocketNotConnected:
2111    case RetryReason::kEmptyResponse:
2112      if (ShouldResendRequest()) {

NB: I’ve removed a few lines of comments from the above for brevity. The source code gives some more detailed explanations behind each status.

2319bool HttpNetworkTransaction::ShouldResendRequest() const {
2320  bool connection_is_proven = stream_->IsConnectionReused();
2321  bool has_received_headers = GetResponseHeaders() != nullptr;
2322
2323  // NOTE: we resend a request only if we reused a keep-alive connection.
2324  // This automatically prevents an infinite resend loop because we'll run
2325  // out of the cached keep-alive connections eventually.
2326  return connection_is_proven && !has_received_headers;
2327}

So Chromium only retries if all of the following are true:

  1. One of the following errors were triggered:
    1. kConnection{Reset,Closed,Aborted}: The server closed the connection ungracefully.
    2. kSocketNotConnected: The connection used was torn down or never connected, typically seen when using a misconfigured proxy.
    3. kEmptyResponse: The response was completely empty.
  2. The browser has not received the response headers yet.
  3. The connect was reused.

This is more likely to happen than you think. We can trigger points 1 and 2 by creating a server which immediately sends an RST on receipt of a request, triggering kConnectionReset. As for point 3? This happens for free. Browsers aggressively re-use connections to the same origin, so connection re-use is probably the most likely scenario! When you navigate to a site, a connection is established for the initial request, then up to 6 connections are maintained for fetching sub-resources and future navigations.

Demo

Taken together, this means we only need to create an HTTP server which:

  • Exposes a single sub-resource.
  • Sends an RST on the first request to that sub-resource.

Chromium should re-use the connection from the navigation for the sub-resource, and we get two POST requests rather than one. This demo works in Firefox too, but I haven’t looked into the source code!

 1const net = require("net");
 2
 3// A button which sends a simple POST request
 4const html = `
 5<button onclick="run()">Send</button>
 6<pre id="out"></pre>
 7<script>
 8  async function run() {
 9    const res = await fetch('/submit', { method: 'POST' });
10    const { count } = await res.json();
11    document.getElementById('out').textContent = count % 2 === 0
12      ? \`Retried! Server actually saw \${count} requests.\`
13      : 'Server saw 1 request.';
14  }
15</script>`;
16
17let count = 0;
18
19net
20  .createServer((socket) => {
21    socket.on("data", (chunk) => {
22      const req = chunk.toString();
23
24      if (req.startsWith("POST /submit")) {
25        count++;
26        console.log(`POST #${count}`);
27
28        // RST any odd request (so the first of each retry pair)
29        if (count % 2 === 1) {
30          console.log("Dropping response, sending RST");
31          socket.destroy();
32          return;
33        }
34
35        // otherwise, return the count!
36        const body = JSON.stringify({ count });
37        socket.write(
38          `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n` +
39            `Content-Length: ${body.length}\r\nConnection: keep-alive\r\n\r\n${body}`,
40        );
41        console.log(`Responded to POST #${count} (the retry)`);
42        return;
43      }
44
45      // Serve the page with keep-alive, so Chromium pools this connection for the POST
46      // This also means successive clicks should cause connection re-use
47      socket.write(
48        `HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n` +
49          `Content-Length: ${html.length}\r\nConnection: keep-alive\r\n\r\n${html}`,
50      );
51    });
52
53    socket.on("error", () => {});
54  })
55  .listen(8080, () => console.log("Listening on http://localhost:8080"));

Try this yourself with node server.js. One click of the button should yield:

1POST #1
2Dropping response, sending RST
3POST #2
4Responded to POST #2 (the retry)

Note that this is HTTP/1.1 specific. HTTP/2 has a different retry mechanism based on the fact that receipt of REFUSED_STREAM suggests that the request was never processed.