
EE 547 - Unit 6
Fall 2026

Why separate
One order, as exchanges
What each exchange is
{"customer": 8812,
"items": [{"sku": "A-204", "qty": 2}],
"amount": 1999}
Held the same on both sides
qty an integer, sku a stringamount in cents, or in dollarsBytes carry none of it
1999 is cents to one program and dollars to another; both parse it without errorIn advance
Checked on arrival
Meaning
1999 was read as dollars
Partial failure
Separate release schedules

What the bytes mean
What happened
Who is asking
What it states
One operation, two levels of contract
create order
accepts customer, items, payment token
returns the order and its number
create order
accepts customer, items, payment token,
a client-chosen request key
returns the order and its number
fails payment declined (no order created)
an item unavailable (no order created)
repeat same request key: the first order returned,
no second charge
Encapsulation: client developers see the contract, never the server’s code
Private to the server
Changeable without telling any client
What the contract states: changed only by telling every client, because some client depends on it


Request and reply: synchronous
Queued message: asynchronous
Published event: asynchronous
Format: names, types, and structure
before {"order_id": 17, "total": 1999}
after {"order_id": 17, "amount": 1999}
Meaning: units, effects, and promises
before {"order_id": 17, "total": 1999} cents
after {"order_id": 17, "total": 1999} dollars
In a schema: the type is checked, the words are not
"total": {"type": "integer", "description": "the order's total, in cents, tax included"}
"total": "19.99" and accepts a server that starts sending dollars
TCP: bytes in order between two ports, with losses resent
HTTP: the application protocol on top of TCP
The API: the meaning HTTP leaves out
HTTP defines what each method and status means; an API that uses them as defined gets the software’s behavior

On the client
Between client and server
When every request is a POST to one address and every reply is 200
Two operations: read an order’s status; cancel the order
Actions (RPC)
POST /getOrderStatus
{"order_id": 17}
POST /cancelOrder
{"order_id": 17}
Resources (REST)
GET /orders/17
DELETE /orders/17
GET: cacheable, safe to repeat; DELETE: a change, marked by the method aloneQueries (GraphQL)
POST /graphql
{ order(id: 17) { status } }
POST /graphql
mutation { cancelOrder(id: 17) }

The API’s address: chosen once, for every operation
https, HTTP inside an encrypted connection; http, the same text unencryptedhttps and 80 for httpThe request target: chosen per operation, by the API’s design
/flights for all flights, /flights/7 for onekey=value pairs after ?, joined by &; which flights, how many, in what order% and two hex digits; a space is %20GET /get?origin=LAX&date=2026-10-20 HTTP/1.1
Host: httpbin.org
User-Agent: curl/8.16.0
Accept: application/json
Request line: method, target, version, separated by spaces
GET reads, POST sends dataHTTP/1.1 is text as shown; HTTP/2 carries the same parts in binary framesRouting: the method and the target select the handler; nothing else in the message does
Headers: one Name: value per line, facts about the request
Host: the name from the URL; one machine serves many namesUser-Agent: the client program, as it names itselfAccept: the formats the client readsEnd of headers: an empty line
Body: none; a read’s parameters travel in the target
httpbin.org: a public server that echoes each request back as JSON
curl -v prints the request as sent; --http1.1 keeps it as textGET /get?origin=LAX&date=2026-10-20 HTTP/1.1
Host: httpbin.org
...
HTTP/1.1 200 OK
Date: Tue, 13 Oct 2026 11:11:15 GMT
Content-Type: application/json
Content-Length: 345
Server: gunicorn/19.9.0
{
"args": {"date": "2026-10-20", "origin": "LAX"},
"headers": {"Accept": "application/json", ...},
"url": "https://httpbin.org/get?origin=LAX&date=2026-10-20"
}
Status line: version, status code, reason phrase
200: the request succeeded; three digits a program acts on before it reads anything elseOK: free text for a person; NOT FOUND and Not Found are the same replyHeaders: facts about the reply
Content-Type: the body format, JSON hereContent-Length: the body’s size in bytes, which marks where this reply ends on the connectionDate, Server: when it was produced, and by what softwareBody: the data; here, the server’s echo of the query it parsed
Content-Type Names the Body FormatPOST /post HTTP/1.1
Host: httpbin.org
User-Agent: curl/8.16.0
Accept: */*
Content-Type: application/json
Content-Length: 51
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
POST: the method for sending data to the server
"json"Content-Type: the body format
application/json here; a browser form sends application/x-www-form-urlencoded; a file upload, multipart/form-data415 Unsupported Media TypeContent-Length: 51, the body’s size in bytes
Accept: the formats the client reads; */* is any of them
406 Not AcceptableContent-Type states the format it choseJSON: objects, arrays, strings, numbers, true, false, null; no date, decimal or byte type, and other text formats lack the same
Dates and times: a string, in the form the contract names
"2026-10-20T08:15:00-07:00" ISO 8601, with its offset
"10/20/2026 8:15" zone and order unstated
Money: 19.99 parses as a binary floating-point number in Python and in JavaScript
>>> 0.1 + 0.2
0.30000000000000004
"total": 1999, or a decimal string, "total": "19.99"; the currency in its own fieldIdentifiers: JavaScript holds every JSON number as a 64-bit float, exact up to \(2^{53}\)
> JSON.parse('{"booking_id": 9007199254740993}')
{ booking_id: 9007199254740992 }
Binary data: no byte type
Content-Type| First digit | Meaning | Client’s next step |
|---|---|---|
2xx |
Succeeded | Use the reply |
3xx |
The answer is at another address | Follow Location |
4xx |
The request is wrong | Fix it; sent again unchanged, it fails again |
5xx |
The server failed | Try later; the request may be correct |
1xx: interim replies, handled inside the HTTP librarySuccesses differ in what comes back
200 OK: the body is the answer201 Created: a new thing exists; Location gives its address204 No Content: done, and no bodyThe class is HTTP’s: which codes an API uses, and what each means for it, is the contract’s
422 as 400GET /status/404 HTTP/1.1
Host: httpbin.org
...
HTTP/1.1 404 NOT FOUND
Content-Length: 0
DELETE /get HTTP/1.1
Host: httpbin.org
...
HTTP/1.1 405 METHOD NOT ALLOWED
Allow: OPTIONS, GET, HEAD
404: nothing at this target405: the target exists, not for this method; Allow lists the methods it takes4xx: the request changes, or the same reply comes backThe request: POST /bookings, passenger 42, flight 7, seat 12A
Succeeded: 201 Created, Location: /bookings/1001
Fix the request: 400 Bad Request
"flight_id": "seven"Prove who is asking: 401 Unauthorized
Stop: 403 Forbidden, 404 Not Found
403: the sender is known and may not book for passenger 42; signing in again changes nothing404: nothing at the target; /booking for /bookingsRead the current state, then choose again: 409 Conflict
Wait, then resend unchanged: 429 Too Many Requests, 503 Service Unavailable
429: this client is over its limit; Retry-After: 30503: the server cannot answer now; Retry-After: 120No reply: the connection times out
The same refused booking, reported two ways

