Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Computer Networks  Module 15 – Web and application protocols

HTTP messages, methods and status codes

How HTTP/1.1 requests and responses are built, what methods and status codes promise, and how headers set body length, connection reuse and cookies.

  • Beginner
  • 30 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Read and write HTTP/1.1 requests and responses by hand
  • Choose methods and status codes with the correct safety and idempotency semantics
  • Use headers for content negotiation, connection reuse and cookies

Before you start

What you’ll learn · Before you start

What you will learn

  • Read and write HTTP/1.1 requests and responses by hand
  • Choose methods and status codes with the correct safety and idempotency semantics
  • Use headers for content negotiation, connection reuse and cookies

Before you start

On this page

HTTP is a request and response protocol: a client sends a request message, and the server answers with exactly one response message. Every message has the same four parts: a start line, header fields, an empty line and an optional body. The request’s start line names a method (what the client wants done, such as GET or POST) and a target; the response’s start line carries a three-digit status code that says how it went, such as 200 or 404. This lesson reads real messages byte by byte, then covers the rules that make HTTP reliable in practice: which requests are safe to repeat, which status code fits which situation, and how a receiver knows where a message ends.

One meaning, three wire formats

The meaning of methods, status codes and header fields is defined once, in HTTP Semantics (RFC 9110), and shared by every version of the protocol. Only the way messages are written onto a connection differs:

  • HTTP/1.1 (RFC 9112): lines of text over a TCP connection, one message after another.
  • HTTP/2 (RFC 9113): binary frames over one TCP connection, many requests at the same time.
  • HTTP/3 (RFC 9114): binary frames on QUIC streams, which run over UDP.

So a 404 means the same thing in all three, and a lesson about methods and status codes is a lesson about all of HTTP. The text format of HTTP/1.1 is the one you can read with your own eyes, which is why it comes first.

The parts of a message

An HTTP/1.1 request and its response, each made of a start line, header fields, an empty line and a body.Request (client to server)Response (server to client)Request linePOST /notes HTTP/1.1Header fields, one per lineHost: notes.exampleContent-Type: application/jsonContent-Length: 20Empty line (CR LF): the header section endsBody: exactly 20 bytes{"text": "buy milk"}Status lineHTTP/1.1 201 CreatedHeader fieldsLocation: /notes/7Content-Type: application/jsonContent-Length: 29Empty line (CR LF)Body: exactly 29 bytes{"id": 7, "text": "buy milk"}same TCP connection

The four parts of every HTTP/1.1 message

Text description of the diagram

The diagram shows two boxes, one above the other, joined by an arrow labelled "same TCP connection".

The upper box is a request, sent from the client to the server. From top to bottom it has four parts:

  • The request line, POST /notes HTTP/1.1: the method, the target and the version.
  • The header fields, one per line: Host: notes.example, Content-Type: application/json and Content-Length: 20.
  • An empty line, a lone CR LF, which ends the header section.
  • The body, exactly 20 bytes long because Content-Length says so: {"text": "buy milk"}.

The lower box is the response, sent back from the server to the client, with the same four parts:

  • The status line, HTTP/1.1 201 Created: the version, the status code and a reason phrase.
  • The header fields: Location: /notes/7, Content-Type: application/json and Content-Length: 29.
  • An empty line.
  • The body, exactly 29 bytes: {"id": 7, "text": "buy milk"}.
  • The start line is the request line (method target version) or the status line (version code reason). The reason phrase, such as Created, is only for people; programs read the code.
  • Header fields follow, one per line, as Name: value. Names are case-insensitive, so content-length and Content-Length are the same field.
  • An empty line ends the header section. In HTTP/1.1 every line ends with the two bytes CR LF (carriage return, line feed), so the empty line is a lone CR LF.
  • The body, when there is one, comes last. The receiver must be told how long it is, because nothing in the bytes themselves marks its end; the section on body length below explains how.

A real exchange, byte by byte

The program below lets Python’s HTTP client talk to Python’s HTTP server over a connected pair of sockets, so it needs no network, and prints every byte the client sends and receives. It makes three requests on one connection: a GET, a POST that creates a note, and a GET whose answer the server sends in pieces.

