Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

System Design (High-Level Design)  Module 5 – APIs and communication patterns

API styles and HTTP semantics for designers

Choose REST, gRPC or GraphQL for each client and team, use the safe and idempotent methods of RFC 9110, and send status codes such as 429 that clients act on.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

What you will learn

  • Choose between REST, RPC (gRPC) and GraphQL for a given client and team
  • Apply safe and idempotent method semantics from RFC 9110
  • Use status codes, including 429 and Retry-After, consistently

Before you start

On this page

An API style decides how a client names what it wants: a resource at a URL (REST), a typed procedure (RPC, today usually gRPC) or a query over a graph of types (GraphQL). Choose it by who calls the API: REST for partners and the open web, gRPC for services you own on both ends, GraphQL for client screens that need different slices of the same data. HTTP semantics are the promises underneath that every client, cache and proxy relies on without reading your documentation: which methods may be repeated, and what each status code tells software to do next. Get those right and retries, caches and monitoring work for you; get them wrong and a timeout can charge a customer twice.

This lesson compares the three styles, then the parts of RFC 9110 that a designer has to get exactly right.

Three styles for one request

Here is the same read, order 42 with its status and total, in each style.

One read of order 42 in three styles: a REST GET to a URL, a gRPC GetOrder call over HTTP/2, and a GraphQL query that picks two fields.REST: a resource at a URLgRPC: a typed methodGraphQL: fields the client picksClientOrders APIClient stubOrderServiceClientGraphQL serverGET /orders/42200 OK, JSON:the whole orderGetOrder(id 42)HTTP/2 POST, protobufOrder message,grpc-status 0POST /graphqlorder 42: status, total200 OK, JSON:data with 2 fields

The same read in REST, gRPC and GraphQL

Text description of the diagram

The figure has three panels, one under another. Each shows a client above a server: the request goes down to the server, and the reply comes back up.

  1. REST, a resource at a URL: the client sends GET /orders/42 to the Orders API, which answers 200 OK with a JSON body holding the whole order.
  2. gRPC, a typed method: a generated client stub calls GetOrder with id 42 on the OrderService. On the wire this is an HTTP/2 POST carrying a protobuf message. The reply is an Order message, and the call's outcome travels separately as grpc-status 0, which means OK.
  3. GraphQL, fields the client picks: the client sends POST /graphql with a query that asks for order 42 and only its status and total. The server answers 200 OK with a JSON body whose data holds just those two fields.

REST models the system as resources with addresses, and acts on them with HTTP’s uniform methods: GET /orders/42 reads the order, PUT replaces it, DELETE removes it. The response is a representation, usually JSON, and its status code and headers mean what RFC 9110 says they mean, which is why caches, proxies and generic tools can work with any REST API without knowing it.

gRPC models the system as services with methods, declared in a .proto file, from which it generates client and server code in many languages. By default the messages are Protocol Buffers, and the core concepts name four kinds of method: unary, server streaming, client streaming and bidirectional streaming. On the wire every call is an HTTP/2 POST to /Service/Method, and its outcome travels as grpc-status in the trailers (gRPC over HTTP2), not as the HTTP status.

GraphQL publishes one schema of types and lets each client ask for exactly the fields it needs, in one request to one endpoint. The specification defines three operations: a query reads, a mutation writes and then reads, a subscription delivers results as events happen. That freedom moves work to the server: one innocent-looking query can fan out into thousands of database reads, so the server has to measure and limit what each query may cost.

How each one fits a client and a team

REST, gRPC and GraphQL compared
Criterion REST over HTTPgRPCGraphQL
The contract URLs, methods, status codes and a JSON shape, often written as OpenAPIA .proto file; code is generated from itA typed schema the server publishes
Who shapes the response The server, per resourceThe server, per methodThe client, field by field
HTTP caches and CDNs Work as they are for GETDo not apply: every call is a POSTHard: queries are usually POSTs to one URL
Browsers NativeOnly through gRPC-Web and a proxyNative
Streaming Not built in; add server-sent events or WebSocketsFour method kinds, streaming built inSubscriptions, over a transport you choose
Errors The HTTP status code and a bodygrpc-status codes in trailersAn errors list beside partial data
Main cost Clients make several calls for one screenTooling, and a proxy for browsersCost control and caching move to the server
When to choose Public and partner APIs, the open web, anything behind a CDNCalls between services you own, in several languages, with deadlinesScreens that combine many services and change often