HTTP/1.1 409 Conflict
Content-Type: application/json
{"error": "seat_taken", "seat": "12A"}
HTTP/1.1 200 OK
Content-Type: application/json
{"ok": false, "error": "seat_taken"}
ok field is each client’s own checkPOST /getFlight
{"flight_id": 7}
POST /createBooking
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
POST /cancelBooking
{"booking_id": 1001}
Remote procedure call (RPC): the API as a list of procedures
The client stub: one method per procedure, generated or written from the list
What the client must hold: the procedure names and each one’s arguments, exactly as the server spells them
POST /getFlight HTTP/1.1 200 OK
POST /createBooking HTTP/1.1 200 OK
POST /cancelBooking HTTP/1.1 200 OK
POST /createBooking HTTP/1.1 200 OK
{"error": "seat_taken"}
One method, one status: the operation and the outcome are inside the bodies
POST, told apart only by the path the designer chose200 with an error object, by this API’s own conventionWhat no cache, proxy or library does
getFlight as a readcancelBooking as harmless to repeatgetFlight(flight_id)
getFlightByNumber(flight_number, date)
listFlightsByOrigin(origin, date)
listFlightsByRoute(origin, destination, date)
searchFlights(origin, destination, date, max_price)
The list grows with every question asked
Each API, its own rules
POST /createBooking
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
HTTP/1.1 200 OK
{"error": {"code": "ALREADY_EXISTS",
"message": "seat 12A is taken"}}
Why a second set: HTTP’s status is 200 for every reply, so the outcome needs a code of its own
NOT_FOUND, ALREADY_EXISTS, UNAVAILABLE among them, sent after the bodyerror object with a numeric code and a message; -32601 for an unknown procedureIn the stub: the code becomes an exception; the client’s code handles it as it would a local one
Outside the stub: a gateway, a proxy, a dashboard counts 200 and nothing else
service Airline {
rpc GetFlight (FlightRequest) returns (Flight);
rpc CreateBooking (BookingRequest) returns (Booking);
}
message BookingRequest {
int32 passenger_id = 1;
int32 flight_id = 2;
string seat = 3;
}
The file is the contract: procedures, argument types, return types
On the wire
Field numbers
Where it is used
POST /rpc/v1/createBooking
Content-Type: application/json
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
POST /rpc
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1,
"method": "createBooking",
"params": {"passenger_id": 42, "flight_id": 7, "seat": "12A"}}
Two shapes: a target per procedure under one prefix, or one target with the procedure named in the body (JSON-RPC)
Names: verb then noun, one spelling throughout: getFlight, listFlights, createBooking, cancelBooking
Method: POST for every call; some APIs allow GET for reads so that caches apply to them
Arguments: one JSON object, named fields, never positional
Replies: the result as one object; an error as one object with a code and a message, never both in one reply
Versioning: a prefix in the path, /rpc/v1/, or a new procedure name; old names kept running
The list: published as a document or a schema file; the client stub generated from it where the tooling exists
GET /flights/7
GET /flights?origin=LAX&date=2026-10-20
HEAD /flights/7
Safe: the request does not change server state, however many times it is sent
GET reads the thing at the target; HEAD reads its headers aloneThe promise is the server’s: made by accepting GET for the operation
What follows, for caches, proxies, and libraries
A read with many parameters: still a GET, with the parameters in the query, so that all of the above applies to it
PUT /bookings/1001
{"passenger_id": 42, "flight_id": 7, "seat": "14C"}
DELETE /bookings/1001
POST /bookings
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
Idempotent: a second identical request leaves the server as the first did
PUT twice: the same body stored again; DELETE twice: already gone, 404, state unchangedNeither: each copy is one more action
POST /bookings twice: two bookings| Method | What it does | Safe | Idempotent |
|---|---|---|---|
GET |
Reads the thing at the target | yes | yes |
HEAD |
Reads its headers only | yes | yes |
PUT |
Replaces the thing at the target with the body | no | yes |
PATCH |
Changes part of it | no | no |
DELETE |
Removes it | no | yes |
POST |
Creates something, or any other action | no | no |
PATCH promises nothing because its body may be an instruction, such as add 500 miles, rather than a value
No reply: the request was applied, or never arrived; the sender cannot tell which
Idempotent: resend without looking
GET: the same read; PUT: the same seat written twice; DELETE: gone either wayPOST neverNot idempotent: the resend is a second request
POST /bookings twice: two bookings, two charges, and the sender holds only the secondGET /bookings/1001
{"passenger_id": 42, "flight_id": 7, "seat": "12A",
"meal": "vegetarian", "status": "confirmed"}
PUT /bookings/1001
{"passenger_id": 42, "flight_id": 7, "seat": "14C"}
PUT: the body is the whole thing, stored at the address the client names
PUT with missing fields erases them: after this one, seat 14C and no meal400, with the missing fields named201PATCH /bookings/1001
{"seat": "14C"}
PATCH: the body names the fields that change; the rest stay
PATCH promises nothingSelection
PUTPATCHPOST /bookings
Content-Type: application/json
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
HTTP/1.1 201 Created
Location: /bookings/1001
Content-Type: application/json
{"booking_id": 1001, "seat": "12A", "status": "confirmed"}
The address: assigned by the server; the client learns it from Location
PUT needs an address the client already holds; a new booking has noneAlso POST: anything that is not a read, a replacement or a removal
No promise: neither safe nor idempotent
POST reply; no proxy or library resends a POST on its ownPOST /bookings
Idempotency-Key: 3f9c0a7e-5d2b-4c81-9e07-1a6b2f3c4d5e
Content-Type: application/json
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
The key: made up by the client once per intended booking, sent on every attempt
The server: stores the key with the first attempt’s result
201 with /bookings/1001; no second bookingWhere: payments, bookings, anything whose repeat costs money
Cache-Control States How Long a GET Reply Stays FreshGET /cache/60 HTTP/1.1
Host: httpbin.org
...
HTTP/1.1 200 OK
Date: Tue, 13 Oct 2026 12:45:55 GMT
Content-Type: application/json
Content-Length: 260
Cache-Control: public, max-age=60
Fresh: for max-age seconds from the reply, a stored copy is the answer
GET replies only; a POST reply is not stored
Per reply, by the server
max-age=60private, stored by that browser onlyno-storeGET /etag/f7a1 HTTP/1.1
Host: httpbin.org
...
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 261
ETag: f7a1
GET /etag/f7a1 HTTP/1.1
Host: httpbin.org
If-None-Match: "f7a1"
...
HTTP/1.1 304 NOT MODIFIED
ETag: f7a1
ETag: a version mark for the thing at this address, changed whenever the thing changes
If-None-Match: the mark the client holds, sent with a stale copy’s next read
304, no body; the copy is fresh again for another max-age200, the new body, and a new markWhat it costs: one round trip with no body, in place of the body again
Last-Modified and If-Modified-Since do the same with a timestamp, to the secondGET /bookings/1001/cancel
A cancel link as a GET: one request, the booking cancelled
Software that trusts the promise
GET from its copy: the second cancel never reaches the server, and looks successfulThe same action under its own method
DELETE /bookings/1001
POST /bookings/1001/cancel
DELETE: resent on its own after no reply, harmlessPOST: resent only by the client’s own code, with an idempotency keyprocedures resources
POST /getFlight {"flight_id": 7} GET /flights/7
POST /createBooking {...} POST /bookings
POST /cancelBooking {"booking_id": 1001} DELETE /bookings/1001
Resource: a thing with an address: a flight, a booking, the list of flights from LAX
Representation: the reply is one rendering of the thing, in the format Accept asked for
GET /flights/7 as JSON for a program, as CSV for a spreadsheet: the same thing, two formatsREST: Representational State Transfer, the name of this style
RESTful: an API that follows the constraints; used loosely for any API that names things and uses HTTP’s methods
/flights the collection
/flights/7 one item
/bookings
/bookings/1001
/airports
/airports/LAX an item named by its code
Naming
200 with an empty list; 404 is for an address with nothing at it| Method | On the collection, /flights |
On one item, /flights/7 |
|---|---|---|
GET |
The list | The one flight |
POST |
Create a flight; the server assigns its address | Rarely offered |
PUT |
Rarely offered | Replace the flight |
PATCH |
Rarely offered | Change part of it |
DELETE |
Rarely offered | Remove it |
DELETE /flights removes every flight/flights/7/bookings the bookings on flight 7
/passengers/42/bookings passenger 42's bookings
/bookings/1001 the booking itself
Nested path: the collection of things that belong to one thing
POST /flights/7/bookings: a booking created on flight 7 without a flight_id in the bodyOne canonical address: /bookings/1001
GET /bookings/1001
{"booking_id": 1001, "seat": "12A",
"flight": "/flights/7",
"passenger": "/passengers/42"}
Link: the related thing’s address, inside the body
/v1/flights
/v1/flights/7
/v1/flights/7/bookings
/v1/bookings/1001
/v1/airports/LAX
/v1/frequent-flyers/42
Paths: lowercase, plural nouns, hyphens between words, no trailing slash
/v1/, or a header; the old version kept runningIdentifiers: the server’s number, or a code the thing already has (LAX); never a name that can change
Methods and replies
POST to a collection: 201 with LocationPUT and PATCH on an item: 200 with the item, or 204 with nothingDELETE: 204; a second DELETE: 404Bodies: JSON, one object; a collection as an object with a list inside, so that total and next have somewhere to go
snake_case or camelCaseRelated things: a nested path for what one thing owns; a link in the body for what it refers to; a sub-collection for many-to-many, /flights/7/passengers
GET /flights?origin=LAX&date=2026-10-20
GET /flights?origin=LAX&date=2026-10-20&sort=-departure_time
GET /flights?origin=LAX&fields=flight_number,departure_time
One collection, every question: the procedures for flights become one address and a query
Still a GET: safe, stored by caches with the query as part of the key, resent after no reply
GET /flights?origin=LAX&date=Oct-20
HTTP/1.1 400 Bad Request
{"error": "invalid_parameter", "parameter": "date",
"expected": "YYYY-MM-DD"}
Malformed: 400, naming the parameter and the form expected
Unknown name: ignored, or 400, as the contract states
No match: 200 and an empty list
GET /flights?origin=LAX&limit=50&offset=100
{"flights": [ ... 50 items ... ],
"total": 2500,
"next": "/flights?origin=LAX&limit=50&offset=150"}
Page size: limit, capped by the server
Position: offset, the rows to skip
The next page: an address in the reply
next: the last pageCost on the server: the skipped rows are read and discarded
OFFSET 100: 100 rows read for none returned; page 50 reads 2,500 rows for 50With a filter: the filter applies first, the pages are of the result

