Syllabus Lesson 33 of 156 · HTTP from the inside
HTTP from the inside

Parse a raw HTTP request

Why it matters Every page you load starts as a short piece of text your browser sends to a server, and that text is where most tracking information travels: your cookies, the page you came from, your language. Once you can read it, nothing about it is hidden from you, and every tool in this leg (the tracker, the blocker, the measurements) is built on top of it.

Think of an HTTP request as a letter in a very strict format. The first line says what you want, then come labelled notes on the envelope, then a blank line, then (sometimes) the contents. A response is the reply letter, in the same format. Your browser writes and reads thousands of these a day.

HTTP: HyperText Transfer Protocol, the agreed format browsers and servers use to ask for and send pages, images and data.

Request: the message the browser sends: a method (what to do, such as GET to fetch or POST to send a form), a target (which path on the server) and a version.

Header: one labelled note in the message, written Name: value on its own line, such as Accept-Language: en-GB.

What a raw request looks like

Here is a request the practice browser sent for ShopA's tent page. Every line ends in two invisible characters, carriage return and line feed, written \r\n in Python. An empty line (so \r\n\r\n in a row) marks the end of the head.

GET /p/tent HTTP/1.1
Host: shopa.example
User-Agent: PracticeBrowser/1.0 (Linux) Practice Firefox 144
Accept-Language: en-ZA
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
  • Request line: GET /p/tent HTTP/1.1, three parts separated by single spaces.
  • Headers: one per line, the name, a colon, then the value.
  • Blank line: the head is over.
  • Body: whatever follows. A GET usually has none; a form POST carries the form fields, such as user=sam.

A response has the same shape, but its first line is a status line: HTTP/1.1 200 OK. The middle part is the status code (200 means fine, 302 means "go elsewhere", 404 means "not found") and the rest is the reason phrase, a human label that can contain spaces (Moved Permanently) or be missing altogether.

Status code: a three-digit number at the start of a response saying how things went. 2xx success, 3xx redirect, 4xx a problem with the request, 5xx a problem on the server.

Three Python tools you need

text.partition(sep) splits at the first sep and gives back three pieces: before, the separator, after. text.split(sep, 2) splits at most twice, so a reason phrase with spaces stays in one piece. text.strip() removes spaces from both ends.

"Referer: https://shopa.example/".partition(":")
# ('Referer', ':', ' https://shopa.example/')
"HTTP/1.1 301 Moved Permanently".split(" ", 2)
# ['HTTP/1.1', '301', 'Moved Permanently']

A common mistake

Splitting a header line at every colon. Referer: https://shopa.example/ has a colon inside its value, so line.split(":") cuts the address in half. Always split at the first colon only. Real browsers learned this the hard way: sloppy parsers are a classic source of security bugs, where two programs read the same message differently.

Does the real web still look like this?

Mostly in spirit. Browsers today usually speak HTTP/2 or HTTP/3, which send the same methods, headers and bodies packed into binary frames instead of text lines. DevTools shows them to you as text anyway, so the fields you learn here are exactly the ones you will see there (checked October 2026). The practice world writes everything as HTTP/1.1 text, which is still what many servers and tools use.

Try it Open DevTools (F12, or Cmd+Option+I on a Mac), choose the Network tab and reload any page. Click the first request and look at Request Headers. Many browsers offer a "Raw" switch that shows the head exactly in this format.

In other languages

JavaScript:

function parseRequest(raw) {
  const [head, ...rest] = raw.split("\r\n\r\n");
  const lines = head.split("\r\n");
  const [method, target, version] = lines[0].split(" ");
  const headers = lines.slice(1).map(line => {
    const i = line.indexOf(":");            // first colon only
    return [line.slice(0, i).trim(), line.slice(i + 1).trim()];
  });
  return { method, target, version, headers, body: rest.join("\r\n\r\n") };
}

Go:

// the standard library has a real parser: net/http.ReadRequest
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(raw)))
if err == nil {
    fmt.Println(req.Method, req.URL.Path, req.Header.Get("User-Agent"))
}

Rust:

let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, ""));
let mut lines = head.split("\r\n");
let mut first = lines.next().unwrap().splitn(3, ' ');
let (method, target) = (first.next(), first.next());
let headers: Vec<(&str, &str)> = lines
    .filter_map(|l| l.split_once(':'))
    .map(|(n, v)| (n.trim(), v.trim()))
    .collect();

Recap

  • A request is a request line, headers, a blank line, then an optional body; a response starts with a status line instead.
  • Split the head from the body at the first \r\n\r\n, and each header at its first colon.
  • The reason phrase can contain spaces or be missing, so split the status line at most twice.
  • HTTP/2 and HTTP/3 carry the same fields in binary, so this picture still holds.
Your turn

Write parse_request(raw) returning a dict with "method", "target" (path plus query), "version", "headers" (a list of (name, value) pairs in order, split at the first colon, both stripped) and "body" (everything after the first blank line, "" if none). Then write parse_response(raw) returning "version", "status" (an int), "reason" ("" if missing), "headers" and "body" in the same way.

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output