The style is a choice about clients and teams as much as about technology. A public API serves programs you will never see, written with plain HTTP libraries and sitting behind caches you do not control, so REST’s reliance on standard HTTP is its main asset. Inside a company the trade turns around: you own both ends, a generated client in Go, Java or Python removes a whole class of mismatched-field bugs, and gRPC’s deadlines travel with each call. GraphQL earns its cost where many client screens draw on many back ends and the screens change every release.

Mixing styles is normal. A common shape is REST at the edge for partners, gRPC between internal services, and either GraphQL or a small backend for each app screen, so that a phone makes one request instead of six.

A worked example. An app’s home screen needs data from six services, and a phone on a mobile network has a round trip of about 150 ms to your servers (an assumption; measure your own users). Six REST calls made one after another spend about 6 × 150 = 900 ms waiting on the network alone. Sent in parallel over one HTTP/2 connection they cost about one round trip, but the app still has to know six APIs and survive six partial failures. A GraphQL server or a screen-specific backend in the data centre makes the same six calls over links whose round trip is well under a millisecond, so the phone waits about one round trip plus the slowest of the six calls, and it receives only the fields the screen shows.

Safe and idempotent: what software may assume

RFC 9110 gives each method two properties that the whole HTTP ecosystem relies on.

  • A safe method only reads: the client does not ask for, and does not expect, any change of state. GET, HEAD, OPTIONS and TRACE are safe (section 9.2.1), so crawlers, link prefetchers and caches may send or repeat them freely.
  • An idempotent method has the same intended effect on the server whether it arrives once or several times. PUT, DELETE and the safe methods are idempotent (section 9.2.2), so a client may repeat one by itself when the connection fails before a response arrives.
Method Safe Idempotent Design it for What a blind retry does
GET Yes Yes Reads, which may be cached Nothing changes
PUT No Yes Create or replace at an address the client chooses Same state as one copy
DELETE No Yes Remove a resource Same state; the second reply may be 404
POST No No Create with an id the server picks, or start a process A second order, payment or email
PATCH No No Change part of a resource Depends on the patch: “add 1” twice adds 2

The example below calls a small in-memory API twice per request, the second time as an automatic retry would after a lost reply, and compares the server’s state after each call:

Retried requests against a tiny API JavaScript · semantics_check.mjs
// A tiny in-memory API, called twice per request: once, then again as an automatic retry would after a lost reply.
// It checks what RFC 9110 promises: repeating an idempotent request leaves the server as one copy did.

function newServer() {
  const state = { orders: {}, nextOrder: 1, profiles: {}, sessions: { s1: { user: 7 } }, views: 0 };
  const handlers = {
    'GET /orders': () => ({ status: 200 }),
    'PUT /profiles/7': (body) => {
      const existed = '7' in state.profiles;
      state.profiles['7'] = { ...body }; // PUT replaces the whole resource with the body
      return { status: existed ? 200 : 201 };
    },
    'DELETE /sessions/s1': () => {
      if (!('s1' in state.sessions)) return { status: 404 };
      delete state.sessions.s1;
      return { status: 204 };
    },
    'POST /orders': (body) => {
      const id = state.nextOrder++; // the server picks the new order's address
      state.orders[id] = { ...body };
      return { status: 201, location: `/orders/${id}` };
    },
    'PATCH /counters/views': (body) => {
      if (body.op === 'increment') state.views += 1; // a relative change
      return { status: 200 };
    },
    'PATCH /profiles/7': (body) => {
      if (!('7' in state.profiles)) return { status: 404 };
      Object.assign(state.profiles['7'], body); // sets the fields given, to absolute values
      return { status: 200 };
    },
  };
  const call = (method, path, body = {}) => handlers[`${method} ${path}`](body);
  return { state, call };
}

/** The server's state as text, with keys sorted, so two states can be compared. */
function snapshot(value) {
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
  const keys = Object.keys(value).sort();
  return `{${keys.map((k) => `${JSON.stringify(k)}:${snapshot(value[k])}`).join(',')}}`;
}

const requests = [
  ['GET', '/orders', {}, 'safe: reads only'],
  ['PUT', '/profiles/7', { name: 'Asha', city: 'Pune' }, 'idempotent'],
  ['DELETE', '/sessions/s1', {}, 'idempotent'],
  ['POST', '/orders', { sku: 'tea-250g', qty: 1 }, 'neither'],
  ['PATCH', '/counters/views', { op: 'increment' }, 'neither'],
  ['PATCH', '/profiles/7', { city: 'Nagpur' }, 'neither, but this patch sets a value'],
];