GET /flights?origin=LAX&limit=50&after=2026-10-20T08:15:00Z,7
{"flights": [ ... ],
"next": "/flights?origin=LAX&limit=50&after=2026-10-20T11:40:00Z,23"}
Cursor: the sort key of the last item returned, carried in the next address
Cost: one index seek to the key, the same on page 1 and page 50
Limits

In every request: who is asking, what is asked, where in the list, which format
On the server: no memory of a client between its requests
What follows
Cost: the proof verified on every request; the request larger by what it repeats
POST /bookings/1001/checkin check in; a check-in now exists
GET /bookings/1001/checkin the boarding pass
DELETE /bookings/1001/checkin undo the check-in
An action with a result: check-in produces a seat confirmed, a boarding pass, a time
POST, read by GET, undone by DELETEPOST /bookings/1001/send-confirmation
An action with no result: send the confirmation email again
POST to a target named for the action, as in RPCGET, nothing to store, nothing to resend on its ownSelection

The framework’s part: the same for every service
The developer’s part: one function per route, taking the request, returning the reply
Flask: a small Python framework, used in the examples
Others: Express for JavaScript, Spring for Java, FastAPI and Django for Python; the same parts under different names
from flask import Flask, jsonify, request, abort
app = Flask(__name__)
FLIGHTS = {7: {"flight_id": 7, "flight_number": "AA 100",
"origin": "LAX", "destination": "JFK",
"seats_remaining": 12}}
@app.get('/flights/<int:flight_id>')
def get_flight(flight_id):
flight = FLIGHTS.get(flight_id)
if flight is None:
abort(404)
return jsonify(flight)@app.get('/flights/<int:flight_id>'): GET on that path shape runs this function
<int:flight_id>: the path segment, converted to an integer and passed as the argument/flights/abc: no match, 404 before the function runsjsonify(flight): the body as JSON, Content-Type set, status 200
abort(404): stops the function and sends the status
Query parameters: request.args.get('origin'), a string or None
GET /flights/7
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 97
{"destination":"JFK","flight_id":7,"flight_number":"AA 100",
"origin":"LAX","seats_remaining":12}
BOOKINGS = {}
@app.post('/bookings')
def create_booking():
data = request.get_json()
if data is None or 'seat' not in data:
return jsonify(error='missing seat'), 400
booking_id = 1001 + len(BOOKINGS)
BOOKINGS[booking_id] = {"booking_id": booking_id,
**data, "status": "confirmed"}
return (jsonify(BOOKINGS[booking_id]), 201,
{'Location': f'/bookings/{booking_id}'})request.get_json(): the body parsed, when Content-Type says JSON; None otherwise
The return: a body alone, or a tuple of body, status, and headers
POST /bookings
Content-Type: application/json
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
HTTP/1.1 201 CREATED
Content-Type: application/json
Content-Length: 86
Location: /bookings/1001
{"booking_id":1001,"flight_id":7,"passenger_id":42,
"seat":"12A","status":"confirmed"}
POST /bookings
Content-Type: application/json
{"passenger_id": 42}
HTTP/1.1 400 BAD REQUEST
{"error":"missing seat"}
flask run Serves the Handlers on One Port$ flask --app airline run --port 8000
* Serving Flask app 'airline'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. ...
* Running on http://127.0.0.1:8000
127.0.0.1 - - [13/Oct/2026 06:35:12] "GET /flights/7 HTTP/1.1" 200 -
127.0.0.1 - - [13/Oct/2026 06:35:12] "GET /flights/99 HTTP/1.1" 404 -
127.0.0.1 - - [13/Oct/2026 06:35:12] "POST /bookings HTTP/1.1" 201 -
127.0.0.1 - - [13/Oct/2026 06:35:12] "POST /bookings HTTP/1.1" 400 -
One process, one port: airline.py imported, app found, a socket opened on 8000
127.0.0.1; --host 0.0.0.0 to accept connections from othersThe log: one line per request, with the method, the target, and the status
Development server: one request at a time; restarts on file changes with --debug
app in a set of worker processes behind one portState in the process: BOOKINGS is a dictionary in this process; a restart empties it, a second process has its own
@app.post('/bookings')
def create_booking():
"""Book a seat on a flight.
Accepts passenger_id, flight_id, seat such as 12A;
meal optional: standard, vegetarian, kosher
Returns 201 and the booking; Location is its address
Fails 400 the body is malformed; the error names the field
403 the sender may not book for this passenger
409 the seat is taken
Repeat the same Idempotency-Key returns the first booking
"""
data = request.get_json()
...The contract, where the code is: what the operation accepts, what it returns, each way it fails, what a repeat does
Still by hand
from flasgger import Swagger
Swagger(app)
@app.post('/bookings')
def create_booking():
"""Book a seat on a flight.
---
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/BookingRequest'}
responses:
201: {description: The booking; Location is its address}
400: {description: Malformed body; the error names the field}
409: {description: The seat is taken}
"""After the ---: the same facts in a fixed form, YAML, that a tool reads
At startup: every handler’s block collected into one document
/apidocs/Other frameworks: FastAPI assembles the same document from the handler’s type annotations, so one declaration gives the document and the body check
OpenAPI: the document’s format, the same for every REST API
components, referred to by nameMade from it, without writing code
Held by the server’s developers, published to clients, versioned with the code
{
"type": "object",
"required": ["passenger_id", "flight_id", "seat"],
"properties": {
"passenger_id": {"type": "integer", "minimum": 1},
"flight_id": {"type": "integer", "minimum": 1},
"seat": {"type": "string",
"pattern": "^[0-9]{1,2}[A-K]$"},
"meal": {"type": "string",
"enum": ["standard", "vegetarian", "kosher"]}
},
"additionalProperties": false
}JSON Schema: the body’s shape, as JSON itself
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
valid
{"passenger_id": 42, "flight_id": 7, "seat": "twelve A"}
'twelve A' does not match '^[0-9]{1,2}[A-K]$'
{"passenger_id": 42, "seat": "12A"}
'flight_id' is a required property
{"passenger_id": 42, "flight_id": "seven", "seat": "12A"}
'seven' is not of type 'integer'
A validator: the same answer on every machine, in every language, with no code written for it
Outside the schema: that flight 7 exists, that seat 12A is free, that passenger_id is the sender
from jsonschema import validate, ValidationError
@app.post('/bookings')
def create_booking():
data = request.get_json()
try:
validate(data, BOOKING_SCHEMA)
except ValidationError as e:
return jsonify(error='invalid_request',
field='/'.join(map(str, e.path)),
detail=e.message), 400
if seat_taken(data['flight_id'], data['seat']):
return jsonify(error='seat_taken',
seat=data['seat']), 409
booking = create(data)
return jsonify(booking), 201, {'Location': booking['url']}At the boundary: the schema check is the first thing the handler does
400 with the field and the validator’s message; the client fixes the requestThen the handler’s own checks: what the schema cannot know
409403The same schema on both sides: the client validates before sending and gets the same message without a round trip
From the document: a framework that validates from the OpenAPI schemas keeps the check and the document from drifting apart
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type": "https://api.airline.example/errors/seat-taken",
"title": "Seat already taken",
"status": 409,
"detail": "Seat 12A on flight 7 was booked at 08:14",
"instance": "/bookings",
"seat": "12A"}
Problem details: one shape for every error the API returns
type: a stable identifier for this kind of error, a URL that may document ittitle: the same short text for every error of this kindstatus: the code, repeated in the bodydetail: this occurrence, free to changeWhy one shape
typetitle and detail can be reworded without breaking a client; type cannotIn the document: the shape once, as the Problem schema; for each operation, which type values it returns
Kept out of an error body
429 and the handler’s 409 read alikebefore {"booking_id": 1001, "seat": "12A", "total": 1999}
after {"booking_id": 1001, "seat": "12A", "total": 1999,
"meal": "standard"}
before {"booking_id": 1001, "seat": "12A", "total": 1999}
after {"booking_id": 1001, "seat": "12A", "amount": 1999}
Compatible: an installed client, unchanged, keeps working
seat pattern acceptedBreaking: some installed client fails or misbehaves
total in cents one day, dollars the nextThe rule: a change is compatible when it lands only in what no existing client reads or sends

