Thuta Learning
IntermediateDevOps & Toolsbeginner

The HTTP Request Lifecycle

What you'll walk away with

  • Explain the core ideas behind The HTTP Request Lifecycle
  • Read the diagram and trace how data actually moves through it
  • Run the sample code and verify its output

Build the mental model

A great deal becomes clear once you see that an HTTP/1.1 message is just text sent over a TCP connection. A request is a request line carrying method, path and version; then any number of 'Name: value' header lines; then one blank line; then the body. Every line ends with CR then LF, and the blank line — CRLF CRLF — is the only marker saying headers are finished. Nothing states how long the body is, so Content-Length or Transfer-Encoding: chunked must; otherwise the receiver cannot know where the message ends until the connection closes.

Do not memorise status codes, learn the families. 2xx succeeded. 3xx means look elsewhere. 4xx means the request you sent was wrong, so sending it again unchanged will fail identically. 5xx means the server broke, so retrying later is reasonable — which is what retry logic should be built on.

The defining decision is that HTTP is stateless: the server understands a request purely from what it contains. This is what lets a hundred identical servers sit behind a load balancer: any of them can handle any request. Login clearly needs memory, so cookies were added: the server sends Set-Cookie once and the client returns the value on every later request. This does not break statelessness — the state now travels inside each request rather than living in the connection. Keep-alive solved a different problem: many requests reuse one TCP connection instead of paying a handshake each time. But HTTP/1.1 requests on that connection still queue one after another, so a slow response blocks those behind it. HTTP/2 splits messages into binary frames and interleaves many streams over one connection, removing that blockage.

text
ANATOMY OF A RAW HTTP REQUEST AND RESPONSE
------------------------------------------
              REQUEST                        RESPONSE
            +---------------------------+  +---------------------------+
start line  | POST /api/signup HTTP/1.1 |  | HTTP/1.1 201 Created      |
            +---------------------------+  +---------------------------+
headers     | Host: shop.example.test   |  | Content-Type: app/json    |
            | Content-Type: app/json    |  | Content-Length: 28        |
            | Content-Length: 27        |  | Set-Cookie: session=xyz   |
            | Cookie: session=abc123    |  | Connection: keep-alive    |
            +---------------------------+  +---------------------------+
blank line  |      CRLF CRLF            |  |      CRLF CRLF            |
            +---------------------------+  +---------------------------+
body        | {"email":"ko@ex.test"}    |  | {"id":42,"status":"ok"}   |
            +---------------------------+  +---------------------------+

Connect it to a real scenario

Take a bug where some users are creating duplicate orders through your checkout API. The logs show the mobile client sending POST /orders, receiving nothing, and retrying, while on the server the first request had already succeeded. The status-code families point straight at the cause. The client's retry rule was written as 'if no response, send it again', which fires on a timeout — and a timeout tells you nothing about whether the request arrived. The usual rule of retrying 5xx but not 4xx is correct, yet a timeout is not a status code at all, so it escapes that rule entirely.

The fix is to make the request itself idempotent. The client generates a unique Idempotency-Key header per order and includes it on the original and every retry; the server, on seeing a key it has already processed, skips the work and replays the stored response. This fits HTTP's stateless model neatly, because the retried request carries everything needed to identify itself. One practical warning while debugging this kind of thing: the browser network tab shows you a tidied, normalised view of the headers, not the raw bytes. Malformed Content-Length values and duplicated headers often vanish in that view and only appear when you look at what is genuinely on the wire.

Try the working example

python
CRLF = "\r\n"

# ---- 1. Build a raw HTTP/1.1 request exactly as it goes on the wire -----
body = '{"email":"ko@example.test"}'
lines = [
    "POST /api/signup HTTP/1.1",
    "Host: shop.example.test",
    "User-Agent: hand-rolled/1.0",
    "Content-Type: application/json",
    "Content-Length: %d" % len(body.encode()),
    "Connection: keep-alive",
    "Cookie: session=abc123",
]
request = CRLF.join(lines) + CRLF + CRLF + body

print("--- request on the wire (CRLF shown as \\r\\n) ---")
for line in request.split(CRLF):
    print(repr(line)[1:-1] + ("\\r\\n" if line != body else ""))