Three requests and responses on one connection Python · on_the_wire.py
"""Three HTTP/1.1 exchanges on one connection, printed byte for byte.

Python's own client (http.client) talks to Python's own server (http.server) over a
connected pair of sockets, socket.socketpair(), so no network is needed. A small tap
records every byte the client sends and receives. The output shows line ends as \\r\\n
(CR LF) and \\n (LF alone), and leaves out the value of the Date header, which changes on
every run.
"""
import http.client
import http.server
import io
import json
import re
import socket
import threading


class NotesHandler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"  # persistent connections, as HTTP/1.1 defines them

    def do_GET(self):
        if self.path == "/hello":
            body = b"Hello, HTTP\n"
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif self.path == "/count":
            # No length known in advance: send the body in chunks.
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Transfer-Encoding", "chunked")
            self.end_headers()
            for part in (b"one\n", b"two\n", b"three\n"):
                self.wfile.write(b"%x\r\n%s\r\n" % (len(part), part))
            self.wfile.write(b"0\r\n\r\n")  # the last chunk has size 0
        else:
            self.send_error(404)

    def do_POST(self):
        length = int(self.headers["Content-Length"])
        note = json.loads(self.rfile.read(length))
        body = json.dumps({"id": 7, "text": note["text"]}).encode()
        self.send_response(201)
        self.send_header("Location", "/notes/7")
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass  # no access log on standard error


class Tap:
    """Wraps the client's socket and records what goes out and what comes in."""

    def __init__(self, sock):
        self.sock = sock
        self.sent = bytearray()
        self.received = bytearray()

    def sendall(self, data):
        self.sent += data
        self.sock.sendall(data)

    def makefile(self, mode):
        tap = self

        class Reader(io.RawIOBase):
            def readable(self):
                return True

            def readinto(self, buffer):
                n = tap.sock.recv_into(buffer)
                tap.received += buffer[:n]
                return n

        return io.BufferedReader(Reader())

    def close(self):
        self.sock.close()


def show(label, data):
    """Prints a message line by line, with its line ends made visible."""
    print(label)
    *lines, rest = bytes(data).decode().split("\r\n")
    for line in lines:
        line = re.sub(r"^Date: .*", "Date: (the time of the run, left out)", line)
        print("   ", line.replace("\n", "\\n") + "\\r\\n")
    if rest:
        print("   ", rest.replace("\n", "\\n"))


client_end, server_end = socket.socketpair()
server = threading.Thread(target=NotesHandler, args=(server_end, ("client", 0), None))
server.start()

tap = Tap(client_end)
conn = http.client.HTTPConnection("notes.example")
conn.sock = tap  # use the socket pair instead of connecting

for method, path, body, headers in [
    ("GET", "/hello", None, {}),
    ("POST", "/notes", json.dumps({"text": "buy milk"}), {"Content-Type": "application/json"}),
    ("GET", "/count", None, {}),
]:
    tap.sent.clear()
    tap.received.clear()
    conn.request(method, path, body=body, headers=headers)
    response = conn.getresponse()
    content = response.read()
    show(f"--> {method} {path}: the client sent {len(tap.sent)} bytes", tap.sent)
    show(f"<-- {response.status} {response.reason}: the client read {len(tap.received)} bytes", tap.received)
    print("    The application got the body:", content)
    print()

conn.close()  # the server sees the connection close and its thread ends
server.join()
print("Three requests, three responses, one connection.")

Output

--> GET /hello: the client sent 71 bytes
    GET /hello HTTP/1.1\r\n
    Host: notes.example\r\n
    Accept-Encoding: identity\r\n
    \r\n
<-- 200 OK: the client read 165 bytes
    HTTP/1.1 200 OK\r\n
    Server: BaseHTTP/0.6 Python/3.14.8\r\n
    Date: (the time of the run, left out)\r\n
    Content-Type: text/plain; charset=utf-8\r\n
    Content-Length: 12\r\n
    \r\n
    Hello, HTTP\n
    The application got the body: b'Hello, HTTP\n'

--> POST /notes: the client sent 144 bytes
    POST /notes HTTP/1.1\r\n
    Host: notes.example\r\n
    Accept-Encoding: identity\r\n
    Content-Length: 20\r\n
    Content-Type: application/json\r\n
    \r\n
    {"text": "buy milk"}