const server = newServer();
console.log('request                    1st    retry  state after the retry');
for (const [method, path, body, promise] of requests) {
  const first = server.call(method, path, body);
  const afterFirst = snapshot(server.state);
  const retry = server.call(method, path, body); // the same request again
  const same = snapshot(server.state) === afterFirst;
  const label = `${method.padEnd(6)} ${path}`.padEnd(26);
  const verdict = same ? 'same as after the 1st' : 'CHANGED by the retry';
  console.log(`${label} ${String(first.status).padEnd(6)} ${String(retry.status).padEnd(6)} ${verdict} (${promise})`);
}

console.log(`\norders created: ${Object.keys(server.state.orders).length} (the client wanted 1)`);
console.log(`view counter: ${server.state.views} (the client sent one increment)`);
console.log(`profile 7: ${snapshot(server.state.profiles['7'])}`);

Output

request                    1st    retry  state after the retry
GET    /orders             200    200    same as after the 1st (safe: reads only)
PUT    /profiles/7         201    200    same as after the 1st (idempotent)
DELETE /sessions/s1        204    404    same as after the 1st (idempotent)
POST   /orders             201    201    CHANGED by the retry (neither)
PATCH  /counters/views     200    200    CHANGED by the retry (neither)
PATCH  /profiles/7         200    200    same as after the 1st (neither, but this patch sets a value)

orders created: 2 (the client wanted 1)
view counter: 2 (the client sent one increment)
profile 7: {"city":"Nagpur","name":"Asha"}

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node semantics_check.mjs

Three design rules follow from the output.

  • Never change state on GET. A GET /orders/42/cancel link that cancels an order will one day be followed by a prefetcher or a crawler, and a retry of it is never questioned.
  • Prefer PUT when the client can choose the address. A client that generates the order’s id and sends PUT /orders/<id> can repeat the request safely; with POST /orders the server picks a new id every time, so the retry in the output created a second order. POST stays the right method when the server must choose, and a later lesson of this module shows how an idempotency key makes its retries safe.
  • Write patches that set values, not patches that change them. RFC 5789 defines PATCH as neither safe nor idempotent, but the output shows that a patch setting city to a value behaves idempotently, while “increment the counter” does not.

Idempotent describes the state, not the reply: the retried DELETE got 404, yet the session is gone either way. RFC 9110 also says that a client should not retry a non-idempotent request automatically unless it knows the request is harmless to repeat or knows the first attempt never took effect, and that a proxy must never do so.

Status codes clients act on

A status code is an instruction to software that will never read your documentation: try again, wait, sign in again, fix the request or give up. Choose each code by what the caller should do next.

Code What it says What the client should do
201 Created A new resource exists; Location gives its address Store the address
202 Accepted The request was accepted, but the work is not done yet Check a status resource later (a later lesson of this module)
204 No Content Success, with nothing to send back Move on
400 Bad Request The request could not be understood Fix it; never repeat it unchanged
401 Unauthorized No valid credentials Authenticate, then try again
403 Forbidden Understood and refused; these credentials are not enough Stop; repeating it with the same credentials will not help
409 Conflict The request clashes with the current state, such as an edit of an old version Read the resource again and redo the change
422 Unprocessable Content Well-formed, but the content fails the rules Show the field errors
429 Too Many Requests This client sent too much in a given time Wait at least Retry-After, then slow down
500 Internal Server Error A bug on the server Report it; repeating seldom helps
502, 503, 504 A gateway or the service cannot serve the request now Retry idempotent requests with backoff, honouring Retry-After

Rate limiting has its own code. RFC 6585 defines 429 for a user who “has sent too many requests in a given amount of time”, says the response should explain the condition and may carry Retry-After, and deliberately leaves open how the server identifies the user and counts requests: per API key, per account or per address are all yours to choose. Retry-After holds either a number of seconds (Retry-After: 30) or an HTTP date (section 10.2.3), and 503 may carry it too. Send it every time: a client that knows when to come back does not have to guess, and guessing clients retry too early.

The exercise asks you to turn the last two sections into the two functions every client library needs: whether it may repeat a request on its own, and how long it should wait first.

Exercise · Medium · Python

Decide when a client may retry, and how long it waits

Write the two rules an HTTP client library needs before it repeats a request on its own, in retry.py.

may_retry(method, status, idempotency_key=False) returns True when the client may send the same request again without asking anyone. status is the response's status code, or None when no response arrived at all (a timeout or a dropped connection). Use this lesson's policy:

