DEV Community

pathvector-dev
pathvector-dev

Posted on • Originally published at blog.pathvector.dev

HTTP/1.1 in the Clear: Read One Request and Response Line by Line, Then Watch 304 Save the Day

Originally published at https://blog.pathvector.dev/protocol-lab-http-10/ — part of the free Protocol Lab series.

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

TCP carried bytes; TLS could wrap them. This lab looks at the bytes themselves: one HTTP/1.1 request and its response, in cleartext, so you can name every line.

Reading guide: rfc-notes/http-requests-responses-caching.md

Prerequisite: TCP Lab 07: One Connection, From SYN to FIN

Expected time: 45–60 minutes.

The Goal

You will send four requests and read each result:

  • GET /200 OK with Cache-Control and ETag,
  • HEAD /200 headers only, no body,
  • GET / with If-None-Match304 Not Modified (the cached copy is still fresh),
  • GET /missing404 Not Found.

By the end, you should be able to label this exchange:

> GET / HTTP/1.1            <- request line: method, target, version
> Host: 10.0.0.2:8080       <- request header
>
< HTTP/1.1 200 OK           <- status line
< Content-Type: text/plain; charset=utf-8
< Content-Length: 40
< Cache-Control: max-age=60 <- how long a cache may reuse this
< ETag: "v1-abc123"         <- a validator for conditional requests
<
Hello from the Protocol Lab HTTP server.
Enter fullscreen mode Exit fullscreen mode

What You Will Learn

  • The shape of an HTTP message: start line, headers, blank line, body.
  • What the GET and HEAD methods do, and how HEAD differs from GET.
  • How status codes group into 2xx / 3xx / 4xx and what 200, 304, and 404 mean.
  • What Cache-Control and ETag are for.
  • How a conditional request (If-None-Match) produces 304 Not Modified and saves a body.

This lab does not cover:

  • HTTPS/TLS on top of HTTP (that was Lab 09).
  • HTTP/2 framing and multiplexing (that is Lab 11).
  • A full cache implementation, Vary, or revalidation edge cases.
  • Cookies, auth, or redirects in depth.

Where to Read in the RFCs

The current HTTP specification is RFC 9110–9112 (2022).

RFC Section What to focus on
RFC 9110 3, 6 The concepts of resource, representation, and message
RFC 9110 9 Methods (the definitions of GET / HEAD)
RFC 9110 15 Status codes (what 200 / 304 / 404 mean)
RFC 9111 5.2 Cache-Control directives
RFC 9111 4.3 Validation and conditional requests (ETag / If-None-Match / 304)
RFC 9112 2–3 HTTP/1.1 message syntax (start line, headers, body)

The Big Picture

Same two-node topology as Lab 07. The server is a small Python HTTP server. No TLS — we observe everything in cleartext.

client (10.0.0.1) ------ eth1/eth1 ------ server (10.0.0.2:8080)
                                          python3 app.py
                                          GET /  -> 200 (+Cache-Control, ETag)
                                          If-None-Match -> 304
                                          /missing -> 404
Enter fullscreen mode Exit fullscreen mode

Both nodes run nicolaka/netshoot (which bundles curl, python3, and tcpdump). No additional images are required.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab

Images used:

  • nicolaka/netshoot:latest

Running the Lab

The quick path, which deploys, starts the HTTP server, runs all four curl requests, checks the statuses, headers, and cache behavior, and tears down for you:

./scripts/labctl.sh run http-10
Enter fullscreen mode Exit fullscreen mode

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/http-10
Enter fullscreen mode Exit fullscreen mode

2. Read the server, then start it

cat server/app.py
sudo containerlab deploy -t http-10.clab.yml
docker exec -d clab-http-10-server python3 /app/app.py
Enter fullscreen mode Exit fullscreen mode

app.py is a tiny server that returns 200 plus cache headers for GET /, a 304 when If-None-Match matches, and a 404 for /missing.

3. Send GET / (200 with cache headers)

docker exec clab-http-10-client curl -v http://10.0.0.2:8080/
Enter fullscreen mode Exit fullscreen mode

What to look for:

> GET / HTTP/1.1
> Host: 10.0.0.2:8080
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 40
< Cache-Control: max-age=60
< ETag: "v1-abc123"
Enter fullscreen mode Exit fullscreen mode