<-- 201 Created: the client read 198 bytes
    HTTP/1.1 201 Created\r\n
    Server: BaseHTTP/0.6 Python/3.14.8\r\n
    Date: (the time of the run, left out)\r\n
    Location: /notes/7\r\n
    Content-Type: application/json\r\n
    Content-Length: 29\r\n
    \r\n
    {"id": 7, "text": "buy milk"}
    The application got the body: b'{"id": 7, "text": "buy milk"}'

--> GET /count: the client sent 71 bytes
    GET /count HTTP/1.1\r\n
    Host: notes.example\r\n
    Accept-Encoding: identity\r\n
    \r\n
<-- 200 OK: the client read 195 bytes
    HTTP/1.1 200 OK\r\n
    Server: BaseHTTP/0.6 Python/3.14.8\r\n
    Date: (the time of the run, left out)\r\n
    Content-Type: text/plain; charset=utf-8\r\n
    Transfer-Encoding: chunked\r\n
    \r\n
    4\r\n
    one\n\r\n
    4\r\n
    two\n\r\n
    6\r\n
    three\n\r\n
    0\r\n
    \r\n
    The application got the body: b'one\ntwo\nthree\n'

Three requests, three responses, one connection.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 on_the_wire.py

Things to notice in the output:

  • The client added Host: notes.example and Accept-Encoding: identity by itself. identity means “send the body as it is, not compressed”.
  • The POST carries Content-Length: 20 because its JSON body is exactly 20 bytes. The server answered 201 Created with a Location header naming the new resource, which is what 201 is for.
  • The third response had no Content-Length. It said Transfer-Encoding: chunked instead, and sent the body as chunks, each starting with its size in hexadecimal: 4, 4, 6, then 0 to mark the end. The client put the chunks back together before the program saw the body.
  • All three exchanges used the same connection. The Date value is left out of the printout because it changes on every run; a server with a clock must send it on most responses.
cURL to Code Converter Paste a curl command copied from your browser's developer tools and see the same request written for Python, fetch and other languages.

Methods: safe, idempotent, cacheable

A method states what the client wants. RFC 9110 gives each method properties that clients, proxies and caches rely on, and those properties matter more in interviews and in production than the list of names.

  • Safe methods only read. A crawler may follow every safe link without changing anything on the server.
  • Idempotent methods can be sent twice with the same effect as once. If a connection drops before the response arrives, a client may send an idempotent request again automatically, because a duplicate does no harm.
  • Cacheable methods have responses that a cache may store and reuse; a later lesson of this module covers how.

Grouped by those properties, the methods of RFC 9110 and RFC 5789 are:

  • Safe, so also idempotent: GET reads a resource; HEAD is like GET but returns only the status and headers; OPTIONS asks which methods and features a target supports; TRACE echoes the request back, for debugging. Responses to GET and HEAD are cacheable.
  • Idempotent but not safe: PUT creates or replaces the resource at this exact address; DELETE removes it. Both change the server’s state, but a repeat changes nothing more.
  • Neither: POST asks the server to process the body (create something, submit a form, start a job); PATCH changes part of a resource (RFC 5789); CONNECT opens a tunnel through a proxy, for example for HTTPS. Responses to POST and PATCH are cacheable only in rare cases.

Two details trip people up. First, idempotent is about the state of the server, not the response: deleting the same order twice may return 204 and then 404, yet the order is gone either way. Second, these are promises the server makes by following the semantics. A GET /cancel-order?id=42 that cancels an order breaks the promise, and a link prefetcher or crawler that trusts it can cancel orders nobody meant to cancel.

POST is the method to watch. If a payment request times out, the client cannot tell whether the server never saw it or processed it and lost the reply. Sending it again might charge twice, so clients do not retry a POST automatically unless they have some other way to know that a repeat is harmless, such as a unique key the server uses to spot duplicates.

Status codes: five classes

The first digit of a status code gives its class, and a client that does not know a particular code treats it like the x00 code of its class (RFC 9110, section 15). IANA keeps the registry of every code.

  • 1xx, informational: an interim answer; the real one follows. 100 Continue, 101 Switching Protocols, 103 Early Hints.
  • 2xx, success: 200 OK, 201 Created, 204 No Content, 206 Partial Content.
  • 3xx, redirection: look elsewhere, or use your cached copy. 301, 302, 303, 304 Not Modified, 307, 308.
  • 4xx, client error: the request is wrong; do not repeat it unchanged. 400, 401, 403, 404, 405, 409, 412, 415, 422, 429.
  • 5xx, server error: the request may be fine; the server failed. 500, 502, 503, 504.