- Only these outcomes are worth a retry: no response, 429, 502, 503 and 504. Every other status code means the same request would fail the same way, or has already succeeded. - Even then, repeat only methods that RFC 9110 defines as idempotent (GET, HEAD, OPTIONS, TRACE, PUT, DELETE), whatever the letter case of method, or a request that carries an idempotency key, which lets the server recognise a duplicate.

retry_delay(headers, now) returns how many seconds the client should wait, read from the Retry-After header. headers is a dictionary of response headers whose names may come in any letter case, and now is the current time as a timezone-aware datetime. The header holds either a whole number of seconds (digits only) or an HTTP date such as Wed, 21 Oct 2026 07:28:00 GMT. For a date, return the seconds from now until then as an int, and 0 when that moment has already passed. Return None when the header is missing or holds anything else.

For example, may_retry("PUT", 503) is True, may_retry("POST", None) is False, and retry_delay({"retry-after": "120"}, now) is 120.

Starter code · retry.py

"""Retry rules for an HTTP client: which requests it may repeat on its own, and how long it waits first."""

from email.utils import parsedate_to_datetime


def may_retry(method, status, idempotency_key=False):
    """True when the client may send the same request again on its own.

    status is the response's status code, or None when no response arrived (a timeout or a dropped connection).
    """
    # Replace this line with your code.
    return False


def retry_delay(headers, now):
    """Seconds to wait before a retry, from the Retry-After header, or None when it is missing or invalid.

    headers maps header names (in any letter case) to values; now is a timezone-aware datetime.
    """
    # Replace this line with your code.
    return None
The sample tests · test_retry.py
from datetime import datetime, timezone

from retry import may_retry, retry_delay

NOW = datetime(2026, 1, 15, 10, 0, 0, tzinfo=timezone.utc)


def test_idempotent_methods_after_no_response():
    """repeats idempotent methods when no response arrived"""
    assert may_retry("GET", None) is True
    assert may_retry("put", None) is True
    assert may_retry("DELETE", None) is True


def test_post_needs_a_key():
    """repeats POST only with an idempotency key"""
    assert may_retry("POST", None) is False
    assert may_retry("POST", 503) is False
    assert may_retry("POST", 503, idempotency_key=True) is True


def test_status_codes():
    """retries overload and gateway errors, never client errors or plain successes"""
    assert may_retry("GET", 429) is True
    assert may_retry("GET", 504) is True
    assert may_retry("GET", 404) is False
    assert may_retry("PUT", 409) is False
    assert may_retry("GET", 200) is False


def test_delay_in_seconds():
    """reads a number of seconds, in any header case"""
    assert retry_delay({"Retry-After": "30"}, NOW) == 30
    assert retry_delay({"retry-after": "120"}, NOW) == 120
    assert retry_delay({"Retry-After": "0"}, NOW) == 0


def test_delay_as_a_date():
    """counts the seconds until an HTTP date, and 0 for a date in the past"""
    assert retry_delay({"Retry-After": "Thu, 15 Jan 2026 10:01:30 GMT"}, NOW) == 90
    assert retry_delay({"Retry-After": "Thu, 15 Jan 2026 09:00:00 GMT"}, NOW) == 0


def test_missing_or_invalid():
    """returns None when there is no usable value"""
    assert retry_delay({}, NOW) is None
    assert retry_delay({"Retry-After": "-5"}, NOW) is None
    assert retry_delay({"Retry-After": "soon"}, NOW) is None
A hint

Normalise first: method.upper() for the method, and look the header up by comparing name.lower() with "retry-after". str.isdigit() tells a number of seconds from a date, and email.utils.parsedate_to_datetime() reads an HTTP date (it raises ValueError or TypeError on text that is not a date).

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

HTTP Status Codes Reference Look up any status code: what it means, which RFC defines it, and whether a client may cache or retry it.

One error body everywhere: problem details

Status codes give the kind of failure; a program also needs to know which failure it was. Instead of inventing a format per team, use RFC 9457, which defines the media type application/problem+json and five members, all optional: type (a URI that names the problem and is its main identifier), title (a short summary of the type), status, detail (about this occurrence, for people) and instance (this occurrence’s URI). A body without type means about:blank, a problem with no meaning beyond its status code. Problem types may add their own members, and clients must ignore members they do not know, so the format can grow without breaking anyone.

Three problem types and an about:blank, checked Python · problem_details.py
"""Three error responses in the problem details format of RFC 9457, checked against the RFC's rules."""

import json
import re

