The real-world analogy: certified mail and library index cards
Imagine managing a physical postal service:
- Tracking Number & Idempotency: You are sending a package containing money to a recipient. To ensure they don't receive the same package twice if the mail carrier gets lost and retries delivery, the post office stamps a unique, global registration number on the envelope (the Idempotency Key). If the carrier brings a package with a number the receiver has already signed for, the receiver rejects the second delivery but hands back a receipt copy of the first one.
- Page Markers (Cursors vs Offsets): You are reading a massive 1,000-page book.
- Offset: You tell your friend, "Open the book to page 300." If someone rips out 5 pages from the beginning of the book, "page 300" is now actually page 305—you've skipped content.
- Cursor: You place a physical sticky bookmark on the page. You tell your friend, "Start reading from the bookmark." No matter how many pages are inserted or removed behind it, you start precisely where you left off.
Model resources, not actions
URLs are nouns; verbs are HTTP methods. POST /trades, GET /portfolios/{id}, DELETE /watchlist/{symbol} — not /createTrade or /getPortfolio.
| Method | Use | Idempotent? | Safe? |
|---|---|---|---|
| GET | read | ✅ | ✅ |
| POST | create / non-idempotent action | ❌ | ❌ |
| PUT | replace (full) | ✅ | ❌ |
| PATCH | partial update | usually ❌ | ❌ |
| DELETE | remove | ✅ | ❌ |
Idempotent = doing it twice == doing it once. It matters because networks retry: a client that times out on PUT or DELETE can safely resend. POST isn't idempotent — which is why creating money-moving resources needs an idempotency key.
Status codes that signal correctly
200/201/204success (201 create, 204 no body).400bad input ·401unauthenticated ·403authenticated-but-forbidden ·404missing ·409conflict ·422validation ·429rate-limited.500us ·503down/overloaded.
Return a consistent error body (code, message, maybe details) so clients can branch on code, not parse prose.
Pagination
- Offset/limit (
?page=3&size=20): simple, but slow deep in the list and skips/dupes rows when data shifts under you. - Cursor/keyset (
?after=<opaque>): pass the last seen sort key; stable and fast at any depth. Prefer cursors for feeds and large/changing lists.
Versioning & idempotency keys
- Versioning:
/.../v1/...(or a header). Version when you make a breaking change; add fields additively otherwise. - Idempotency key: client sends a unique
Idempotency-Keyon aPOST; the server records the result for that key and returns the same response on retry — so a retried "place trade" or "charge card" doesn't double-execute.
Here's the retry that would have double-charged, made safe:
Implementation — Client-Side Retries with Backoff
The client must submit a unique key for unsafe operations and handle retries gracefully using exponential backoff when hitting transient limits.
Python
import time
import uuid
import requests
def submit_payment_with_retry(url: str, amount: float, max_retries: int = 3) -> dict:
idempotency_key = str(uuid.uuid4())
headers = {
"Idempotency-Key": idempotency_key,
"Content-Type": "application/json"
}
payload = {"amount": amount}
for attempt in range(max_retries):
try:
response = requests.post(url, json=payload, headers=headers, timeout=5)
if response.status_code == 201:
return response.json()
elif response.status_code == 429: # Rate limited
time.sleep(2 ** attempt) # Exponential backoff
else:
raise Exception(f"Unrecoverable error: {response.status_code}")
except requests.exceptions.RequestException:
# Network drop or connection timeout - safe to retry due to idempotency key!
time.sleep(2 ** attempt)
raise Exception("Max retries exceeded")
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.UUID;
public class ApiClient {
private final HttpClient httpClient = HttpClient.newBuilder().build();
public String submitPaymentWithRetry(String url, double amount, int maxRetries) throws Exception {
String idempotencyKey = UUID.randomUUID().toString();
String jsonPayload = String.format("{\"amount\": %.2f}", amount);
for (int attempt = 0; attempt < maxRetries; attempt++) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Idempotency-Key", idempotencyKey)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 201) {
return response.body();
} else if (response.statusCode() == 429) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
} else {
throw new RuntimeException("Unrecoverable error: " + response.statusCode());
}
} catch (Exception e) {
// Network timeout or socket close - safe to retry
Thread.sleep((long) Math.pow(2, attempt) * 1000);
}
}
throw new RuntimeException("Max retries exceeded");
}
}
C++
#include <iostream>
#include <string>
#include <chrono>
#include <thread>
#include <cmath>
#include <stdexcept>
// Mock Response Structure
struct HttpResponse {
int status_code;
std::string body;
};
// Mock Http Client Class
class HttpClient {
public:
HttpResponse post(const std::string& url, const std::string& body, const std::string& idempotency_key) {
// Simulated HTTP POST execution
return HttpResponse{201, "{\"status\":\"success\"}"};
}
};
class ApiClient {
private:
HttpClient client;
public:
std::string submitPaymentWithRetry(const std::string& url, double amount, int max_retries = 3) {
std::string idempotency_key = "uuid-mock-12345";
std::string payload = "{\"amount\": " + std::to_string(amount) + "}";
for (int attempt = 0; attempt < max_retries; ++attempt) {
try {
HttpResponse response = client.post(url, payload, idempotency_key);
if (response.status_code == 201) {
return response.body();
} else if (response.status_code == 429) {
int sleep_sec = static_cast<int>(std::pow(2, attempt));
std::this_thread::sleep_for(std::chrono::seconds(sleep_sec));
} else {
throw std::runtime_error("Unrecoverable error: " + std::to_string(response.status_code));
}
} catch (const std::exception& e) {
int sleep_sec = static_cast<int>(std::pow(2, attempt));
std::this_thread::sleep_for(std::chrono::seconds(sleep_sec));
}
}
throw std::runtime_error("Max retries exceeded");
}
};
Interactive Quiz
1.
2.
3.