The pairs that interviewers ask about:

  • 301 or 308, 302 or 307. All four send the client to another address, given in Location. 301 and 308 mean “moved for good” (clients may update their stored links); 302 and 307 mean “for now”. For historical reasons a client may turn a POST into a GET when it follows a 301 or 302. 307 and 308 forbid that, so a form posted to an address that answers 308 is posted again, body and all, to the new one. 303 See Other asks for the opposite: fetch the new address with GET, the usual answer after a form is processed.
  • 401 or 403. 401 Unauthorized means the request has no valid credentials, and the response must carry a WWW-Authenticate header that says how to authenticate. 403 Forbidden means the server understood the request and refuses it anyway: the credentials it received, if any, do not grant access. A server that does not want to reveal that a forbidden resource exists may answer 404 instead.
  • 429 or 503. 429 Too Many Requests (RFC 6585) says this client sent too many requests; 503 Service Unavailable says the server is overloaded or down for maintenance. Both may carry Retry-After, a delay in seconds or a date, and a well-behaved client waits at least that long.
  • 502 or 504. Both come from a gateway or proxy: 502 Bad Gateway when the server behind it sent something invalid, 504 Gateway Timeout when it did not answer in time.
HTTP Status Codes Reference Look up any status code: what it means, its RFC section, whether it may be cached or retried, and its usual causes.

Host, and the rules a server enforces

One machine often serves many sites, so every HTTP/1.1 request must name the site it is for in the Host header. RFC 9112 is strict here: a client must send exactly one Host, and a server must answer 400 Bad Request to an HTTP/1.1 request without one or with more than one (RFC 9112, section 3.2). It also forbids any space between a field name and its colon, because servers and proxies that handled such spaces differently have been tricked into treating one request in two ways (section 5.1).

Not every server applies these rules. The program below sends five hand-written requests to Python’s built-in server and prints what came back:

Five requests, with and without Host Python · host_rules.py
"""Hand-written requests sent to Python's http.server, and the status line of each answer.

RFC 9112 says an HTTP/1.1 request must carry exactly one Host header, and a server
must answer 400 (Bad Request) when it is missing or repeated. This program sends five
requests over a socket pair (no network) and prints what the server answered, next
to what the RFC asks for.
"""
import http.server
import socket
import threading


