Course outline

2.2

HTTP Methods and Status Codes

Safe, idempotent and cacheable methods, and what every status-code family tells the client.

50 min Reading + animations

Tip: tap or hover any dotted-underlined term to see what it means.

By the end of this lesson you will be able to

  • Say exactly what every HTTP method means (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT, TRACE, and the new QUERY), and which ones carry a body.
  • Classify each method as safe, idempotent and cacheable, and explain why each property matters to browsers, proxies, caches and retry logic.
  • Choose between PUT and PATCH, and between JSON Merge Patch and JSON Patch; prevent lost updates with If-Match.
  • Explain why retrying a POST after a timeout can charge a customer twice, and how an idempotency key fixes it.
  • Read status codes as a senior engineer does: the five families, the codes that matter (100 to 504), which redirects keep the method, 401 vs 403, 404 vs 410, and who generated a 502, 503 or 504.
  • Decide which responses are safe to retry, and design error responses with problem details (RFC 9457) instead of “200 with an error inside”.

#Where this lesson fits

Lesson 1.1 introduced the request–response cycle, a table of methods, the status codes you meet every day, and the rule “retry only idempotent requests”. This lesson takes those two tables apart. Every row hides a decision that some piece of software makes without understanding your application: a browser deciding whether it may prefetch a link, a proxy deciding whether it may resend a request to another server, a CDN deciding whether it may store a response, a client library deciding whether a failure is worth a retry. They decide from the method and the status code alone.

So the method and the status code are a contract with every machine on the path, not just with the person reading your API docs. Use them correctly and caching, retries and monitoring work for free. Use them loosely and you get double payments, cached error pages and dashboards that stay green during an outage.

Two other lessons build on this one: 2.3 covers the headers (including the CORS preflight that uses OPTIONS) and 2.4 covers caching and validation (ETags and 304) in depth. The HTTP version does not matter here: HTTP/1.1, HTTP/2 and HTTP/3 (lesson 2.1) carry exactly the same methods and status codes. Their meaning is defined once, in RFC 9110, HTTP Semantics (2022).

#The method: a promise about side effects

The first word of every request is the method (sometimes called the verb). It tells the server what the client wants done with the resource named by the URL. Methods are case-sensitive and written in capitals by convention.

MethodWhat it asks forRequest bodyTypical success
GETSend me the current representation of this resourceNo defined meaning200 (or 206 for a range, 304 if unchanged)
HEADSame as GET, but send only the headersNo200
POSTProcess this body according to the resource’s own rulesYes201 + Location (created), 200 (result), 202 (queued), 204
PUTMake the resource at this URL exactly this (create or replace)Yes, the full representation201 (created), 200 or 204 (replaced)
PATCHApply this set of changes to the resourceYes, a patch document200 or 204
DELETERemove the resource at this URLNo defined meaning204, 200, or 202 (deletion queued)
OPTIONSWhat can I do with this resource?Rarely204 or 200, with an Allow header
CONNECTProxy, open a raw tunnel to this host and portNo200, then raw bytes
TRACEEcho my request back so I can see what arrivedNo (must not have one)200, usually disabled

#Every method in detail

GET fetches a representation. It is the most common request on the web and the only one most caches store. A GET may be conditional (If-None-Match, answered with 304 Not Modified, lesson 2.4) or a range request (Range: bytes=0-1023, answered with 206 Partial Content, used by video seeking and resumable downloads). The notes’ table says a GET has no body; precisely, a body on a GET is not forbidden but has no defined meaning: many servers, proxies and CDNs ignore or drop it, and some reject the request. Do not put data that matters in a GET body.

HEAD is GET without the response body. The headers are the ones a GET would return, including Content-Length, Last-Modified and ETag. It is used to check whether something exists, how big a file is before downloading it, or whether a link is still alive, without moving the bytes.

POST is the general-purpose method. The notes call it “create”, and creating an item in a collection (POST /orders) is the most common use, but RFC 9110 defines it more broadly: the target resource processes the enclosed body according to its own semantics. Submitting a form, appending a comment, starting a job (POST /reports), running an action (POST /orders/77/cancel) and anything that fits no other method are all POST. When it creates something, answer 201 Created with a Location header pointing to the new resource.

PUT replaces the whole state of the resource at the URL with the body, or creates it there if it does not exist. The notes describe PUT twice: once as “replace or update” and once, in the protocols section, as “create or full update”. The second is the complete definition. The key difference from POST is who chooses the URL: with POST /users the server picks the new ID; with PUT /users/42 or PUT /files/report.pdf the client names it. Answer 201 when PUT created the resource and 200 or 204 when it replaced one.

