Filter concepts by levelShowing all levels.

Python · Web and HTTP Fundamentals

HTTP

Concepts
5

HTTP methods and their safety/idempotency guarantees, status codes, headers, cookies and sessions, and the content-negotiation/compression/caching/connection-reuse mechanics — verified against live endpoints, not described in the abstract.

Python overview

The request/response exchange

The vocabulary of a single HTTP exchange — the method stating intent, the status code stating outcome, and headers carrying everything else about the message.

HTTP methods

corebeginner

Each HTTP method is a verb that states intent — GET reads, POST creates or triggers an action, PUT/PATCH update, DELETE removes — and two properties, safety and idempotency, decide whether a client or a proxy can retry a request automatically.

Think of it as

A method is a labeled request slot at a counter: "returns" (GET), "new order" (POST), "replace this order entirely" (PUT), "just fix this one line" (PATCH), "cancel" (DELETE). The label tells the counter clerk — and any automatic retry logic — whether resubmitting the same slip twice is safe or would double the order.

python
import httpx

client.get(url)                       # safe, idempotent
client.post(url, json={'name': 'x'})  # NOT idempotent -- calling twice creates two
client.put(url, json=full_resource)   # idempotent -- same end state every time
client.patch(url, json={'name': 'y'}) # NOT idempotent by the spec's own definition
client.delete(url)                    # idempotent -- deleting twice ends the same way

What we're doing: Prove PUT is idempotent (repeating it produces the identical response) using a real live endpoint, the concrete meaning of "idempotent" beyond the definition alone.

method_idempotency.pypython
import httpx

with httpx.Client() as client:
    r1 = client.put('https://httpbin.org/put', json={'status': 'active'})
    r2 = client.put('https://httpbin.org/put', json={'status': 'active'})

    body1 = r1.json()['json']
    body2 = r2.json()['json']
    print('status codes:', r1.status_code, r2.status_code)
    print('same resulting resource state:', body1 == body2)
4
The first PUT sends the full desired state of the resource.
5
The second, identical PUT is safe to repeat — a network retry after a lost response would land here with no different outcome.
Output
status codes: 200 200
same resulting resource state: True

Why this works: Both PUT calls return status 200 and the identical echoed resource body — sending the same PUT request any number of times leaves the resource in the same final state, which is the concrete, testable meaning of idempotent, not just a label from a table.

Safe to retry, or not — by method

GET, HEAD, OPTIONS

safe AND idempotent — always fine to retry

PUT, DELETE

idempotent, not safe — retry is fine, state already changes

POST, PATCH

neither — a blind retry can create a duplicate

  • GET, HEAD, OPTIONS — safe AND idempotent — always fine to retry
  • PUT, DELETE — idempotent, not safe — retry is fine, state already changes
  • POST, PATCH — neither — a blind retry can create a duplicate

Automatically retrying a failed POST request

Wrong

python
for attempt in range(3):
    try:
        response = client.post('/orders', json=order_data)
        break
    except httpx.TimeoutException:
        continue   # if the first POST actually succeeded server-side, this creates a duplicate order

Better

python
# generate an idempotency key once, send it with every retry attempt
idempotency_key = str(uuid.uuid4())
for attempt in range(3):
    try:
        response = client.post(
            '/orders', json=order_data,
            headers={'Idempotency-Key': idempotency_key},   # server dedupes by this key
        )
        break
    except httpx.TimeoutException:
        continue

What you see: A duplicate order/charge appears after a network blip — the first POST actually succeeded server-side, but the client never saw the response and retried blindly.

Why: POST is not idempotent by the HTTP spec, so blindly retrying it after a timeout (where the request may have already succeeded) can create a second resource — a server-recognized Idempotency-Key lets the server safely reject or dedupe a retried request instead of creating a duplicate.

The seven methods