class Echo(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        hosts = self.headers.get_all("Host") or []
        body = f"Host headers seen: {hosts}\n".encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Connection", "close")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


def send(raw):
    """Sends one raw request and returns the status line and the body of the answer."""
    client, server_end = socket.socketpair()
    worker = threading.Thread(target=Echo, args=(server_end, ("client", 0), None))
    worker.start()
    client.sendall(raw)
    client.shutdown(socket.SHUT_WR)  # no more requests on this connection
    worker.join()  # the handler answers, then stops at the end of the input
    server_end.close()  # hang up, as a server does after its last response
    answer = b""
    while chunk := client.recv(4096):
        answer += chunk
    client.close()
    head, _, body = answer.partition(b"\r\n\r\n")
    return head.split(b"\r\n")[0].decode(), body.decode().strip()


cases = [
    ("HTTP/1.1 with one Host", b"GET / HTTP/1.1\r\nHost: shop.example\r\n\r\n", "200"),
    ("HTTP/1.1 without Host", b"GET / HTTP/1.1\r\n\r\n", "400"),
    ("HTTP/1.1 with two Hosts", b"GET / HTTP/1.1\r\nHost: shop.example\r\nHost: admin.example\r\n\r\n", "400"),
    ("HTTP/1.0 without Host", b"GET / HTTP/1.0\r\n\r\n", "200 (Host is optional in 1.0)"),
    ("Space before the colon", b"GET / HTTP/1.1\r\nHost : shop.example\r\n\r\n", "400"),
]
for name, raw, rfc in cases:
    status, body = send(raw)
    print(f"{name:24} -> {status:24} RFC 9112 asks for: {rfc}")
    print(f"{'':24}    {body}")

Output

HTTP/1.1 with one Host   -> HTTP/1.1 200 OK          RFC 9112 asks for: 200
                            Host headers seen: ['shop.example']
HTTP/1.1 without Host    -> HTTP/1.1 200 OK          RFC 9112 asks for: 400
                            Host headers seen: []
HTTP/1.1 with two Hosts  -> HTTP/1.1 200 OK          RFC 9112 asks for: 400
                            Host headers seen: ['shop.example', 'admin.example']
HTTP/1.0 without Host    -> HTTP/1.1 200 OK          RFC 9112 asks for: 200 (Host is optional in 1.0)
                            Host headers seen: []
Space before the colon   -> HTTP/1.1 200 OK          RFC 9112 asks for: 400
                            Host headers seen: []

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 host_rules.py

Python’s server answered 200 OK to all five, including the three that RFC 9112 says must get a 400. For the request with a space before the colon, its parser did not accept Host : shop.example as a header field and quietly dropped it, together with any header lines after it, so the program found no Host, while another server on the same path might have read shop.example. That is not a bug to report: Python’s documentation says http.server is not recommended for production and implements only basic security checks (Python docs). It is a reminder that a production server must check these rules itself, as the exercise below does.

Where the body ends

A connection carries one message after another, so the receiver must find each body’s end exactly. HTTP/1.1 has two ways to say it (RFC 9112, section 6):

  • Content-Length: n: the body is the next n bytes. The sender must know the length before it starts.
  • Transfer-Encoding: chunked: the body comes in chunks, each with its size in hexadecimal on a line of its own, and a chunk of size 0 ends it. A server that generates a page as it goes uses this, as the third response above did.

A request with neither header has no body. A response with neither runs until the server closes the connection, unless it is one that never has a body, such as the answer to a HEAD request, a 204 or a 304; the close ends the body and the connection together, so nothing can follow it. A message must never be ambiguous about its length, and RFC 9112 is strict about the two ways it could be: when both headers arrive, Transfer-Encoding wins and the message ought to be handled as an error, and when Content-Length arrives with different values the framing is invalid. A careful server answers 400 Bad Request to such a request and closes the connection, because once two programs on a connection disagree about where one message ends, they misread every message after it.

Decoding the chunks of the third response above by hand shows how the framing works:

A chunked body, decoded by hand Python · dechunk.py
"""Decoding a chunked body by hand: hexadecimal sizes, data, and the zero-size chunk at the end."""

wire = b"4\r\none\n\r\n4\r\ntwo\n\r\n6\r\nthree\n\r\n0\r\n\r\n"

body = b""
rest = wire
while True:
    size_line, _, rest = rest.partition(b"\r\n")
    size = int(size_line, 16)  # the size is written in hexadecimal
    print(f"size line {size_line.decode()!r:6} -> {size} bytes")
    if size == 0:
        break  # the last chunk: the body is complete
    body += rest[:size]
    rest = rest[size + 2:]  # skip the data and the CR LF after it

print(f"{len(wire)} bytes on the wire carried a {len(body)}-byte body: {body!r}")

Output

size line '4'    -> 4 bytes
size line '4'    -> 4 bytes
size line '6'    -> 6 bytes
size line '0'    -> 0 bytes
34 bytes on the wire carried a 14-byte body: b'one\ntwo\nthree\n'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dechunk.py

Each size line is hexadecimal, so a would mean 10 bytes and 1f4 500. The 34 bytes on the wire carried 14 bytes of body; the rest is framing, the price of not knowing the length in advance.

Exercise · Medium · Python

Parse an HTTP/1.1 request and refuse ambiguous ones

Finish parse_request(data) in request_parser.py. It takes the bytes of one request and returns a dictionary:

  • "method", "target" and "version" from the request line, for example "POST", "/notes" and "HTTP/1.1";
  • "headers": field names in lower case, each with its value (the values of a field that appears more than once joined with ", ");
  • "body": the body as bytes, b"" when there is none.

The starter code already splits the request line and the header lines. Add the rules a careful server applies, and raise ValueError whenever one is broken:

  1. Only HTTP/1.1 and HTTP/1.0 are accepted.
  2. Every header line has a colon, with no space before it (Host : a.example is an error).
  3. An HTTP/1.1 request has exactly one Host header. HTTP/1.0 may leave it out, but never send two.
  4. A request with both Content-Length and Transfer-Encoding is an error: two framing rules could disagree.
  5. With Transfer-Encoding, the last coding must be chunked. Decode the chunks: each starts with its size in hexadecimal and CR LF (anything after a ; on that line is an extension to skip), then the data and CR LF; a chunk of size 0 ends the body.
  6. With Content-Length, every value must be the same whole number (5 twice is fine, 5 and 6 is not), and the body is that many bytes.

parse_request(b"POST /notes HTTP/1.1\r\nHost: a.example\r\nContent-Length: 5\r\n\r\nhello")["body"] is b"hello". The six sample tests cover every rule, among them a request whose two length headers disagree.

Starter code · request_parser.py

def parse_request(data):
    """Parse one HTTP/1.1 (or 1.0) request from bytes; raise ValueError for a bad one."""
    head, _, rest = data.partition(b"\r\n\r\n")
    lines = head.decode("latin-1").split("\r\n")
    method, target, version = lines[0].split(" ")

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

    # Add the rules of the prompt here, and read the body.
    body = b""
    return {"method": method, "target": target, "version": version, "headers": headers, "body": body}
The sample tests · test_request_parser.py
from request_parser import parse_request


def rejects(data):
    """True when parse_request refuses the request with ValueError."""
    try:
        parse_request(data)
    except ValueError:
        return True
    return False


def test_request_line_and_headers():
    """splits the request line and keeps header names in lower case"""
    r = parse_request(b"GET /docs?page=2 HTTP/1.1\r\nHost: shop.example\r\nAccept: text/html\r\n\r\n")
    assert r["method"] == "GET"
    assert r["target"] == "/docs?page=2"
    assert r["version"] == "HTTP/1.1"
    assert r["headers"] == {"host": "shop.example", "accept": "text/html"}
    assert r["body"] == b""


def test_content_length_body():
    """reads exactly Content-Length bytes of body"""
    r = parse_request(b"POST /notes HTTP/1.1\r\nHost: a.example\r\nContent-Length: 5\r\n\r\nhello")
    assert r["body"] == b"hello"
    r = parse_request(b"POST /notes HTTP/1.1\r\nHost: a.example\r\nContent-Length: 2\r\n\r\nhello")
    assert r["body"] == b"he"


def test_chunked_body():
    """decodes a chunked body, skipping chunk extensions"""
    data = b"POST /up HTTP/1.1\r\nHost: a.example\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nnetw\r\n5;note=x\r\norks!\r\n0\r\n\r\n"
    assert parse_request(data)["body"] == b"networks!"
    data = b"POST /up HTTP/1.1\r\nHost: a.example\r\nTransfer-Encoding: chunked\r\n\r\na\r\n0123456789\r\n0\r\n\r\n"
    assert parse_request(data)["body"] == b"0123456789"


def test_host_rules():
    """needs exactly one Host in HTTP/1.1, and never two"""
    assert rejects(b"GET / HTTP/1.1\r\n\r\n") is True
    assert rejects(b"GET / HTTP/1.1\r\nHost: a.example\r\nHost: b.example\r\n\r\n") is True
    assert rejects(b"GET / HTTP/1.0\r\n\r\n") is False
    assert rejects(b"GET / HTTP/1.0\r\nHost: a.example\r\nHost: b.example\r\n\r\n") is True


def test_ambiguous_length():
    """refuses a request whose body length could be read two ways"""
    both = b"POST / HTTP/1.1\r\nHost: a.example\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"
    assert rejects(both) is True
    differ = b"POST / HTTP/1.1\r\nHost: a.example\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\nhello!"
    assert rejects(differ) is True
    same = b"POST / HTTP/1.1\r\nHost: a.example\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\nhello"
    assert parse_request(same)["body"] == b"hello"


def test_malformed_requests():
    """refuses broken header lines, versions, lengths and codings"""
    assert rejects(b"GET / HTTP/1.1\r\nHost : a.example\r\n\r\n") is True
    assert rejects(b"GET / HTTP/1.1\r\nHost: a.example\r\nNoColonHere\r\n\r\n") is True
    assert rejects(b"GET / HTTP/2.0\r\nHost: a.example\r\n\r\n") is True
    assert rejects(b"POST / HTTP/1.1\r\nHost: a.example\r\nContent-Length: five\r\n\r\nhello") is True
    assert rejects(b"POST / HTTP/1.1\r\nHost: a.example\r\nTransfer-Encoding: gzip\r\n\r\nxyz") is True
A hint

Collect every header line into a dictionary of lists first: fields.setdefault(name.lower(), []).append(value). Then len(fields.get("host", [])) tells you how many Host headers there were, and fields.get("content-length") and fields.get("transfer-encoding") tell you which framing rule the request asks for, before you read any body bytes.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Connections are reused

In HTTP/1.1 a connection stays open after a response by default (RFC 9112, section 9.3), so the next request skips the TCP and TLS handshakes, which cost at least one round trip each, as the first lesson of this track measured. Either side ends the connection after a message by sending Connection: close. HTTP/1.0 worked the other way round: one request per connection, unless both sides agreed to keep it open.

What HTTP/1.1 cannot do well is overlap requests on one connection. A client may send several requests without waiting (this is called pipelining), but the server must send the responses in the same order as the requests, so one slow response holds up every response queued behind it. Clients therefore open a few connections to the same server in parallel instead, and RFC 9112 asks them to keep that number limited. HTTP/2 solves the problem properly by interleaving many requests on a single connection, as a later lesson of this module shows.

Content negotiation

One address can have several representations: HTML or JSON, English or Hindi, compressed or not. The client lists what it accepts, and the server picks one and labels it (RFC 9110, section 12).

  • Format: the client sends Accept, the server labels its choice with Content-Type. Accept: application/json gets Content-Type: application/json.
  • Language: Accept-Language and Content-Language. Accept-Language: hi, en;q=0.8 prefers Hindi, then English.
  • Compression: Accept-Encoding and Content-Encoding. Accept-Encoding: gzip, br lets the server compress the body.

The q values rank preferences from 0 to 1. When the choice depends on such a request header, the server adds Vary with that header’s name, for example Vary: Accept-Encoding, so that caches keep the versions apart. A server that has nothing acceptable may answer 406 Not Acceptable; one that cannot read the body a client sent answers 415 Unsupported Media Type.

MIME Type Lookup Find the right Content-Type for a file extension, and check what a file really is from its first bytes.

Cookies carry state

HTTP itself remembers nothing between requests. Cookies add memory (RFC 6265): the server sends Set-Cookie, and the browser sends the value back in a Cookie header on later requests to the same site.

Setting a session cookie and reading it back Python · cookies.py
"""A session cookie: the Set-Cookie header a server sends, and what comes back.

http.cookies builds and parses the headers. The attributes (Path, Max-Age, Secure,
HttpOnly, SameSite) are instructions to the browser and are never sent back: the
browser returns only name=value pairs in the Cookie header.
"""
from http.cookies import SimpleCookie

jar = SimpleCookie()
jar["session"] = "a3f9c2e17b"
jar["session"]["path"] = "/"
jar["session"]["max-age"] = 3600  # seconds: the browser drops it an hour after it arrives
jar["session"]["secure"] = True  # only over HTTPS
jar["session"]["httponly"] = True  # page scripts cannot read it
jar["session"]["samesite"] = "Lax"  # not sent with most cross-site requests
jar["theme"] = "dark"
jar["theme"]["path"] = "/"

print("The server's response carries:")
print(jar.output())

print("\nLater requests from the browser carry one line:")
returned = "session=a3f9c2e17b; theme=dark"
print("Cookie:", returned)

seen = SimpleCookie(returned)
print("\nThe server reads it back as:")
for name, morsel in seen.items():
    print(f"  {name} = {morsel.value}")

Output

The server's response carries:
Set-Cookie: session=a3f9c2e17b; HttpOnly; Max-Age=3600; Path=/; SameSite=Lax; Secure
Set-Cookie: theme=dark; Path=/

Later requests from the browser carry one line:
Cookie: session=a3f9c2e17b; theme=dark

The server reads it back as:
  session = a3f9c2e17b
  theme = dark

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cookies.py

The attributes after the value are instructions to the browser and never come back to the server:

  • Path and Domain limit where the browser sends the cookie; Max-Age (in seconds) or Expires limits how long it keeps it.
  • Secure sends it only over HTTPS; HttpOnly hides it from page scripts, which limits what an injected script can steal.
  • SameSite=Strict, Lax or None controls whether the cookie travels with requests that another site starts, which gives some protection against cross-site request forgery. It is defined in the update of RFC 6265 that the IETF is finishing (draft-ietf-httpbis-rfc6265bis), and that draft has browsers drop a SameSite=None cookie that is not also Secure.

A related rule is often misunderstood. When a page on one site uses JavaScript to read a response from another site, the browser checks the response’s Access-Control-Allow-Origin header before it lets the page see the response. This cross-origin resource sharing (CORS) is defined by the Fetch Standard and enforced by browsers. It protects the people using a browser, not the server: curl or any other program ignores it, so a server must still check every request’s credentials itself.

GATE CS

The GATE 2027 syllabus for Computer Science names exactly two application-layer protocols in its Computer Networks section: DNS and HTTP (syllabus). Methods, status codes, persistent connections and the parts of a message are the HTTP topics to have at your fingertips.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Which of these methods are safe, meaning a request asks only to read and never to change the server's state?

    Choose every answer that is right.

    Show the answer to question 1

    Answer:

    • GET
    • HEAD
    • OPTIONS

    RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe. PUT and DELETE are not safe, because they change what the server stores, but they are idempotent: sending the same one twice leaves the same result as sending it once. POST is neither by default.

  2. Question 2 of 5 A client sent PUT /users/42/email and the connection dropped before any response arrived. May the client library send the same request again on its own?

    Choose one answer.

    Show the answer to question 2

    Answer: Yes. PUT is idempotent, so a second identical PUT leaves the server in the same state as the first.

    An idempotent request can be repeated after a failed connection without changing the outcome, which is why RFC 9110 lets clients retry PUT and DELETE automatically. A POST that creates something is the dangerous case: repeating it can create the thing twice.

  3. Question 3 of 5 The checkout form posts to /checkout, which has moved for good to /pay. Which status code tells every client to repeat the POST, with its body, at the new address?

    Choose one answer.

    Show the answer to question 3

    Answer: 308 Permanent Redirect

    308 forbids changing the method. With 301 (and 302) a client may, for historical reasons, turn the POST into a GET, and 303 asks for exactly that: fetch the new address with GET.

  4. Question 4 of 5 A signed-in user without the admin role asks for /admin/reports. Which status fits, if the site does not mind that the page exists?

    Choose one answer.

    Show the answer to question 4

    Answer: 403 Forbidden

    403 means the server understood the request and refuses it: the credentials it received are not enough. 401 means the request carries no valid credentials, and it must come with a WWW-Authenticate header saying how to authenticate. A site that wants to hide the page's existence may answer 404 instead.

  5. Question 5 of 5 A chunked body arrives as three chunks whose size lines read 1a, 4 and 0. How many bytes of body does the receiver end up with?

    Type a number.

    Show the answer to question 5

    Answer: 30 bytes

    Chunk sizes are hexadecimal: 1a is 26, then 4 more bytes, and the chunk of size 0 marks the end. 26 + 4 = 30.

Interview questions

Fresher · What does it mean that PUT is idempotent and POST is not?

An idempotent request can be sent several times with the same effect on the server as sending it once. PUT /users/42 with the same body leaves user 42 in the same state however often it arrives, and DELETE leaves the resource deleted. That is why a client may repeat them on its own after a dropped connection. POST asks the server to process the body, and processing it twice can create two orders or two payments, so it is not retried automatically. The response may differ between repeats (a second DELETE can get 404); idempotence is about the server’s state, not the response.

Fresher · When does a server answer 401, and when 403?

401 Unauthorized means the request carried no valid credentials: the user is not signed in, or the token has expired. The response must include a WWW-Authenticate header that says how to authenticate. 403 Forbidden means the server understood the request and still refuses it: the credentials it received, if any, are not enough, for example a signed-in user without the admin role. A site that must not reveal that a resource exists may answer 404 instead of 403.

Key takeaways

  • Every HTTP/1.1 message is a start line, header fields, an empty line and an optional body, with CR LF line ends. RFC 9110 defines what the parts mean for HTTP/1.1, HTTP/2 and HTTP/3 alike.
  • Safe methods only read; idempotent methods (the safe ones plus PUT and DELETE) can be repeated after a failure; POST and PATCH are neither, so they are not retried automatically.
  • The first digit of a status code is its class. Know the pairs: 301 and 308, 302 and 307, 401 and 403, 429 and 503, 502 and 504.
  • An HTTP/1.1 request needs exactly one Host header, and a body is framed by Content-Length or by chunks, never by both: a receiver that cannot tell where a message ends must refuse it.
  • HTTP/1.1 reuses connections by default but answers pipelined requests strictly in order, which is why HTTP/2 interleaves them instead.
  • Cookies give HTTP memory. Their attributes stay in the browser, and CORS is a browser rule, not a server’s access control.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.