HOST = "https://api.example.com"
REASONS = {404: "Not Found", 409: "Conflict", 422: "Unprocessable Content", 429: "Too Many Requests"}
MEMBER_TYPES = {"type": str, "title": str, "status": int, "detail": str, "instance": str}
# RFC 9457 section 3.2: extension names should start with a letter, use letters, digits and "_", and be 3+ long.
GOOD_EXTENSION_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{2,}")


def problem(status, headers, body):
    return {"status": status, "headers": {"Content-Type": "application/problem+json", **headers}, "body": body}


responses = [
    problem(422, {}, {
        "type": f"{HOST}/problems/invalid-fields",
        "title": "Some fields are not valid",
        "status": 422,
        "detail": "2 fields failed validation.",
        "instance": "/orders",
        "errors": [
            {"pointer": "/qty", "detail": "must be between 1 and 20"},
            {"pointer": "/email", "detail": "must be an email address"},
        ],
    }),
    problem(429, {"Retry-After": "30"}, {
        "type": f"{HOST}/problems/rate-limited",
        "title": "Too many requests for this key",
        "status": 429,
        "detail": "This key may send 100 requests a minute; try again in 30 seconds.",
        "limit_per_minute": 100,
    }),
    problem(409, {}, {
        "type": f"{HOST}/problems/version-conflict",
        "title": "The order changed since you read it",
        "status": 409,
        "detail": "You edited version 3; the order is at version 4.",
        "instance": "/orders/1842",
        "current_version": 4,
    }),
    # No "type": a client must treat it as "about:blank", which adds nothing to the status code.
    problem(404, {}, {"title": "Not Found", "status": 404}),
]


def check(response):
    body, status = response["body"], response["status"]
    problems = []
    for name, kind in MEMBER_TYPES.items():
        if name in body and not isinstance(body[name], kind):
            problems.append(f"{name} should be a {kind.__name__}")
    if body.get("status", status) != status:
        problems.append("the status member differs from the HTTP status code")
    if body.get("type", "about:blank") == "about:blank" and body.get("title") != REASONS[status]:
        problems.append("with about:blank the title should be the status code's reason phrase")
    for name in body.keys() - MEMBER_TYPES.keys():
        if not GOOD_EXTENSION_NAME.fullmatch(name):
            problems.append(f"extension name {name!r} breaks the naming advice")
    return problems or ["ok"]


for response in responses:
    print(f"HTTP/1.1 {response['status']} {REASONS[response['status']]}")
    for name, value in response["headers"].items():
        print(f"{name}: {value}")
    print(json.dumps(response["body"], indent=2))
    kind = response["body"].get("type", "about:blank")
    print(f"-- type {kind.removeprefix(HOST)}; checks: {'; '.join(check(response))}\n")

Output

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/invalid-fields",
  "title": "Some fields are not valid",
  "status": 422,
  "detail": "2 fields failed validation.",
  "instance": "/orders",
  "errors": [
    {
      "pointer": "/qty",
      "detail": "must be between 1 and 20"
    },
    {
      "pointer": "/email",
      "detail": "must be an email address"
    }
  ]
}
-- type /problems/invalid-fields; checks: ok

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30
{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too many requests for this key",
  "status": 429,
  "detail": "This key may send 100 requests a minute; try again in 30 seconds.",
  "limit_per_minute": 100
}
-- type /problems/rate-limited; checks: ok

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/version-conflict",
  "title": "The order changed since you read it",
  "status": 409,
  "detail": "You edited version 3; the order is at version 4.",
  "instance": "/orders/1842",
  "current_version": 4
}
-- type /problems/version-conflict; checks: ok

HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
  "title": "Not Found",
  "status": 404
}
-- type about:blank; checks: ok

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 problem_details.py

The output shows the rules that make the format useful: the status member matches the HTTP status, every machine-readable fact (the field errors, the limit, the current version) has a member of its own, and detail is written for people. Clients branch on type and those members, never on the wording of detail, so you can reword a message without breaking an integration.

When the style hides HTTP semantics