The seven methods
MethodPurposeSafeIdempotent
GETRead a resourceYesYes
POSTCreate a resource / trigger an actionNoNo
PUTReplace a resource entirelyNoYes
PATCHPartially update a resourceNoNo
DELETERemove a resourceNoYes
OPTIONSDiscover allowed methods/CORS preflightYesYes
HEADSame as GET, headers only, no bodyYesYes

Remember: Safe methods never change state; idempotent methods are safe to retry — POST and PATCH are neither by default, so retry them only with a dedupe key.

See also: status codes

HTTP status codes

corebeginner

A status code is a 3-digit number in the first line of every HTTP response, grouped by its first digit into five classes — 2xx success, 3xx redirect, 4xx the client's fault, 5xx the server's fault — and picking the specific, correct code (not just 200 or 500 for everything) is what makes an API self-describing.

Think of it as

The first digit is a traffic light color glanced at from across the room — green (2xx) proceed, yellow (3xx) go elsewhere first, red client (4xx) you did something wrong, red server (5xx) something broke on our end. The full three digits are the specific instruction once you are close enough to read it.

python
from http import HTTPStatus

HTTPStatus.OK                    # 200
HTTPStatus.CREATED               # 201
HTTPStatus.NO_CONTENT            # 204
HTTPStatus.NOT_FOUND             # 404
HTTPStatus.UNPROCESSABLE_ENTITY  # 422
HTTPStatus.TOO_MANY_REQUESTS     # 429

What we're doing: Confirm the stdlib HTTPStatus enum's integer values match the real codes named throughout this concept, run directly rather than assumed.

status_codes.pypython
from http import HTTPStatus

for status in [
    HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.NO_CONTENT,
    HTTPStatus.NOT_FOUND, HTTPStatus.UNPROCESSABLE_ENTITY,
    HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.INTERNAL_SERVER_ERROR,
    HTTPStatus.SERVICE_UNAVAILABLE,
]:
    print(f'{int(status)} {status.name} - {status.phrase}')
1
http.HTTPStatus is stdlib, no install needed — every standard code has a name, an int value, and a human-readable phrase.
Output
200 OK - OK
201 CREATED - Created
204 NO_CONTENT - No Content
404 NOT_FOUND - Not Found
422 UNPROCESSABLE_ENTITY - Unprocessable Entity
429 TOO_MANY_REQUESTS - Too Many Requests
500 INTERNAL_SERVER_ERROR - Internal Server Error
503 SERVICE_UNAVAILABLE - Service Unavailable

Why this works: The printed integers confirm the codes are exactly what this concept states (200, 201, 204, 404, 422, 429, 500, 503) — using the named enum in application code (return HTTPStatus.NOT_FOUND, not the bare literal 404) makes the intent readable at the call site without memorizing numbers.

The five classes, by first digit

2xx Success

200 OK, 201 Created, 204 No Content

3xx Redirect

301 permanent, 302 temporary, 304 Not Modified

4xx Client error

400, 401, 403, 404, 409, 422, 429

5xx Server error

500, 502, 503, 504

  • 2xx Success — 200 OK, 201 Created, 204 No Content
  • 3xx Redirect — 301 permanent, 302 temporary, 304 Not Modified
  • 4xx Client error — 400, 401, 403, 404, 409, 422, 429
  • 5xx Server error — 500, 502, 503, 504

Returning 200 OK for a response body that actually reports an error

Wrong

python
# handler always returns 200, error is only inside the JSON body
return {'error': 'user not found'}, 200

Better

python
return {'error': 'user not found'}, 404   # the status line itself carries the outcome

What you see: Client-side error handling written around response.ok / status checks never triggers — callers must inspect every response body to know if it failed, defeating a huge part of what HTTP status codes are for.

Why: HTTP client libraries, caches, load balancers, and monitoring all key off the status code first — a 200 with an error hidden in the body is invisible to all of them (a cache may even cache the "successful" error), while a correct 404 lets every layer of the stack react appropriately without parsing the body.