PATCH (added by RFC 5789 in 2010) applies a partial modification. The body is a patch document whose media type says how to apply it, so the same URL can accept different patch formats (next section).

DELETE removes the resource, or more exactly the link between the URL and what it pointed to. It is idempotent: deleting twice leaves the same state. Like GET, a body on DELETE has no defined meaning. If the deletion only happens later (a background purge), answer 202 Accepted.

OPTIONS asks what the server supports for a URL. The answer lists methods in an Allow header. Browsers send it automatically as the CORS preflight before cross-origin requests that are not “simple” (lesson 2.3). OPTIONS * asks about the server as a whole.

CONNECT asks a proxy to open a TCP tunnel: CONNECT api.example.com:443. After a 200 the proxy just copies bytes both ways, and the client runs TLS through the tunnel, so the proxy never sees the content. This is how HTTPS crosses a corporate forward proxy (lesson 1.12). HTTP/2 and HTTP/3 also have an extended CONNECT, used to run WebSockets over them (lesson 2.6).

TRACE makes the final server echo the request it received, to debug what proxies changed. Because the echo could include cookies and authorisation headers (a “cross-site tracing” attack once used it to steal them), servers and proxies disable it. You will only meet it in security scans that flag it as enabled.

QUERY is new: the IETF HTTP working group is standardising a method that is safe and idempotent like GET but carries the query in a body, for searches too large or too structured for a URL (a filter of 50 conditions, a GraphQL query, a vector search). Today such searches use POST and lose GET’s caching and retry guarantees. Check whether it has been published as an RFC and whether your framework, proxies and CDN support it before relying on it. Other extensions exist too: WebDAV added PROPFIND, MKCOL, LOCK and others for file editing, which you may still meet in file servers.

#Safe, idempotent, cacheable: three different properties

The notes define two properties; HTTP has three, and each is used by different machines.

  • Safe: the client neither requests nor expects any change of state on the server. Safe is about the meaning of the request, not about the server doing nothing: logging, counting a page view or warming a cache are fine. Because safe requests are harmless by definition, browsers prefetch them, crawlers and link previews follow them, and nobody asks the user before sending them.
  • Idempotent: the intended effect on the server of several identical requests is the same as of one. The response may differ (DELETE returns 204, then 404). Every safe method is idempotent; DELETE and PUT are idempotent without being safe. Because a repeat is harmless, clients, proxies and libraries may resend an idempotent request automatically when a connection fails before the answer arrives.
  • Cacheable: a response to this method may be stored and reused for later requests. GET and HEAD responses are cacheable. A POST response is cacheable only with explicit freshness information and a Content-Location matching the URL, which almost no cache implements. A successful PUT, PATCH, POST or DELETE invalidates what a cache stored for that URL.
MethodSafeIdempotentCacheableWhy
GET✅✅✅Only reads; the response is a copy of the resource
HEAD✅✅✅GET without the body
OPTIONS✅✅❌Only asks about capabilities; the spec says its responses are not cacheable (CORS preflights have their own Access-Control-Max-Age cache)
TRACE✅✅❌Only echoes
PUT❌✅❌Sets the final state; repeating sets the same state
DELETE❌✅❌Gone once is gone; gone twice is still gone
POST❌❌rarelyIts meaning is up to the resource; a repeat may create a second order
PATCH❌not guaranteed❌Depends on the patch: “set city to Sylhet” is repeatable, “append item 7” is not
CONNECT❌❌❌Opens a new tunnel each time
QUERY (draft)✅✅✅GET semantics with a body; the cache key must include the body

Two consequences follow directly. First, the server is responsible for keeping safe methods safe: a GET /posts/7/delete link will be followed by a prefetcher or crawler sooner or later. Second, an API that sends everything as POST (an RPC-style “tunnel”) throws away caching and automatic retries for every read.

Send it again: what repeating each method does to server state

Idempotent means the effect on the server is the same after one or many identical requests. Watch the state badge on the database, not the response.

Scenario

A JSON Merge Patch that sets a value. PATCH is not idempotent by definition, but this one behaves idempotently.

Clientapp, crawler or retry library
API serverapi.example.com
Databasecity = Dhaka
PATCH /users/42 {"city": "Sylhet"}
Step 1 of 6
1

The body is a merge patch (Content-Type: application/merge-patch+json): “make city equal Sylhet”. It names the final value, not a change relative to the current one.

Self-checkQuestion

A team exposes GET /cart/add?item=7 so that a product link can add to the cart in one click. What will eventually go wrong?

#PUT vs PATCH