Lines starting with > are the request you sent; lines starting with < are the response that came back (curl's notation).

4. Send HEAD / (headers only)

docker exec clab-http-10-client curl -v -I http://10.0.0.2:8080/
Enter fullscreen mode Exit fullscreen mode

HEAD returns the same headers as GET but no body. Content-Length is present, yet the body is empty.

5. Send a conditional GET (304)

Take the ETag you just saw, put it in If-None-Match, and GET again.

docker exec clab-http-10-client curl -v -H 'If-None-Match: "v1-abc123"' http://10.0.0.2:8080/
Enter fullscreen mode Exit fullscreen mode

What to look for:

< HTTP/1.1 304 Not Modified
< ETag: "v1-abc123"
< Cache-Control: max-age=60
Enter fullscreen mode Exit fullscreen mode

304 means "the copy you already have is still fresh — I'm not sending the body." This cuts both network traffic and server load.

6. Hit a path that doesn't exist (404)

docker exec clab-http-10-client curl -v http://10.0.0.2:8080/missing
Enter fullscreen mode Exit fullscreen mode
< HTTP/1.1 404 Not Found
Enter fullscreen mode Exit fullscreen mode

7. Confirm it's cleartext with a capture

docker exec clab-http-10-client sh -c \
  "tcpdump -i eth1 -A -s0 'tcp port 8080' &  sleep 1;  curl -s http://10.0.0.2:8080/ >/dev/null;  sleep 1;  pkill tcpdump"
Enter fullscreen mode Exit fullscreen mode

With -A, tcpdump prints the payload as ASCII, and you can read GET / HTTP/1.1 and HTTP/1.1 200 OK right off the wire. There is no TLS, so any on-path observer sees the contents — a direct contrast with Lab 09.

Expected Output

  • GET /: HTTP/1.1 200 OK, Cache-Control: max-age=60, ETag: "v1-abc123".
  • HEAD /: the same headers, no body.
  • Conditional GET: HTTP/1.1 304 Not Modified, no body.
  • GET /missing: HTTP/1.1 404 Not Found.
  • The capture: request line, status line, and headers all readable in cleartext.

Why It Works

HTTP is a protocol for exchanging representations of resources via request/response. One message is, in order: a start line (a request line or a status line), a set of headers, a blank line, and a body.

  • Methods say "what do I want to do." GET retrieves; HEAD says "just the headers, no body please." That makes HEAD cheap to transfer — handy for checking whether something exists or how big it is.
  • Status codes classify the outcome. 2xx is success, 3xx means further action, 4xx is a client-side problem. 200 is a successful fetch, 404 means "not here," and 304 means "unchanged."
  • Cache headers: Cache-Control: max-age=60 says "you may reuse this as-is for 60 seconds." ETag is an identifier — a validator — for this particular representation.
  • Conditional requests: next time around, the client (or a cache) asks with If-None-Match: <etag>. If nothing changed, the server answers 304 with no body; if it did change, it answers 200 with the new body. That's how HTTP avoids re-sending things that haven't changed.

The key insight: HTTP's semantics — methods, status codes, headers — read as a layer that is completely independent of the TCP and TLS underneath.

Common Pitfalls

  • Expecting HEAD to return a body. HEAD is headers only. Content-Length is present, but there is no body.
  • Reading 304 as an error. 304 is a successful optimization: "nothing changed, so I'm not sending it."
  • Mixing up the roles of Cache-Control and ETag. max-age says how long you may reuse a copy (freshness); ETag is the mark you use to check whether it's still the same (validation).
  • Assuming curl caches on its own. curl does not cache. What this lab shows is the caching mechanism — the headers and the 304. Browsers and CDNs are what actually use it.
  • Forgetting this is cleartext. This lab is plain HTTP (no encryption), which is exactly why the capture is readable. Real deployments use HTTPS.
  • The Host header. Mandatory in HTTP/1.1. It lets one IP address serve multiple sites apart — playing a role similar to what SNI does for TLS.

Cleanup

sudo containerlab destroy -t http-10.clab.yml --cleanup
Enter fullscreen mode Exit fullscreen mode

If you used labctl.sh run http-10, the script runs destroy for you at the end.

Check Your Understanding

  1. What are the four components of an HTTP message, starting with the start line?
  2. How do GET and HEAD differ? What is HEAD useful for?
  3. What do 200 / 304 / 404 each mean, and which group (2xx/3xx/4xx) does each belong to?
  4. What are Cache-Control: max-age=60 and ETag each for?
  5. When does a conditional GET (If-None-Match) return 304? What does that save?
  6. Why can this lab's traffic be read in a capture? How does that differ from Lab 09?

References

Verified Run Log (2026-07-05)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • client / server: nicolaka/netshoot:latest (bundles curl / python3; the server runs server/app.py)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run http-10 performed deploy → verify → destroy, and verification.json returned "status": "verified". No image or topology fixes were needed.

GET / (200 OK plus cache headers)

$ curl -sv http://10.0.0.2:8080/
< HTTP/1.1 200 OK
< Server: protocol-lab/1.0 Python/3.14.5
< Content-Type: text/plain; charset=utf-8
< Content-Length: 41
< Cache-Control: max-age=60
< ETag: "v1-abc123"
Enter fullscreen mode Exit fullscreen mode

Cache-Control: max-age=60 means "you may reuse this as-is for 60 seconds." The ETag is the version identifier for this representation — we'll use it for the conditional GET next.

HEAD / (headers only, no body)

$ curl -sv -I http://10.0.0.2:8080/
< HTTP/1.1 200 OK
< Content-Length: 41
< Cache-Control: max-age=60
< ETag: "v1-abc123"
Enter fullscreen mode Exit fullscreen mode

The same headers come back as for GET — including Content-Length: 41 — but no body is transferred.

Conditional GET (If-None-Match → 304 Not Modified)

$ curl -sv -H 'If-None-Match: "v1-abc123"' http://10.0.0.2:8080/
> If-None-Match: "v1-abc123"
< HTTP/1.1 304 Not Modified
< ETag: "v1-abc123"
Enter fullscreen mode Exit fullscreen mode

The client sends the ETag it already holds in If-None-Match, and the server replies "unchanged" — 304 Not Modified with no body. That's revalidation: permission to keep reusing the cached representation.

GET /missing (404 Not Found)

$ curl -sv http://10.0.0.2:8080/missing
< HTTP/1.1 404 Not Found
Enter fullscreen mode Exit fullscreen mode

A path that doesn't exist gets a 404.

Cleanup

containerlab destroy -t http-10.clab.yml --cleanup
Enter fullscreen mode Exit fullscreen mode

That's one HTTP/1.1 exchange, read in the clear: a start line, some headers, a blank line, and a body — with Cache-Control, ETag, and 304 quietly doing the work of never re-sending what hasn't changed.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up in Lab 11, we'll move from readable text to binary frames and see how HTTP/2 multiplexes many streams over one connection.

Top comments (0)