The five classes

The five classes
RangeMeaningCommon codes
2xxSuccess200, 201, 204
3xxRedirection301, 302, 304
4xxClient error400, 401, 403, 404, 409, 422, 429
5xxServer error500, 502, 503, 504

Remember: 2xx success / 3xx redirect / 4xx client's fault / 5xx server's fault — the status line, not the body, is what every intermediate layer inspects.

See also: http methods

HTTP headers

standardbeginner

Headers are name: value metadata pairs sent alongside every request and response, separate from the body — they describe the message (Content-Type, Content-Length), control it (Cache-Control, Authorization), or negotiate it (Accept, Accept-Encoding), without the receiver having to parse the body to know how to handle it.

Think of it as

Headers are the outside of a shipped package — the label. A courier decides how to route, refrigerate, or verify the package entirely from what is printed on the outside (headers), without opening it (the body) — that separation is exactly why headers exist as their own channel.

python
import httpx

response = client.get(
    url,
    headers={
        'Authorization': f'Bearer {token}',
        'Accept': 'application/json',
    },
)
print(response.headers['content-type'])   # reading is case-insensitive too

What we're doing: Send custom headers on a real request and confirm the server received them exactly as sent, case-insensitively readable back.

headers_roundtrip.pypython
import httpx

with httpx.Client() as client:
    response = client.get(
        'https://httpbin.org/headers',
        headers={'Authorization': 'Bearer test-token-123', 'X-Request-Id': 'req-42'},
    )
    echoed = response.json()['headers']
    print('Authorization:', echoed.get('Authorization'))
    print('X-Request-Id:', echoed.get('X-Request-Id'))
    print('Content-Type read case-insensitively:', response.headers.get('content-type'))
4
httpbin.org/headers echoes back exactly what it received, useful for confirming headers travel as sent.
9
httpx normalizes header lookups to be case-insensitive — "content-type" finds the same value as "Content-Type" would.
Output
Authorization: Bearer test-token-123
X-Request-Id: req-42
Content-Type read case-insensitively: application/json

Why this works: The echoed headers match exactly what was sent, confirming headers travel as opaque metadata untouched by the server's body-handling logic — and content-type (lowercase) still finds the value even though HTTP wire format typically capitalizes it, proving the case-insensitivity claim rather than just stating it.

Putting an API token in the URL query string instead of a header

Wrong

python
url = f'https://api.example.com/data?api_key={secret_token}'
response = client.get(url)
# token now sits in server access logs, browser history, and any proxy's logs

Better

python
response = client.get(
    'https://api.example.com/data',
    headers={'Authorization': f'Bearer {secret_token}'},
)

What you see: No functional bug — the request still succeeds — but the credential leaks into every layer that logs URLs: web server access logs, browser history, proxy logs, and any tool that prints request URLs for debugging.

Why: URLs are logged by default almost everywhere in the request path, while headers like Authorization are typically excluded from default logging specifically because credentials belong there — using the header keeps the secret out of every place a URL gets written down.

Common headers by purpose

Common headers by purpose
HeaderDirectionPurpose
Content-TypeRequest & responseFormat of the body (application/json, text/html)
AuthorizationRequestCredentials (Bearer token, Basic auth)
AcceptRequestFormats the client can handle back (content negotiation)
Accept-EncodingRequestCompression the client can decode (gzip, br)
Cache-ControlResponseCaching rules (max-age, no-store, public/private)
ETagResponseA version fingerprint for conditional requests

Remember: Headers describe/control the message without opening the body — credentials belong in Authorization, never in the URL.

See also: content negotiation and performance

Advertisement

State and performance

Cookies and sessions for recognizing a returning client, and the negotiation/caching/connection-reuse mechanics that make repeated requests cheaper.

Cookies and sessions

coreintermediate

A cookie is a small piece of data the server asks the browser to store and resend on every subsequent request to the same site — it is what makes HTTP, which has no memory between requests on its own, able to recognize "this is the same visitor" across a session.