A new version: the breaking changes, collected and released together under a new prefix
/v1/bookings and /v2/bookings on the same server, the same handlers behind a translation where the shapes differAccept: application/vnd.airline.v2+json, the same address for bothRetiring the old one
Sunset: Wed, 01 Apr 2027 00:00:00 GMT on every old-version reply, months ahead410 Gonedef test_create_booking_matches_contract(client):
reply = client.post('/bookings', json={
'passenger_id': 42, 'flight_id': 7, 'seat': '12A'})
assert reply.status_code == 201
assert reply.headers['Location'].startswith('/bookings/')
validate(reply.get_json(), BOOKING_SCHEMA) # the document's schema
def test_seat_taken_is_a_problem(client):
client.post('/bookings', json=BOOKING)
reply = client.post('/bookings', json=BOOKING)
assert reply.status_code == 409
assert reply.get_json()['type'].endswith('/seat-taken')What the tests hold the server to: the status, the headers, and the body shape the document promises, for success, and for each failure
Both ends against one document: the client library generated from it, the server tested against it
What passes every test: total switched from cents to dollars, with the schema unchanged
1999POST /bookings
Content-Type: application/json
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
What the server has: the bytes, and the address they came from
Two questions, answered separately
Authentication needs: something in the request that only this client could have put there
Authorization needs: a rule in the handler, applied to that identity and this data
GET /bookings/1001
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
GET /flights?origin=LAX
X-Api-Key: ak_live_7f3c6b2a5d9e4f8ba1c39e8d7c6b5a4f
Where it goes: the Authorization header, or a header the API names
Bearer: the server treats whoever presents the token as its ownerRefererWhat the proof must be
Over TLS only
http every router on the way can read ithttpsMissing or invalid: 401, with WWW-Authenticate naming the scheme the server accepts
The simplest proof: a secret the server issued to one program
X-Api-Key: ak_live_7f3c6b2a5d9e4f8ba1c39e8d7c6b5a4f
Issued once to a program: the partner’s inventory system, a reporting script, a mobile app’s backend
On every request: the server hashes the key and looks up the record: which client, which plan, which limits
What a key is for
A key’s limits
Rotation: issue a second key, deploy it, then delete the first
POST /sessions
Content-Type: application/json
{"email": "p42@example.com", "password": "correct horse battery"}
HTTP/1.1 201 Created
Content-Type: application/json
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-10-18T11:35:00Z"}
GET /bookings/1001
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The password: sent once over TLS and compared with a stored hash; never sent again
The token: issued by the server, sent by the app on every request after
Two forms of token, by where the server keeps its meaning
token 9f1c3e7a2b8d4f6e0a5c7b9d1e3f5a7c
sessions table
token_hash user_id expires_at scope
sha256(...) 42 2026-10-18 11:35:00 bookings:read ...
Opaque: the string means nothing by itself
On every request: hash, look up, read the record
Revoked at once: delete the record and the next request with that token fails
Cost: the lookup, and the store that every server depends on
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiI0MiIsImlhdCI6MTc5MjMyMjQwMCwiZXhwIjoxNzkyMzIzMzAwLCJzY29wZSI6ImJvb2tpbmdzOnJlYWQgYm9va2luZ3M6d3JpdGUifQ
.
UrseWNyWy7zGM4sXtsPCPOfR09vJ0dbfellwwpnzIz8
header {"alg":"HS256","typ":"JWT"}
claims {"sub":"42","iat":1792322400,"exp":1792323300,
"scope":"bookings:read bookings:write"}
signature HMAC-SHA256(header . claims, the server's key)
JSON Web Token (JWT): three parts, base64url, joined by dots
sub: whom the token is about; iat and exp: issued and expires, as Unix seconds; scope: what it may doVerifying: recompute the signature over the first two parts with the key and compare
Keys
What the server keeps: the key, and nothing per token
$ echo 'eyJzdWIiOiI0MiIsImlhdCI6MTc5MjMyMjQwMCwiZXhwIjoxNzkyMzIzMzAw
LCJzY29wZSI6ImJvb2tpbmdzOnJlYWQgYm9va2luZ3M6d3JpdGUifQ==' | base64 -d
{"sub":"42","iat":1792322400,"exp":1792323300,
"scope":"bookings:read bookings:write"}
Signed, not encrypted: base64 is an encoding; anyone with the token can decode it
Irrevocable until exp: the token verifies until then, whatever has happened to the account
So lifetimes are short: minutes
exp fifteen minutes after iat in the token above
Access token: signed, short-lived, sent on every request, verified without a lookup
Refresh token: opaque, long-lived, sent only to the login service to get the next access token
Together
Where each goes: the access token to every service; the refresh token to one, stored where the app keeps secrets

Delegation: the booking app writes a trip to the passenger’s calendar, held by another company
calendar.read or calendar.write, and can withdraw it at the providerOAuth 2.0: the sequence above, standardized, with the token as the result
Authorization header, the same short lifetime and refresh@app.before_request
def identify_sender():
token = request.headers.get('Authorization', '').removeprefix('Bearer ')
try:
g.claims = jwt.decode(token, SIGNING_KEY, algorithms=['HS256'])
except jwt.InvalidTokenError:
abort(401)
@app.get('/bookings/<int:booking_id>')
def get_booking(booking_id):
booking = db.get_booking(booking_id)
if booking is None:
abort(404)
if booking['passenger_id'] != int(g.claims['sub']) \
and 'agent' not in g.claims['scope']:
abort(403)
return jsonify(booking)Authentication once per request: a hook that runs before every handler verifies the token and stores the claims for it
401 before any handler runsAuthorization in the handler: the rule needs the row
Where each check can live
Every scheme is a secret in two places: in transit, and at rest on the client
Where tokens leak
What bounds the damage
Selection
@app.post('/bookings')
def create_booking():
data = request.get_json()
booking = db.create_booking(data)
charge = requests.post(f'{PAYMENTS}/charges',
json={'amount': booking['total_cents'],
'token': data['payment_token']},
headers={'Idempotency-Key': booking['id']},
timeout=(1, 3))
if charge.status_code != 201:
db.cancel_booking(booking['id'])
return problem(422, 'payment-declined'), 422
return jsonify(booking), 201, {'Location': booking['url']}One request inside another: the booking handler waits for the payment processor while the passenger’s app waits for the handler
What the handler now holds
Two waits
The default is no timeout: a call without one waits as long as the other side takes, possibly forever
A budget: the caller’s own client is waiting too
At the timeout: an exception in the handler, and no knowledge of the outcome
backoff_factorRetry-After on 429 or 503 is waited out insteadPOST is not in the list; it is retried only by code that holds an idempotency keyTransient: the next attempt can succeed with the request unchanged
502, 503, 504; 429 once its wait has passed; a connection resetNot transient: the request itself is the problem
400, 401, 403, 404, 409: sent again unchanged, the same reply comes backBackoff with jitter: each wait longer than the last, with a random part
Bounded: a count and a total time, inside the budget

One failure, many requests: each layer that retries multiplies what the layer below sends
One layer retries: the one nearest the failure, with a bounded count
Stop early: a dependency that has been failing for a minute is not retried on each request

Closed: calls go through; failures are counted
Open: after a run of failures, calls are not made for a cooldown
503 to its own client, with Retry-AfterHalf-open: after the cooldown, one call is let through
Per dependency, in the caller: the payment processor’s breaker is open while the seat map’s is closed
POST /flights/7/boarding-passes
HTTP/1.1 202 Accepted
Location: /jobs/318
{"job_id": 318, "status": "queued"}
GET /jobs/318
HTTP/1.1 200 OK
{"job_id": 318, "status": "running", "done": 140, "of": 212}
GET /jobs/318
HTTP/1.1 200 OK
{"job_id": 318, "status": "done",
"result": "/flights/7/boarding-passes.zip"}
Longer than a request should wait: 212 boarding passes to render, a report over a month of bookings, a charge that the processor settles in minutes
202 Accepted: the request is valid and the work has been queued, not done
Location: the job, a thing with an address like any otherThe job: queued, running, done or failed, with progress and, at the end, where the result is
GETThe work: in a separate process that takes jobs from a queue, so that the handler returns in milliseconds and a restart loses no job
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/problem+json
{"type": "https://api.airline.example/errors/payments-unavailable",
"title": "Payments are unavailable",
"status": 503,
"detail": "The payment processor did not answer within 3 s"}
The client sees one API: the airline’s, in its own error shape, with its own Retry-After
detail, for a personWhich code
504 Gateway Timeout502 Bad Gateway503: 503, with a Retry-After the client can useWhose fault
422 or 409, for the passenger to act on400 from the processor: a server bug, 500Unchanged: the error shape, the status classes, the promise that a resend with the same key charges once
from flask import Flask, jsonify, request, abort
app = Flask(__name__)
@app.get('/flights')
def list_flights():
flights = db.list_flights(origin=request.args.get('origin'))
return jsonify(flights=flights)
@app.get('/flights/<int:flight_id>')
def get_flight(flight_id):
flight = db.get_flight(flight_id)
if flight is None:
abort(404, description=f'No flight {flight_id}')
return jsonify(flight)
@app.post('/bookings')
def create_booking():
...One function per operation: the API’s operations, each written as a function with its method and path on the decorator
GET /flights lists; GET /flights/<id> reads one; POST /bookings createsThe app object: created once when the process starts, called by the server with each request
Inside a function: the path’s values arrive as arguments; the rest of the request is read from request; the reply is whatever is returned
@app.get('/flights')
def list_flights(): # no values in the path
flights = db.list_flights()
return jsonify(flights=flights)
@app.get('/flights/<int:flight_id>')
def get_flight(flight_id): # flight_id is the int 7
flight = db.get_flight(flight_id)
if flight is None:
abort(404)
return jsonify(flight)
@app.get('/airports/<code>/flights')
def airport_flights(code): # code is the str 'LAX'
flights = db.list_flights(origin=code)
return jsonify(flights=flights)
url_for('get_flight', flight_id=7) # '/flights/7'
url_for('airport_flights', code='LAX') # '/airports/LAX/flights'GET /flights list_flights()
GET /flights/7 get_flight(flight_id=7)
GET /airports/LAX/flights airport_flights(code='LAX')
A fixed path: the function takes no arguments; the rule matches that path and nothing else
A variable segment: <name> in the path matches one segment and passes it as the argument of that name
<int:flight_id>: digits only, converted to int before the call/path matches across slashes; uuid matches oneBuilding addresses: url_for takes the function’s name and the values, and returns the path
Location of a new booking is url_for('get_booking', booking_id=...), so no handler spells out a pathrequest Holds the Query, Headers, and Bodyfrom flask import request, g
request.method # 'POST'
request.path # '/bookings'
request.args.get('origin') # query string
request.headers.get('X-Request-Id')
request.get_json(silent=True) # None if not JSON
request.form # a submitted form
request.accept_mimetypes.best # from Accept
g.request_id = '25af563f' # set in a hook, read anywhere
g.db # the connection, opened on first userequest: the parsed request, as a global name
get_json parses only when Content-Type says JSON; silent=True returns None instead of raising 400g: an empty object per request, for what the hooks and the handler share
gOutside a request: both raise; a test uses app.test_request_context() to make one
GET /flights/7
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 133
Cache-Control: public, max-age=60
X-Request-Id: 2b81f02e
{"departure_time":"2026-10-20T08:15","destination":"JFK",
"flight_id":7,"flight_number":"AA 100","origin":"LAX",
"seats_remaining":12}
What a handler may return
Response: status, headers and body already set200jsonify: a Response with the body serialized and Content-Type: application/json
make_response does the same for any bodyabort(404): raises; the error handlers turn it into the reply
GET /flights/7 get_flight(flight_id=7)
GET /flights/abc no rule matches: 404
GET /flight/7 no rule matches: 404
PUT /flights/7 the path matches, the method does not: 405
PUT /flights/7
HTTP/1.1 405 METHOD NOT ALLOWED
Allow: GET, HEAD, OPTIONS
Content-Type: application/problem+json
The match comes first: the app compares the method and path against every rule before any function runs
/flights/abc fails the int converter, so the rule does not match405, with Allow naming the methods that doNo function ran: the reply is the router’s, built by the same error handlers as a handler’s own abort
HEAD and OPTIONS are added to every GET rule by Flask, which is why Allow lists threeSettled before the handler runs: whether the path exists and whether the method is allowed
def problem(status, kind, detail):
body = {'type': f'https://api.airline.example/errors/{kind}',
'title': kind.replace('-', ' '), 'status': status,
'detail': detail, 'instance': request.path}
reply = jsonify(body)
reply.status_code = status
reply.mimetype = 'application/problem+json'
return reply
def register(app):
@app.errorhandler(HTTPException)
def http_error(e):
reply = problem(e.code, e.name.lower().replace(' ', '-'), e.description)
if e.code == 405:
reply.headers['Allow'] = ', '.join(sorted(e.valid_methods))
return reply
@app.errorhandler(SeatTaken)
def seat_taken(e):
return problem(409, 'seat-taken', str(e))
@app.errorhandler(Exception)
def unexpected(e):
app.logger.exception('unhandled error')
return problem(500, 'internal-error', 'The request could not be completed')GET /flights/99
HTTP/1.1 404 NOT FOUND
Content-Type: application/problem+json
{"type": ".../errors/not-found", "title": "not found",
"status": 404, "detail": "No flight 99",
"instance": "/flights/99"}
Three sources, one shape
abort(404) and the router’s own 404 and 405: all HTTPExceptionSeatTaken500 with nothing of the traceback in itHandlers raise; they do not build error replies: the shape is decided once, here

before_request: runs first, for every rule
401 for a bad token comes from here, before any handlerafter_request: takes the reply, returns it
teardown_appcontext: after the reply is sent, with the exception if there was one

WSGI middleware: a callable that takes the app and returns a wrapped app; the server calls the wrapper
ProxyFix: behind a load balancer every request arrives from the balancer’s address; the middleware reads X-Forwarded-For and X-Forwarded-Proto and sets the client’s real address and scheme before the app looksWhat it has: the raw request and the raw reply, as the server passes them
What it does not have: request, g, the hooks, the error handlers; it runs outside the app
g, which is why the endpoint is a route inside the app insteadHook or middleware
<h1>Departures from {{ origin }}</h1>
<table>
<tr><th>Flight</th><th>To</th><th>Departs</th><th>Seats</th></tr>
{% for f in flights %}
<tr>
<td>{{ f.flight_number }}</td>
<td>{{ f.destination }}</td>
<td>{{ f.departure_time }}</td>
<td>{% if f.seats_remaining > 0 %}{{ f.seats_remaining }}
{% else %}sold out{% endif %}</td>
</tr>
{% endfor %}
</table>Jinja: a page with holes, filled from the handler’s values
{ } inserts a value; {% %} loops and branchesEscaping, on by default: every inserted value has <, >, & and quotes replaced
flight_number AA <script>alert(1)</script>
rendered AA <script>alert(1)</script>
| safe turns it off for one value, and is the thing to look for in a reviewLayout once: a base template with the page’s frame; each page extends it and fills its blocks
Accept Selects the Reply FormatGET /flights?origin=LAX
Accept: application/json
{"flights":[{"flight_id":7,"flight_number":"AA 100", ...},
{"flight_id":12, ...}, {"flight_id":23, ...}]}
GET /flights?origin=LAX
Accept: text/html
<h1>Departures from LAX</h1>
<table> ... <td>AA 100</td><td>JFK</td> ... </table>
The same data, two representations: the handler queries once and chooses the rendering by the request’s Accept
text/html and gets the pageapplication/json and gets the listWhat stays the same: the address, the query parameter, the data module’s query, the error handlers, the hooks
What differs: the last line
/board, when the page and the API drift apartairline/
__init__.py create_app: the factory
flights.py blueprint: /flights
bookings.py blueprint: /bookings
db.py the queries; one connection per request
errors.py the error shape and its handlers
templates/
board.html the departures page
wsgi.py app = create_app()
Blueprint: a group of routes with a shared prefix, defined in its own file without an app
flights.py and bookings.py each define one; the app registers bothurl_for('bookings.get_booking', ...)One module per concern: routes by resource, queries, errors, and pages each in one place
def create_app(config=None):
app = Flask(__name__)
app.config['DATABASE'] = os.environ.get('AIRLINE_DB', 'airline.sqlite')
app.config.update(config or {})
app.register_blueprint(flights_bp)
app.register_blueprint(bookings_bp)
errors.register(app)
@app.before_request
def start_request():
g.request_id = request.headers.get('X-Request-Id') or uuid.uuid4().hex[:8]
@app.after_request
def mark_reply(reply):
reply.headers['X-Request-Id'] = g.request_id
return reply
@app.teardown_appcontext
def close_db(exc):
db.close()
return appThe factory: a function that builds and returns the app, instead of a module-level object
wsgi.py makes the one the server runs# db.py
def connect():
if 'db' not in g:
g.db = sqlite3.connect(current_app.config['DATABASE'])
g.db.row_factory = sqlite3.Row
return g.db
def create_booking(passenger_id, flight_id, seat):
conn = connect()
cur = conn.execute(
'INSERT INTO bookings (passenger_id, flight_id, seat, status)'
' VALUES (?,?,?,?)', (passenger_id, flight_id, seat, 'confirmed'))
conn.execute('UPDATE flights SET seats_remaining = seats_remaining - 1'
' WHERE flight_id = ?', (flight_id,))
conn.commit()
return get_booking(cur.lastrowid)One connection per request: opened on first use, kept in g, closed by the teardown hook
The handler’s view: functions that take and return plain values
UNIQUE (flight_id, seat) constraint is what refuses the second booking; the handler turns the database’s error into the API’sReplaceable in one place: the file, a PostgreSQL connection, a managed service; the handlers unchanged
Testable in one place: app.test_client() against the temporary database, every handler and hook included
gunicorn Runs the App in Worker Processes$ gunicorn --workers 3 --bind 127.0.0.1:8001 wsgi:app
[94697] [INFO] Starting gunicorn 26.2.0
[94697] [INFO] Listening at: http://127.0.0.1:8001 (94697)
[94697] [INFO] Using worker: sync
[94701] [INFO] Booting worker with pid: 94701
[94702] [INFO] Booting worker with pid: 94702
[94703] [INFO] Booting worker with pid: 94703

WSGI: the app object is a callable the server invokes with each parsed request; Flask and gunicorn meet at that interface
The master: opens the socket, forks the workers, restarts one that dies, reloads on a signal
Each worker: a process with its own copy of the app, its own memory, its own connection
Sizing: a few workers per core; the development server is one process with one thread

One request per worker: a worker that is waiting on the payment processor serves no one else
More capacity
Where this application’s time goes: in the queries and in the calls to other services, which is what the worker count has to cover
Query: a read, written as the outline of the answer
Both sent as POST /graphql with the text in the body; the reply follows the request’s outline, under data

Types: each with its fields and their types; ! non-null, [ ] a list
Flight.bookings and Booking.flight are fields whose type is another type: the relationships, as fieldsEntry points: the fields of Query and Mutation, with their arguments
Query: from Query.flights to a list of Flight; on each, two scalars and bookings; on each booking, two scalars
Cannot query field 'gate' on type 'Flight'Reply: the same outline, filled in; a list where the field was a list, an object where it was a type, the value where it was a scalar
By resource: each flight as the server defines it, every field, every time
GET /flights?origin=LAX


By resource: the list, then one request per item for what it relates to
By query: the relationship is a field, so the nesting is in the one request
Recursion: Booking.flight leads back to Flight, and Flight.bookings leads on again
flights { bookings { flight { bookings { ... } } } } to any depth; the schema allows the walk, and the server has to bound it
Resolver: the server function for one field; Flight.bookings is called once per flight in the list
Batching: a loader collects the keys asked for at one level and runs one read for all of them, then hands each resolver its part
200 OK; the outcome is in the body, in a shape the specification fixesFrom the schema, by the server library, with no handler code
400 and the position of the mistakeinput typeerrors with a path, null at that field, the other fields filledFrom the resolver: the message, and the API’s own code under extensions
Outside the client: a gateway, a proxy, a dashboard counts a 200; by resource the same seat is a 409 that every program between counts
Where a query API fits
Where a resource API fits
Costs on each side: work a resource API did not ask for
Server
Client
GETBoth at once: a resource API for the public and a query layer for the company’s own screens, over the same data
ariadne Is a Schema-First GraphQL Library for Python$ pip install ariadne
from ariadne import QueryType, make_executable_schema, graphql_sync
type_defs = """
type Query { flight(id: Int!): Flight }
type Flight { id: Int! flightNumber: String! seatsRemaining: Int! }
"""
query = QueryType()
@query.field("flight")
def resolve_flight(_, info, id):
return db.get_flight(id)
schema = make_executable_schema(type_defs, query)
@app.post("/graphql")
def graphql_endpoint():
ok, result = graphql_sync(schema, request.get_json())
return jsonify(result), 200 if "data" in result else 400Server library: the schema text and the resolvers in; parsing, checking, calling, and assembling done. Independent of the web framework: mounted on Flask as the one route above, or as WSGI middleware in front of any app
The names
graphql-js is its reference implementation, and graphql-core the Python port the servers below build onariadne (schema-first, the text above), strawberry and graphene (code-first, the schema generated from Python classes)urql for the browser; gql for Pythonaiodataloader and the loader each server shipsClient library: sends queries, keeps replies in a cache keyed by type and id, generates reply types from the schema
Tooling from the schema alone: editors that complete queries, documentation rendered from the types, a registry that diffs each change
The division: the schema is the contract; the server enforces it, the client consumes it, the tooling reads it
POST /sessions login, from the airline's own page
HTTP/1.1 201 Created
Set-Cookie: session=9f1c3e7a2b8d; HttpOnly; Secure; SameSite=Lax; Path=/
GET /bookings/1001
Cookie: session=9f1c3e7a2b8d added by the browser, not by the page
Cookie: a value the server sets once and the browser returns on every later request to that site, until it expires
HttpOnly: the page’s scripts cannot read it; Secure: sent over https onlyWhat differs from a program as client: the proof is attached by the browser’s rule, not by the page’s code
fetchWhat the server sees: a request with a valid session, whoever composed it

Cross-site request forgery (CSRF): another site’s page sends a request the user’s cookie authorizes
What stops it
SameSite=Lax on the cookie: the browser leaves it off requests that another site’s page caused, except a plain link followed; Strict leaves it off those tooOrigin header the browser adds to cross-site requests, checked by the serverGET, so a link alone changes nothing
Origin: scheme, host, and port together; a different host, or http against https, is a different origin
app.airline.example and api.airline.example are two origins, the same company or notThe rule: a script may send a request anywhere; it may read the reply only from its own origin
OPTIONS /post HTTP/1.1
Host: httpbin.org
Origin: https://app.airline.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.airline.example
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Preflight: before a request that is not a plain form or GET, the browser asks the server with OPTIONS whether the page’s origin may send it
PUT and DELETE all need oneMax-Age: how long the answer is kept, so it is asked once an hour and not once a requestCross-origin resource sharing (CORS): headers on the server’s replies naming the origins whose scripts may read them
Allow-Origin: one origin, echoed, or * for anyAllow-Credentials: true: cookies may be sent along, and then * is not allowedThe page at app.airline.example calling api.airline.example: the API’s replies include Allow-Origin: https://app.airline.example, and nothing else changes
What CORS is not: a protection for the server; a program outside a browser reads any reply regardless
Page storage: localStorage, sessionStorage, variables in scripts
Injected scripts: a review left unescaped, an advertisement, a library update
HttpOnly cookie: sent by the browser, unreadable by any script
Selection
HttpOnly, Secure, SameSite cookieHttpOnly cookielocalStorageEscaping: the template that escapes every value is what keeps the injected script out in the first place
Everything the browser receives, the user has: the page’s scripts, every request they send, every reply, in the developer tools
What a key in a page can be: an identifier, not a secret
What stays on the server: any key that may spend money or read data
Connections: six at a time to one host over HTTP/1.1; the seventh request waits
Mixed content: a page on https may not fetch from http; the request is blocked before it is sent
Redirects: followed by the browser; a script sees the final reply and not the hop
Referer: the page’s address sent with each request it causes, unless the page says otherwise
Caching: by the reply’s headers, in the browser’s store, shared by every page on the origin
What the server can set, per reply, to tighten the page’s own rules
Content-Security-Policy: which origins scripts may be loaded from and sent toStrict-Transport-Security: https only for this host from now onNone of it binds a program: a script outside a browser does what its author wrote; these rules exist because the page’s author and the user are not the same person
Two kinds of client, one set of handlers
Authorization, attached by its own code; no cookie, so no cross-site request can use itSameSite and Origin checks apply to this path aloneWhat the page is, for the API: a client whose code the user can read, whose proof the browser attaches, and whose requests another page can cause
Beyond the API: the page itself