PUT says “make it exactly this”. Fields you leave out are not “unchanged”, they are gone (or reset to defaults). It is idempotent precisely because the result does not depend on the old state. Use it when the client holds the whole resource: a settings document, a file, a configuration object.

PATCH says “apply these changes”. How to interpret the changes depends on the body’s Content-Type, and two standard formats exist for JSON:

JSON Merge Patch (RFC 7396, application/merge-patch+json): the body looks like the resource with only the fields to change. null means “remove this field”.

PATCH /users/42 HTTP/1.1
Content-Type: application/merge-patch+json

{ "city": "Khulna", "phone": null }

It is simple and, in normal use, idempotent. Its limits: you cannot set a field to the value null, and arrays are replaced whole (you cannot “add one tag”).

JSON Patch (RFC 6902, application/json-patch+json): the body is a list of operations (add, remove, replace, move, copy, test) with JSON Pointer paths. The whole list is applied atomically: if one operation fails, nothing changes.

PATCH /carts/9 HTTP/1.1
Content-Type: application/json-patch+json

[
  { "op": "test",    "path": "/version",  "value": 7 },
  { "op": "add",     "path": "/items/-",  "value": { "sku": "A-17", "qty": 1 } },
  { "op": "replace", "path": "/coupon",   "value": "EID25" }
]

It can express anything (array inserts, moves, a test that acts as a guard), but an add to /items/- (“after the last element”) is not idempotent: two copies add two items. The test operation is a built-in safety check: if version is no longer 7, the whole patch fails.

A server lists the patch formats it accepts in an Accept-Patch header and answers 415 Unsupported Media Type for any other. A patch that cannot be applied to the current state is answered with 409 Conflict (or 422).

PUTJSON Merge PatchJSON Patch
BodyThe full resourceOnly changed fieldsA list of operations
IdempotentYesIn practice yesNo (in general)
Remove a fieldLeave it out"field": null{"op": "remove"}
Edit inside an arraySend the whole arraySend the whole arrayYes (add, remove by index)
Best forWhole documents, filesSimple form editsPrecise or conditional edits

#Lost updates and conditional requests

Two people editing the same record can lose each other’s work even with perfect methods: both read version 1, both write, and the second write silently overwrites the first (a lost update). HTTP’s answer is optimistic concurrency with conditional requests. The server sends a version tag, the ETag, with every read; the client sends it back on the write:

  • If-Match: "v1": apply this only if the resource is still version v1. Otherwise 412 Precondition Failed, and nothing is written. The client re-reads and tries again.
  • If-None-Match: * on a PUT: create only, never overwrite (412 if something already exists there). Amazon S3 has supported both conditions on writes since 2024.
  • If-Unmodified-Since: the same idea with a timestamp, less precise.
  • 428 Precondition Required: the server refuses writes that come without a condition, forcing clients to protect themselves.

The read-side twins (If-None-Match with an ETag, If-Modified-Since, answered with 304) save bandwidth instead of preventing conflicts; they belong to lesson 2.4. Designing ETags and versioned APIs is part of Chapter 26.

PUT vs PATCH, lost updates and If-Match

Two editors, one user record: {name: Rahim, city: Dhaka, phone: 017…}. The ETag is the record’s version tag.

Scenario

PUT sends the complete new state. Whatever is missing is gone.

Alice’s app
API server/users/42name, city=Dhaka, phone
Bob’s app
PUT /users/42 {"name": "Rahim", "city": "Khulna"}
Step 1 of 3
1

Alice only wants to change the city, so her app sends just name and city. But PUT means “make the resource exactly this”.

#Why retrying a POST is dangerous

When a request times out, the client cannot know which of three things happened: the request never arrived, the server crashed halfway, or the server finished and the response was lost. For a GET this does not matter. For a payment it is everything.

Retries happen in more places than the code you wrote:

  • Browsers may resend an idempotent request when a reused connection closes before any response arrived (RFC 9112 allows this), but never a POST silently: reloading a page that was the result of a POST shows “Confirm form resubmission”.
  • Proxies and load balancers retry on another upstream when one fails. Nginx has refused to pass a non-idempotent request (such as POST or PATCH) to the next server, once it was sent to one, since version 1.9.13 unless told otherwise; Envoy and service meshes have similar per-route retry policies.
  • Client libraries retry by method: Python’s urllib3, for example, retries GET, HEAD, PUT, DELETE, OPTIONS and TRACE by default, never POST.
  • HTTP/2 and HTTP/3 tell the client when a stream was never processed (a GOAWAY frame names the last stream the server handled; REFUSED_STREAM rejects one). Those requests are safe to retry even if they are POSTs, because the server guarantees it did not start them.
  • TLS 1.3 0-RTT “early data” can be replayed by an attacker, so servers accept it only for safe requests and answer 425 Too Early otherwise (lesson 2.8).