Think of it as

HTTP by itself is a stranger at the door every single time — no memory of the last visit. A cookie is a wristband stamped at check-in: the server stamps it once (Set-Cookie), and the browser shows it back automatically on every later request (Cookie) so the door does not have to ask "who are you" again.

python
import http.cookies

cookie = http.cookies.SimpleCookie()
cookie['session_id'] = 'abc123'
cookie['session_id']['httponly'] = True
cookie['session_id']['secure'] = True
cookie['session_id']['samesite'] = 'Lax'
cookie['session_id']['max-age'] = 3600
print(cookie.output())   # the literal Set-Cookie header line

What we're doing: Build a real session cookie with the standard security attributes and confirm the exact Set-Cookie header string produced.

session_cookie.pypython
import http.cookies

c = http.cookies.SimpleCookie()
c['session_id'] = 'abc123'
c['session_id']['httponly'] = True
c['session_id']['secure'] = True
c['session_id']['samesite'] = 'Lax'
c['session_id']['max-age'] = 3600

print(c.output())
5
httponly=True is what stops document.cookie in the browser from ever reading this value.
6
secure=True refuses to send this cookie over a plain, unencrypted HTTP connection.
Output
Set-Cookie: session_id=abc123; HttpOnly; Max-Age=3600; SameSite=Lax; Secure

Why this works: The printed line is the literal, real Set-Cookie header the server would send — every security attribute is visible in it, confirming the cookie really does carry HttpOnly/Secure/SameSite rather than those being described abstractly and hoped for.

A cookie makes stateless HTTP recognize the same visitor
Browser
Server
  1. 1. GET /login
  2. 2. Set-Cookie: session_id=abc123; HttpOnly; Secureserver stamps the wristband once
  3. 3. GET /dashboard (Cookie: session_id=abc123)browser echoes it back automatically
  4. 4. looks up session server-side, from session_id alone
  1. Browser → Server: GET /login
  2. Server → Browser: Set-Cookie: session_id=abc123; HttpOnly; Secure (server stamps the wristband once)
  3. Browser → Server: GET /dashboard (Cookie: session_id=abc123) (browser echoes it back automatically)
  4. Server → Browser: looks up session server-side, from session_id alone

Storing sensitive data directly in a cookie instead of a session ID

Wrong

python
c['user'] = json.dumps({'user_id': 42, 'is_admin': True})   # sent to the browser, unsigned
# a user can edit this cookie's value directly and grant themselves admin

Better

python
c['session_id'] = generate_secure_random_id()   # opaque, unguessable
c['session_id']['httponly'] = True
# is_admin lives server-side, looked up FROM session_id, never trusted from the client

What you see: A user edits their own cookie value in browser dev tools and gains privileges they should not have — no server log shows anything unusual, since it is a normal-looking request.

Why: A cookie is client-controlled storage — anything readable and unsigned inside it can be edited by the user holding the browser. A session ID is meaningless on its own (just an opaque lookup key); the actual trusted data stays server-side, keyed by that ID, where the client cannot alter it directly.

Cookie attributes

Cookie attributes
AttributeEffect
HttpOnlyNot readable from JavaScript — blocks theft via XSS
SecureOnly sent over HTTPS
SameSite=StrictNever sent on a cross-site request
SameSite=LaxSent on top-level navigation, not on cross-site subrequests (default in modern browsers)
Max-Age / ExpiresHow long the browser keeps the cookie before dropping it

Remember: HttpOnly blocks JS theft, Secure blocks plaintext transmission, SameSite blocks cross-site sending — and cookies should hold a session ID, never trusted data itself.

See also: csrf · xss

Content negotiation, compression, caching, and connection reuse

coreintermediate

