HTTP Basics for Scraping
On this page
Understanding the Backbone of the Web#
Every scraper is an HTTP client. A browser is also an HTTP client, one that happens to have a rendering engine and a JavaScript runtime bolted on. When you scrape, you replace the browser with code that sends the same requests and reads the same responses, so the first skill is being able to read and write HTTP messages by hand.
When a server receives your request, it answers with a status code, a set of headers, and usually a body (HTML, JSON, an image). Most scraping bugs are HTTP bugs in disguise: a redirect you did not notice, a compressed body you could not decode, a status code you treated as success.
Anatomy of a Request and a Response#
HTTP/1.1 is a plain-text protocol, and seeing a raw exchange once makes everything else easier to reason about:
GET /products?page=2 HTTP/1.1
Host: shop.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0 Safari/537.36
Accept: text/html,application/xhtml+xml
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate
Cookie: session=abc123
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Set-Cookie: session=abc123; Path=/; HttpOnly
Cache-Control: max-age=60
<!doctype html> ...
A request has three parts:
- The request line: the method (
GET,POST), the path plus query string (/products?page=2), and the protocol version. - Headers: key-value metadata about the client and what it will accept.
Hostis mandatory in HTTP/1.1; the ones that matter most for scraping areUser-Agent,Accept,Accept-Language,Accept-Encoding,Referer, andCookie. - The body: empty for
GET, used byPOST/PUT/PATCHto send form data or JSON.
HTTP/2 carries the same semantics (method, path, headers, body) in a binary, multiplexed framing, and header names are lowercase on the wire. Python's requests speaks only HTTP/1.1; httpx can speak HTTP/2 if you install the h2 extra and pass http2=True. For most scraping the version does not change what data you get, but it does change how your client looks to anti-bot systems, which the TLS fingerprinting lesson covers.
Methods You Will Actually Use#
- GET retrieves a resource. Parameters travel in the query string. Safe to retry.
- POST submits data: login forms, search forms, GraphQL queries, many internal APIs. Parameters travel in the body, either form-encoded (
data=in requests) or JSON (json=). - HEAD returns only headers. Useful for checking
Content-LengthorLast-Modifiedbefore downloading a large file, though some servers handle it badly. - PUT/PATCH/DELETE modify state. A read-only scraper should almost never send them.
The distinction between idempotent (GET, HEAD, PUT, DELETE) and non-idempotent (POST) methods matters when you add retries: retrying a GET is harmless, retrying a POST that places an order or submits a form may do it twice.
Status Codes as a Decision Table#
The status code is the first thing your code should branch on. Group them by what your scraper should do next:
| Code | Meaning | What the scraper should do |
|---|---|---|
| 200 | Success | Parse, but still validate the body (a block page can return 200) |
| 204 | Success, no body | Treat as empty result, do not parse |
| 301 / 308 | Permanent redirect | Follow, and update your stored URL to the new one |
| 302 / 303 / 307 | Temporary redirect | Follow, keep the original URL |
| 304 | Not modified | Reuse your cached copy (only happens if you sent a conditional header) |
| 400 | Bad request | Your request is malformed; fix the code, do not retry |
| 401 / 403 | Unauthorized / Forbidden | Missing credentials, or a WAF or anti-bot decision; do not hammer |
| 404 / 410 | Not found / Gone | Record as missing, do not retry |
| 429 | Too many requests | Slow down, honor Retry-After |
| 500 / 502 / 503 / 504 | Server or gateway failure | Retry with exponential backoff and a cap |
The error handling lesson turns this table into retry code. The key habit is that "2xx" does not mean "correct data" and "4xx" does not all mean the same thing.
Why Headers Matter#
By default requests sends User-Agent: python-requests/2.x and httpx sends python-httpx/0.x. Many sites reject those outright because they cannot be anything other than a script. You have two honest options: a descriptive bot identity with contact details (MyDataBot/1.0 (+contact: data@mycompany.example)), which the robots.txt and ethics lesson recommends wherever it works, or a browser-like header set when the site only serves normal browsers.
If you send browser-like headers, send a coherent set. A Chrome User-Agent paired with no Accept-Language and Accept: */* looks like nothing a real Chrome ever sends.
import requests
url = "https://example.com/api/data"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
}
response = requests.get(url, headers=headers, timeout=(3, 15))
print("status:", response.status_code)
print("sent headers:", dict(response.request.headers))
response.request.headers shows what was actually sent, including headers the library added for you (Accept-Encoding, Connection). Checking it is the fastest way to find out why your "identical" request behaves differently from the browser's.
Reading the Response Like a Postmortem#
Before writing a single selector, inspect what the server actually sent. The status code tells you the outcome; the headers and redirect chain tell you what your scraper will face next.
import httpx
resp = httpx.get("https://example.com/products", timeout=15.0, follow_redirects=True)
print("status:", resp.status_code)
print("final url:", resp.url)
print("content-type:", resp.headers.get("content-type"))
print("redirect chain:", [(r.status_code, r.headers["location"]) for r in resp.history])
print("elapsed:", resp.elapsed.total_seconds(), "seconds")
Note follow_redirects=True. Unlike requests, httpx does not follow redirects by default, so without it you get the 301 itself, an empty history, and a body that is just a stub. That single default difference explains a lot of "httpx returns nothing" bug reports.
Typical output for a page that has moved looks like:
status: 200
final url: https://www.example.com/catalog/products
content-type: text/html; charset=utf-8
redirect chain: [(301, 'https://www.example.com/products'), (301, '/catalog/products')]
elapsed: 0.412 seconds
The headers worth reading on every new target:
content-typetells you whether to parse HTML or call.json(), and which charset the text is in.content-encodingsays the body was compressed (gzip,deflate,br,zstd); the client decompresses for you, but only for encodings it supports.set-cookieissues session state; see cookies and state.retry-afteris the server's precise instruction for how long to wait when rate limiting you.cache-control,etag, andlast-modifiedlet you make conditional requests and skip unchanged pages.
How HTTP Goes Wrong in Scrapers#
Compressed garbage. If you copy Accept-Encoding: gzip, deflate, br from a browser but the brotli package is not installed, the server may send Brotli and requests hands you undecoded bytes. resp.text looks like random symbols. Either install brotli or advertise only gzip, deflate.
Wrong character encoding. requests takes the charset from Content-Type; if the header omits it for a text/* type, it falls back to ISO-8859-1 and accented characters turn into mojibake like Café. Compare resp.encoding with resp.apparent_encoding, or decode resp.content yourself using the <meta charset> in the page.
Silent redirects. A product URL that redirects to the homepage or a login page returns 200 after following, and your selectors quietly find nothing. Log resp.url and compare it with the URL you asked for.
Hanging requests. Neither library applies a total deadline for you: requests has no timeout at all unless you pass one, and one stalled server can freeze a whole crawl. Always pass timeout.
Debugging With curl#
When Python and the browser disagree, drop down to curl, which shows the raw exchange:
curl -sv -o /dev/null -H "Accept-Language: en-US" https://example.com/products 2>&1 | grep -E "^[<>]"
curl -sI https://example.com/products # headers only
curl -sL -w "%{http_code} %{url_effective}" -o /dev/null https://example.com/old-path
Lines starting with > are what you sent, < is what came back. The last command follows redirects and prints the final status and URL, a quick way to map redirect behavior across many URLs.
Practice#
Pick any public page you are allowed to crawl. Fetch it with requests and httpx (with and without follow_redirects), print status, final URL, content-type, content-encoding, and the sent headers for each, then reproduce the same request with curl -v. Explain every difference you see in one sentence each.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.