So what about the requests that must not run twice, such as POST /payments? Make them idempotent yourself with an idempotency key: before the first attempt the client generates a unique value (usually a random UUID) and sends it in an Idempotency-Key header on that attempt and every retry. The server stores the key with the result; when the same key comes again it returns the stored response instead of doing the work again. The notes’ own example: a customer pays ৳5000 for order 500, the network times out, and the retry carries Idempotency-Key: pay-abc-123, so the money is taken once. The header was popularised by Stripe and is being standardised as an IETF draft.

Two edge cases need an answer in every implementation: a second request with the same key while the first is still running (answer 409 Conflict, “in progress”), and the same key with a different body (answer 422, because the client is misusing the key). Where to store keys, how long to keep them, and how to make the debit and the stored response commit together are designed in Chapter 26; consumers of queues and webhooks face the same problem in Chapter 30.

The response never came: who may retry, and what happens

A timeout does not tell the client whether the server did the work. The method decides whether trying again is harmless.

Scenario

One upstream instance dies mid-request. The proxy can safely try another one.

Mobile app
Reverse proxyNginx / Envoy
Payment API
Ledger DB
GET /payments/981
Step 1 of 4
1

The app asks for a payment’s status.

#Status codes: the server’s answer in three digits

Every response starts with a three-digit status code. The first digit is the class, and that is what a client must understand even when it has never seen the specific code: RFC 9110 says an unknown code must be treated like the x00 code of its class (an unknown 4xx is handled as 400, an unknown 5xx as 500). That is why inventing 299 or 600 is pointless.

The text after the number (“OK”, “Not Found”) is the reason phrase. It is only for humans, clients must ignore it, and HTTP/2 and HTTP/3 do not send it at all: they carry only the number, in the :status pseudo-header. Never write code that compares the reason phrase.

ClassMeaningWho should act
1xx InformationalAn interim answer; the final response is still to comeNobody; the client keeps going
2xx SuccessThe request was received, understood and acceptedNobody
3xx RedirectionFurther action is needed: go to another URL, or use your cached copyThe client, usually automatically
4xx Client errorThe request is wrong or not allowedThe client must change something before retrying (except 408, 425, 429)
5xx Server errorThe server, or something behind it, failed on a valid requestThe server team; the client may retry later

#1xx: informational

1xx responses are interim: a server may send one or more before the real response.

  • 100 Continue. A client about to upload a 2 GB file sends the headers with Expect: 100-continue and waits. The server either answers 100 (“go ahead, send the body”) or rejects at once with a final code such as 401 or 413 Content Too Large, saving the upload.
  • 101 Switching Protocols. The answer to an HTTP/1.1 Upgrade request, mainly for WebSocket (lesson 2.6). HTTP/2 and HTTP/3 have no 101; they use extended CONNECT instead.
  • 103 Early Hints (RFC 8297). While the server is still building a slow page, it sends Link headers so the browser can start preloading the CSS and fonts or connecting to other origins. It replaced HTTP/2 server push (lesson 2.1).
  • 102 Processing came from WebDAV and is deprecated.

#2xx: success

  • 200 OK: the generic success. The body depends on the method: the resource for GET, a result for POST.
  • 201 Created: something new exists. Include Location: /orders/501 (and often the new resource or its ETag in the body).
  • 202 Accepted: the request was accepted for processing but not completed, and it may still fail. The standard asynchronous pattern returns a status URL in Location (and optionally Retry-After); the client polls it; when the job finishes the status resource answers 303 See Other to the result. Use it whenever the work takes longer than a few seconds: reports, video transcoding, bulk imports, anything put on a queue (Chapter 30).
  • 204 No Content: success with an empty body, common for PUT, PATCH and DELETE. A browser that submitted a form and gets 204 stays on the same page.
  • 206 Partial Content: the answer to a Range request. Video players seek with it, download managers resume and parallelise with it. An impossible range gets 416 Range Not Satisfiable.

202 Accepted: long work without a long request

Building a monthly report takes about a minute. Holding one HTTP request open that long breaks on every proxy timeout.

Scenario

The standard asynchronous request pattern.

Client
APIapi.example.com
Job queue
Worker
POST /reports {month: 2026-10}
Step 1 of 10
1

The client asks for a report.

#3xx: redirection

A redirect tells the client to repeat the request at the URL in the Location header. The codes differ in two ways: is the move permanent (may the client and search engines remember it?) and must the client keep the method and body?