gRPC and GraphQL both carry their own outcome inside an HTTP 200, so the generic HTTP machinery that the earlier sections relied on sees much less.

  • gRPC reports the outcome as one of 17 codes in grpc-status, while the HTTP status stays 200. Its status code guide calls UNAVAILABLE most likely a transient condition, which a retry with backoff can fix, but warns that retrying non-idempotent operations is not always safe, and that DEADLINE_EXCEEDED may come back even when a state-changing call succeeded. The rules of the methods table still apply; you write them into each method’s contract instead of getting them from the HTTP method. Browsers reach gRPC services only through gRPC-Web and a proxy, and the gRPC-Web README lists unary and server-streaming calls only.
  • GraphQL reports a field that fails during execution by setting it to null and adding an entry to the errors list with the field’s path, so one response can be partly successful. Many servers answer 200 whenever any data came back, so dashboards that count HTTP 5xx miss these failures: count the errors entries instead. The GraphQL over HTTP working draft asks for a 4xx or 5xx status when no data could be produced at all, and lets queries, never mutations, travel as GET, which is what makes them cacheable.
GraphQL Formatter & Validator Paste a long query to indent it and see at a glance which fields and how many levels it asks for.

Interview questions

Fresher · How do REST, gRPC and GraphQL differ?

REST exposes resources at URLs and acts on them with HTTP’s methods, so caches, proxies and any HTTP client understand it. gRPC exposes typed methods declared in a .proto file, generates clients in many languages and runs over HTTP/2 with Protocol Buffers, with streaming and deadlines built in, which suits calls between services you own. GraphQL publishes one schema and lets each client pick the fields it needs in a single request, which suits screens that combine many back ends, at the price of controlling query cost and caching on the server.

Fresher · A response is 429 with Retry-After: 30. What should the client do?

Stop sending that request for at least 30 seconds, because the server says this client has exceeded its rate limit. Then retry with backoff and some random jitter, so that many clients do not return at the same moment, and slow its overall request rate. The response does not mean the request was invalid: the same request should succeed later, so an idempotent request, or a POST that carries an idempotency key, can be retried automatically after the wait.

Key takeaways

  • Choose the style by audience: REST for partners and the open web, gRPC between services you own, GraphQL (or a backend per screen) where screens combine many services.
  • GET, HEAD, OPTIONS and TRACE are safe; they and PUT and DELETE are idempotent and may be repeated automatically. POST and PATCH are neither, so their retries need another guarantee.
  • Pick status codes by what the client should do next; send Retry-After with 429 and 503.
  • Use one error format, RFC 9457 problem details, with a type per problem and machine-readable members.
  • gRPC and GraphQL carry their outcome inside HTTP 200, so retry rules and monitoring must read grpc-status or the errors list instead.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A client sends PUT /profiles/7 with the full profile, and the connection drops before any response arrives. What may the client do on its own?

    Choose one answer.

    Show the answer to question 1

    Answer: Send the same PUT again, because repeating it leaves the profile as one copy would

    PUT replaces the resource with the body, so two copies leave the same state as one. RFC 9110 lets a client repeat an idempotent request automatically when the connection fails before it has read a response. A GET first is harmless but unnecessary, and POST is the method that must not be repeated blindly.

  2. Question 2 of 5 Which of these methods does RFC 9110 define as idempotent?

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • DELETE
    • GET
    • PUT

    The safe methods (GET, HEAD, OPTIONS, TRACE) plus PUT and DELETE are idempotent. POST is neither safe nor idempotent. PATCH, defined in RFC 5789, is not idempotent either, although a particular patch that only sets values can behave idempotently.

  3. Question 3 of 5 A GraphQL query for a user's profile and orders returns HTTP 200. Its JSON has data.orders set to null and one entry in errors whose path is ["orders"]. What happened?

    Choose one answer.

    Show the answer to question 3

    Answer: The orders field failed and was set to null; the rest of the data can still be used

    GraphQL reports a field that fails during execution as null in data and adds an error whose path names that field, so a response can be partly successful. The 200 says only that a GraphQL response came back: clients and monitoring have to read the errors list.

  4. Question 4 of 5 Your API wants a client to stop sending requests for a while because it has used up its quota. What should the response be?

    Choose one answer.

    Show the answer to question 4

    Answer: 429 Too Many Requests with a Retry-After header

    429, from RFC 6585, says this client sent too many requests in a given amount of time, and Retry-After says how long to wait. 503 is about the server being unable to serve anyone right now, and 403 tells the client that waiting will not help.

  5. Question 5 of 5 Which consumer is the best fit for a gRPC interface?

    Choose one answer.

    Show the answer to question 5

    Answer: Forty internal services in Go, Java and Python that call each other with tight deadlines

    gRPC gives typed contracts generated for many languages, deadlines and streaming over HTTP/2, which suits services you control on both ends. Partners and browsers do better with plain HTTP resources, and a screen with changing data needs is where GraphQL or a backend for that screen helps.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.