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
GETusually has none; a formPOSTcarries the form fields, such asuser=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.
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.
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.