CodeNamePermanent?Method on the next requestCached by defaultUse it for
301Moved PermanentlyYesBrowsers turn POST into GETYesOld page URLs, http:// → https:// for GET traffic
302FoundNoBrowsers turn POST into GETNoLegacy temporary moves (login redirects)
303See OtherNoAlways GETNoAfter a POST: “see the result over there” (Post/Redirect/Get), job results
307Temporary RedirectNoKept (same method and body)NoTemporary moves of APIs, maintenance
308Permanent RedirectYesKeptYesPermanent moves of APIs that receive POST or PUT
304Not Modified(not a move)n/an/aAnswer to a conditional GET: “your cached copy is still valid”

Why do 301 and 302 change POST into GET? Early browsers did it, the behaviour became too common to fix, and the specification eventually allowed it. So 307 and 308 were added for the cases where the method must survive. Some practical rules:

  • Permanent redirects are sticky. A browser may cache a 301 or 308 indefinitely; a mistake keeps sending users to the wrong place long after you fix the server. Test with 302 or 307 first.
  • Post/Redirect/Get: after a successful form POST, answer 303 to a page that shows the result. Reloading that page repeats only a harmless GET.
  • Search engines treat 301 and 308 as “index the new URL and move its ranking signals there”; temporary redirects keep the old URL indexed. That is the “SEO juice” in the notes; it applies to permanent redirects, not to 302.
  • Loops: browsers give up after about 20 redirects (“too many redirects”). The classic cause is an app behind a TLS-terminating load balancer that sees plain HTTP and redirects to HTTPS forever; the fix is to trust X-Forwarded-Proto or redirect only at the edge.
  • Open redirects: a ?next= parameter copied into Location without checking lets attackers bounce users from your trusted domain to a phishing site. Allow only relative paths or a list of known hosts.
  • API clients often do not follow redirects (curl needs -L), and those that do may switch POST to GET too. Do not rely on redirects in an API; if you must move one, use 308.
  • 304 is in the 3xx family but is not a move: it answers a conditional GET, meaning the client’s stored copy, although no longer fresh, has been validated and is still correct, so no body is sent (the notes call it “the cached file is fresh”, which is slightly off). Lesson 2.4 covers it.

Redirects: 301 vs 308 vs 307 vs 303

Every redirect carries a Location header. What differs is whether it is permanent and whether the browser keeps the method and body.

Scenario

An API moved for good and answers 301. A form POST arrives.

Browser
Edge / old URLold.example.com
App / new URLwww.example.com
POST /orders {item: 7}
Step 1 of 4
1

An old page still posts to the old host.

#4xx: client errors

A 4xx says the problem is in the request, so repeating it unchanged will fail again (with three deliberate exceptions: 408, 425 and 429).

CodeNameMeaning and typical use
400Bad RequestMalformed syntax: invalid JSON, a missing required header, a broken query string
401UnauthorizedUnauthenticated: no credentials, or bad or expired ones. Must include WWW-Authenticate saying how to authenticate
403ForbiddenAuthenticated (or not needed) but not allowed. Logging in again will not help
404Not FoundNo resource here, or one you are not allowed to know about
405Method Not AllowedThe resource exists but not for this method. Must include Allow: GET, HEAD
406Not AcceptableThe server cannot produce any format listed in the client’s Accept
408Request TimeoutThe client was too slow sending the request; the server closes the connection. Safe to retry
409ConflictThe request conflicts with the current state: duplicate username, version conflict, idempotency key still in progress
410GoneIt existed and has been removed on purpose, permanently. Search engines drop it faster than a 404
412Precondition FailedAn If-Match / If-None-Match / If-Unmodified-Since condition was false
413Content Too LargeThe body exceeds the limit (Nginx’s default client_max_body_size is only 1 MB)
414URI Too LongThe URL is too long, often a GET that should have been a POST or QUERY
415Unsupported Media TypeThe body’s Content-Type is not accepted (XML sent to a JSON-only API, an unknown patch format)
422Unprocessable ContentWell-formed but semantically invalid: an email address without a domain, an end date before the start
425Too EarlyThe server will not process replayable TLS 1.3 early data; retry after the handshake
428Precondition RequiredWrites must be conditional; send If-Match
429Too Many RequestsRate limited. Should include Retry-After
431Request Header Fields Too LargeUsually a cookie that grew out of control
451Unavailable For Legal ReasonsBlocked by a legal demand (the number is a nod to Fahrenheit 451)