print("request bytes  :", len(request.encode()))

# ---- 2. Parse a raw HTTP/1.1 response ----------------------------------
raw = (
    "HTTP/1.1 201 Created" + CRLF +
    "Content-Type: application/json" + CRLF +
    "Content-Length: 28" + CRLF +
    "Set-Cookie: session=xyz789; HttpOnly" + CRLF +
    "Connection: keep-alive" + CRLF +
    CRLF +
    '{"id":42,"status":"created"}'
)

FAMILIES = {
    1: "informational", 2: "success", 3: "redirection",
    4: "client error", 5: "server error",
}

head, _, resp_body = raw.partition(CRLF + CRLF)
status_line, *header_lines = head.split(CRLF)
version, code, reason = status_line.split(" ", 2)

headers = {}
for line in header_lines:
    name, _, value = line.partition(": ")
    headers[name.lower()] = value

print()
print("--- parsed response ---")
print("version        :", version)
print("status         : %s %s (%s)"
      % (code, reason, FAMILIES[int(code) // 100]))
print("headers parsed :", len(headers))
for name in sorted(headers):
    print("  %-14s %s" % (name + ":", headers[name]))
print("body           :", resp_body)

declared = int(headers["content-length"])
print("content-length : declared %d, actual %d, match=%s"
      % (declared, len(resp_body.encode()), declared == len(resp_body.encode())))
print("connection     :", headers["connection"],
      "-> socket stays open for the next request")
You should see
--- request on the wire (CRLF shown as \r\n) ---
POST /api/signup HTTP/1.1\r\n
Host: shop.example.test\r\n
User-Agent: hand-rolled/1.0\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
Connection: keep-alive\r\n
Cookie: session=abc123\r\n
\r\n
{"email":"ko@example.test"}
request bytes  : 210

--- parsed response ---
version        : HTTP/1.1
status         : 201 Created (success)
headers parsed : 4
  connection:    keep-alive
  content-length: 28
  content-type:  application/json
  set-cookie:    session=xyz789; HttpOnly
body           : {"id":42,"status":"created"}
content-length : declared 28, actual 28, match=True
connection     : keep-alive -> socket stays open for the next request

5-minute try-it

Change the response's Content-Length from 28 to 12 and rerun: what does the check report, and what would a real HTTP client do with the extra bytes? Then delete the Host header from the request and reason about why HTTP/1.1 makes Host mandatory when HTTP/1.0 did not.

One important caution

Hand-writing raw requests with a bare \n as the line ending. HTTP specifies CRLF; some servers tolerate it but proxies and stricter servers reject it.

Believing keep-alive means multiplexing. On an HTTP/1.1 connection requests still run one at a time; genuine concurrency over one connection arrives only with HTTP/2.

RFC 9110 - HTTP SemanticsComputer Networking

Easy traps

  • Hand-writing raw requests with a bare \n as the line ending. HTTP specifies CRLF; some servers tolerate it but proxies and stricter servers reject it.
  • Believing keep-alive means multiplexing. On an HTTP/1.1 connection requests still run one at a time; genuine concurrency over one connection arrives only with HTTP/2.
  • Validate sample code in a local or test environment before applying it to a production network.

Exercise

Change the response's Content-Length from 28 to 12 and rerun: what does the check report, and what would a real HTTP client do with the extra bytes? Then delete the Host header from the request and reason about why HTTP/1.1 makes Host mandatory when HTTP/1.0 did not.

You'll know it worked when: --- request on the wire (CRLF shown as \r\n) --- POST /api/signup HTTP/1.1\r\n Host: shop.example.test\r\n User-Agent: hand-rolled/1.0\r\n Content-Type: application/json\r\n Content-Length: 27\r\n Connection: keep-alive\r\n Cookie: session=abc123\r\n \r\n {"email":"ko@example.test"} request bytes : 210 --- parsed response --- version : HTTP/1.1 status : 201 Created (success) headers parsed : 4 connection: keep-alive content-length: 28 content-type: application/json set-cookie: session=xyz789; HttpOnly body : {"id":42,"status":"created"} content-length : declared 28, actual 28, match=True connection : keep-alive -> socket stays open for the next request