Content negotiation lets a client and server agree on a response FORMAT (Accept) and encoding (Accept-Encoding for compression) before the body is sent; caching headers let a client skip re-fetching unchanged data; and keep-alive/connection pooling let multiple requests reuse one already-open TCP connection instead of paying a new handshake every time.

Think of it as

Content negotiation is ordering off a menu in your preferred language before the kitchen starts cooking. Compression is shrink-wrapping the dish for the trip. A cache header is a note on the container saying "still good until Tuesday, no need to re-order." Keep-alive is the delivery courier waiting at the door instead of driving back to base between every single order.

python
import httpx

with httpx.Client() as client:            # a Client pools/reuses connections
    r1 = client.get(url)                  # first request opens a connection
    r2 = client.get(url)                  # second request reuses it -- no new handshake

    cached = client.get(url, headers={'If-None-Match': r1.headers['etag']})
    print(cached.status_code)             # 304 if unchanged -- no body sent at all

What we're doing: Run a real conditional request and confirm the server actually returns 304 Not Modified with no fresh body — the concrete payoff caching headers exist for.

conditional_request.pypython
import httpx

with httpx.Client() as client:
    first = client.get('https://httpbin.org/etag/abc123')
    etag = first.headers.get('etag')

    second = client.get(
        'https://httpbin.org/etag/abc123',
        headers={'If-None-Match': etag},
    )
    print('first status:', first.status_code)
    print('etag:', etag)
    print('conditional status:', second.status_code)
4
The first request fetches the resource normally and receives an ETag — a fingerprint of this exact version.
7
The second request sends that fingerprint back via If-None-Match — asking "has this changed since I last saw etag abc123?"
Output
first status: 200
etag: abc123
conditional status: 304

Why this works: The server genuinely returns 304 Not Modified — not a 200 with a body — because the ETag sent back matched what it currently has. A 304 has no body at all, saving the full transfer cost of a resource the client already has cached, which is the entire point of a conditional request over a plain re-fetch.

A conditional request — 304 skips the body entirely
Client
Server
  1. 1. GET /etag/abc123
  2. 2. 200 OK, ETag: abc123
  3. 3. GET /etag/abc123 (If-None-Match: abc123)asks: has this changed since abc123?
  4. 4. 304 Not Modified — no body at all
  1. Client → Server: GET /etag/abc123
  2. Server → Client: 200 OK, ETag: abc123
  3. Client → Server: GET /etag/abc123 (If-None-Match: abc123) (asks: has this changed since abc123?)
  4. Server → Client: 304 Not Modified — no body at all

Creating a new httpx.Client (or requests.Session) per request instead of reusing one

Wrong

python
def fetch(url):
    with httpx.Client() as client:   # new connection pool every call
        return client.get(url)

for url in urls:
    fetch(url)   # pays a fresh TCP+TLS handshake on every single request

Better

python
with httpx.Client() as client:   # one pool, created once
    for url in urls:
        client.get(url)              # reuses an open connection when possible

What you see: Noticeably higher latency per request and more open/closed sockets than necessary — invisible in a quick manual test, very visible under load or against a slow/distant host.

Why: A fresh httpx.Client() has no warmed connection pool, so every single get() pays a new TCP handshake (and TLS handshake, for HTTPS) instead of reusing an already-open keep-alive connection — creating one Client and reusing it across requests is what lets connection pooling actually do its job.

Negotiation and performance headers

Negotiation and performance headers
HeaderDirectionPurpose
AcceptRequestFormats the client can handle back
Accept-EncodingRequestCompressions the client can decode
Content-EncodingResponseCompression actually used on this body
Cache-ControlResponseHow long / whether a response can be reused from cache
ETag / If-None-MatchResponse / RequestConditional re-validation — 304 if unchanged
Connection: keep-aliveBothReuse this TCP connection for more requests

Remember: Accept/Accept-Encoding negotiate the response shape before it is sent; ETag + If-None-Match can turn a re-fetch into an empty 304; reuse one Client to actually get connection pooling.

See also: headers

Advertisement