Four pairs cause most arguments:

  • 401 vs 403. The names are historical. 401 is “who are you?” and invites the client to authenticate; 403 is “I know who you are, and no”. A client that answers 403 by logging the user in again creates an endless loop. Sessions and tokens are in lesson 1.3; authorisation models in Chapters 34–35.
  • 403 vs 404. If revealing that something exists is itself a leak (another tenant’s invoice, a private repository), answer 404. RFC 9110 explicitly allows it, and GitHub does exactly this.
  • 404 vs 410. 404 says nothing about the future; 410 promises it will not come back. Use 410 for deliberately retired content and API versions. Both are cacheable by default, so a CDN may keep answering 404 for a while after the resource appears: set an explicit Cache-Control on error responses.
  • 400 vs 422. Many APIs use 400 for everything; others use 400 for unparseable input and 422 for well-formed input that breaks a business rule. Either is fine. Pick one convention, document it, and put the details in the body.

401 → log in → 200, and why 403 does not go away

401 asks “who are you?”; logging in can fix it. 403 says “I know who you are, and no”; logging in again cannot.

Scenario

The classic flow.

Web appin the browser
Invoices APIapi.example.com
Login serverlogin.example.com
GET /invoices
Step 1 of 6
1

The app calls the API without credentials.

429 Too Many Requests deserves its own word because it is not an error in the client’s logic; it is flow control. The server (usually an API gateway) is saying “slow down”, and Retry-After says for how long, in seconds or as an HTTP date. Well-behaved clients wait at least that long, add random jitter so that thousands of clients do not return in the same instant, and cap their attempts. Many APIs also report the quota itself: the IETF is standardising RateLimit and RateLimit-Policy headers, and older APIs send X-RateLimit-Limit and X-RateLimit-Remaining. Rate-limiting algorithms are in Chapter 29 and retry strategy in Chapter 28.

429 and 503 with Retry-After: slowing clients down politely

Limit: 100 requests per minute per API key. The gateway counts; the API behind it never sees the excess.

Scenario

A client that reads the headers.

ClientAPI key k-17
API gatewayrate limiter100 / 100
API
request no. 100
Step 1 of 6
1

The client has used its whole minute’s budget.

#5xx: server errors

A 5xx says the request may have been fine, but the server, or something behind it, could not complete it. The single most useful question when you see one is who generated it: your application, or a proxy in front of it?

CodeNameUsually generated byTypical causeLook at
500Internal Server ErrorThe applicationAn unhandled exception or unexpected conditionApp logs and traces for that request ID
501Not ImplementedThe serverIt does not support this method at allThe client, or the server’s method list
502Bad GatewayA proxy / load balancerThe upstream refused or reset the connection, crashed mid-response, or sent a malformed responseUpstream process: crashes, restarts, out-of-memory kills, keep-alive timeouts
503Service UnavailableA proxy (no healthy targets) or the app (shedding load)Overload, maintenance, a deploy, no capacity. Temporary by definition; send Retry-AfterHealth checks, capacity, deploy status
504Gateway TimeoutA proxy / load balancerThe upstream did not answer within the proxy’s timeout (60 s by default in Nginx’s proxy_read_timeout and in AWS ALB’s idle timeout)Whatever the app was waiting for: a slow query, another service, a lock
505HTTP Version Not SupportedThe serverA protocol version it refusesThe client

Real platforms add their own: Nginx logs 499 when the client closed the connection before the answer (never sent on the wire, but a rising 499 rate means users are giving up); Cloudflare answers 520–527 for problems between it and your origin (521 origin refused the connection, 522 connection timed out, 524 the origin took longer than 100 seconds, 525 TLS handshake failed). On AWS, an ALB returns 503 when a target group has no registered targets; when all registered targets are unhealthy it “fails open” and sends traffic to them anyway, so the symptoms become 502 or 504.

The notes’ one-line definitions are right in spirit; two need sharpening. A 502 is not only a “wrong” response: a refused or reset connection counts too. And 503 does not mean the server is switched off: it is a temporary refusal, often by the load balancer, and often deliberate (an app protecting itself by rejecting work it cannot finish in time).

Who produced this status code? 500 vs 502 vs 503 vs 504

A reverse proxy or load balancer sits in front of the app. The 5xx code tells you which hop failed, if you know who generated it.

Scenario

Client
Load balancertimeout 60 s
App server
Database
GET
Step 1 of 3
1

The client sends GET /orders/77 to the load balancer, which picks a healthy app server.

#Which responses are safe to retry?

ResponseRetry?How
Connection refused, before anything was sentYesNothing reached the server; try again or try another instance
Timeout / connection reset after sendingOnly if idempotent or protected by an idempotency keyThe server may have done the work
408, 425YesResend (for 425, after the handshake completes)
429, 503Yes, laterWait at least Retry-After; otherwise exponential backoff with jitter
502If idempotentUsually a restarting or crashed instance; another one may work
504If idempotent, carefullyThe upstream may still be working, and retries add load to whatever is already slow
500At most once, if idempotentUsually a bug: the same input hits the same bug
401Once, after refreshing the tokenIf it fails again, the user must log in
409, 412After re-reading the current stateRe-apply the change to the new version, or ask the user
Other 4xxNoFix the request

Retries also multiply. If a client, a gateway and a service each try three times, one failing call at the bottom becomes 3 × 3 × 3 = 27 attempts, at the very moment the bottom service is struggling. Retry at one layer only (usually the one closest to the user), use a retry budget, and back off with jitter. Chapter 28 covers retries, backoff and circuit breakers properly.

#Error responses that help: problem details

A status code says which kind of failure; the body should say exactly what failed, in a form both programs and people can read. Instead of every API inventing its own error shape, use Problem Details for HTTP APIs (RFC 9457, 2023, which replaced RFC 7807), with Content-Type: application/problem+json:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://example.com/problems/validation",
  "title": "Your request is not valid",
  "status": 422,
  "detail": "2 fields failed validation",
  "instance": "/signup/requests/7f3a",
  "errors": [
    { "field": "email",    "reason": "must contain a domain" },
    { "field": "birthDate", "reason": "must be in the past" }
  ],
  "traceId": "4bf92f3577b34da6"
}
  • type is a URI that identifies the kind of problem (ideally a page that documents it); clients branch on it, not on the human text.
  • title is a short summary that does not change between occurrences; detail explains this occurrence.
  • status repeats the HTTP code (handy when the body is logged on its own); instance identifies this occurrence.
  • Extra members (errors, traceId, balance) are allowed and expected.

Never put stack traces, SQL or internal hostnames in an error body: they help attackers more than users. Put a trace ID there instead and keep the details in your logs. Error format design for a whole API (codes, versioning, documentation) is part of Chapter 26.

#Anti-patterns

  • 200 with an error inside (200 OK {"success": false, "error": "db down"}). Every machine on the path reads only the status: the CDN caches the error as a success, the error-rate dashboard stays green, retry logic never fires, and every client must parse every body to find out whether it worked. (GraphQL traditionally returns 200 with an errors array; the newer GraphQL-over-HTTP specification uses proper 4xx and 5xx codes for request-level failures.)
  • 500 for the client’s mistakes. A validation failure answered with 500 pages the on-call engineer and invites retries that can never succeed. Validation is 400 or 422.
  • 404 for an empty list. GET /orders?status=open with no matches is 200 []; 404 means the collection itself does not exist.
  • 403 when the user is not logged in, or 401 when they are logged in but lack permission: the client cannot tell whether to show a login screen or an “access denied” message.
  • 301 for an API that receives POST: the body is dropped and the method changes. Use 308.
  • GET with side effects and POST for every read: the first breaks safety, the second throws away caching and automatic retries.
  • Custom status codes (299, 600): clients treat them as 200 and 500 anyway. Put the detail in the body.
  • Errors with no cache headers. 404, 405, 410, 414 and 501 are cacheable by default, so a CDN may keep serving a stale 404. Set Cache-Control deliberately on error responses (no-store, or a short max-age).

Error responses: why “200 with an error inside” hurts

A CDN caches successful responses for 5 minutes; a monitor counts 5xx responses. Both only read the status code.

Scenario

The database is down and the API hides it inside a 200.

Users
CDN
Prices API
Monitoring
GET /prices
Step 1 of 5
1

A user asks for prices.

#Methods and status codes in system design

  • Load balancer health checks are HTTP requests judged by status code: the ALB default healthy range is 200, and many teams use GET /healthz answering 200 or 503. A readiness endpoint that returns 503 while the instance is draining lets the balancer stop sending new traffic before shutdown (Chapter 25).
  • Monitoring and SLOs are built on status codes. The usual availability SLI counts 5xx (and timeouts) as failures and leaves 4xx out, because refusing bad input is correct behaviour. Track 4xx, 429 and 499 separately: a jump in 4xx often means a broken client release.
  • Circuit breakers trip on 5xx and timeouts, not on 4xx; a 404 storm is not a reason to stop calling a healthy service (Chapter 28).
  • Caches and CDNs decide what to store from the method and the status code (lesson 2.4; CDNs in Chapters 23–24).
  • Async work follows the 202 + status URL + 303 pattern, or replaces polling with server-sent events or webhooks (lesson 2.5, Chapter 26).
  • gRPC runs over HTTP/2 but returns HTTP 200 for almost every call and carries its own status (such as NOT_FOUND or UNAVAILABLE) in trailers; gateways translate between the two (Chapter 26).
  • Interview angle: “Is PATCH idempotent?” (not necessarily; explain merge vs JSON Patch). “Is DELETE idempotent if the second call returns 404?” (yes). “How do you make a payment API safe to retry?” (idempotency key). “502 vs 504?” (dead upstream vs slow upstream, both generated by the proxy). “Which codes do you retry?” (the table above).

#Try it yourself

curl shows the method, status line and headers with -i (response headers) or -v (everything). httpbin.org is a public test service that answers whatever you ask for.

# HEAD: headers only. Compare Content-Length with what a GET would download.
curl -I https://httpbin.org/image/png

# OPTIONS: which methods does this URL allow? (look at the Allow header)
curl -i -X OPTIONS https://httpbin.org/get

# A wrong method gives 405 with an Allow header
curl -i -X DELETE https://httpbin.org/get

# Ask for any status code to see how your client reacts
curl -i https://httpbin.org/status/429
curl -s -o /dev/null -w '%{http_code}\n' https://httpbin.org/status/503

# Redirects: curl -L follows them. Watch the method on the second request with -v.
# 302 (and 301, 303): curl switches POST to GET; 307 (and 308) keep POST and the body.
curl -v -L -X POST -d 'item=7' 'https://httpbin.org/redirect-to?url=/anything&status_code=302'
curl -v -L -X POST -d 'item=7' 'https://httpbin.org/redirect-to?url=/anything&status_code=307'

# Range request: ask for the first 100 bytes, get 206 Partial Content
curl -i -H 'Range: bytes=0-99' https://httpbin.org/range/1000

# Send an idempotency key (httpbin just echoes it back)
curl -i -X POST https://httpbin.org/anything \
  -H 'Idempotency-Key: 6f1c2a9e-3c1d-4f7a-9a51-2b7e0d4c8f10' \
  -H 'Content-Type: application/json' -d '{"order_id": 500, "amount": 5000}'

In the browser, open the developer tools (F12), go to the Network tab, tick Preserve log, and submit a form on any site: you will see the POST, the redirect after it (often 302 or 303) and the GET that follows, with the method of each. Reload the result page and notice whether the browser asks to resubmit.

#Summary

Key takeaways

  • The method and the status code are a contract with every machine on the path (browsers, proxies, CDNs, libraries, monitors), which act on them without understanding your app. Semantics are defined once in RFC 9110 and are the same in HTTP/1.1, /2 and /3.
  • GET reads (a body has no defined meaning), HEAD is GET without the body, POST processes a body by the resource’s own rules (not only “create”), PUT creates or replaces at a client-chosen URL, PATCH applies a patch document, DELETE removes, OPTIONS asks what is allowed (CORS preflight), CONNECT opens a tunnel through a proxy, TRACE echoes and is disabled. QUERY (being standardised) is a safe, idempotent GET with a body.
  • Safe = no requested state change (GET, HEAD, OPTIONS, TRACE): prefetchers and crawlers send them freely. Idempotent = same effect once or many times (safe methods plus PUT and DELETE): may be retried automatically. Cacheable = GET and HEAD in practice. Idempotent is about state, not responses (DELETE → 204, then 404).
  • PUT replaces everything; JSON Merge Patch sends changed fields (null removes; arrays whole); JSON Patch sends atomic operations (not idempotent in general). Prevent lost updates with If-Match + ETag → 412.
  • A timeout hides whether the work happened. Proxies and libraries never auto-retry POST; make critical POSTs idempotent with an Idempotency-Key (same key → stored response; in progress → 409; different body → 422). Full design in Chapter 26.
  • 1xx interim (100, 101, 103), 2xx success (200, 201 + Location, 202 + status URL, 204, 206), 3xx redirects (301/302 may turn POST into GET, 303 always GET, 307/308 keep the method; 304 = validated cached copy), 4xx fix the request (400, 401 unauthenticated vs 403 forbidden, 404 vs 410, 405 + Allow, 409, 412, 415, 422, 429 + Retry-After), 5xx server side (500 app bug; 502 dead upstream, 503 temporarily unavailable, 504 slow upstream, usually generated by the proxy).
  • Retry 408, 425, 429 and 503 (after waiting), 502 and 504 only when idempotent, 500 at most once, other 4xx never; retry at one layer, with backoff and jitter.
  • Return errors with the right code and an application/problem+json body (RFC 9457); never 200 with an error inside, never 500 for a validation failure.