HTTP semantics for API design and operations: safe and idempotent methods, status code classes, caching and content headers, and the evolution from HTTP/1.1 to HTTP/2 and HTTP/3.
HTTP is a request/response protocol: method + path + headers (+ body) in, status + headers (+ body) out.
GET/HEAD/OPTIONS are safe; GET, PUT, DELETE are idempotent (repeating has the same effect); POST and usually PATCH are not, so retries need an idempotency key.Content-Type, Accept, Authorization, Cache-Control, ETag/If-None-Match, Retry-After, traceparent.| Method | Safe | Idempotent | Typical use |
|---|---|---|---|
| GET | yes | yes | Read a resource |
| HEAD | yes | yes | Headers only (existence, size) |
| OPTIONS | yes | yes | CORS preflight, capabilities |
| PUT | no | yes | Replace a resource at a known URI |
| DELETE | no | yes | Remove a resource |
| POST | no | no | Create, or run a command |
| PATCH | no | not guaranteed | Partial update |
Idempotency is what makes retries safe. Retrying a timed-out POST /payments can charge twice unless the server deduplicates with an Idempotency-Key header.
| Code | Meaning | Note |
|---|---|---|
| 200 / 201 / 204 | OK / Created (+ Location) / No Content |
|
| 304 | Not Modified | Response to If-None-Match with matching ETag |
| 400 / 422 | Bad request / validation failed | Return ProblemDetails |
| 401 | Not authenticated | Missing or invalid credentials |
| 403 | Authenticated but not allowed | |
| 404 / 409 | Not found / Conflict (e.g. concurrency) | |
| 429 | Too many requests | Include Retry-After |
| 500 | Unhandled server error | |
| 502 / 503 / 504 | Bad gateway / unavailable / gateway timeout | Usually from a load balancer or proxy |
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Format | Text | Binary frames | Binary frames |
| Transport | TCP | TCP (+TLS in practice) | QUIC over UDP (TLS 1.3 built in) |
| Concurrency | One request at a time per connection; clients open ~6 per host | Many streams on one connection | Many streams, independent loss recovery |
| Header compression | No | HPACK | QPACK |
| HOL blocking | At HTTP level | At TCP level | Removed (per stream) |
gRPC requires HTTP/2 (or HTTP/3).
A mobile client retried POST /orders on timeouts. When the API was slow, some customers got two orders. We added an Idempotency-Key header generated by the client per checkout, stored keys with the response for 24 hours, and returned the stored response on repeats. Duplicate orders stopped, and we also returned 429 with Retry-After during overload so clients backed off instead of hammering.
Checks practical HTTP knowledge that shapes API design and production behaviour.
Green flags
Red flags
GET /orders/42 HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer eyJ...
If-None-Match: "v7"
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
HTTP/1.1 304 Not Modified
ETag: "v7"
Cache-Control: private, max-age=60
Cache-Control: public, max-age=300 lets CDNs and browsers cache; private limits to the client; no-store forbids caching.ETag + If-None-Match lets a client revalidate cheaply (304, no body).ETag + If-Match on PUT/PATCH gives optimistic concurrency: 412 Precondition Failed if someone else changed it.Vary: Accept-Encoding, Authorization tells caches which request headers change the response.connection 1: [req A ───── resp A][req B ── resp B]
connection 2: [req C ─── resp C]
... browsers open ~6 per host; slow responses block the rest
Pipelining exists but is effectively unused because responses must return in order.
one TCP connection
├─ stream 1: HEADERS GET /a ... DATA
├─ stream 3: HEADERS GET /b ... DATA frames interleaved
└─ stream 5: HEADERS POST /c ... DATA
HttpClient uses HTTP/2 when the server negotiates it via ALPN over TLS and you set Version = 2.0 (default request version is 1.1). SocketsHttpHandler.EnableMultipleHttp2Connections opens more connections when stream limits are reached.Same semantics, carried on QUIC. Faster connection setup, better on lossy mobile networks, connection migration. Requires UDP 443 open; negotiated via Alt-Svc.
Proxies, caches, SDKs and retry policies all rely on method and status semantics. Returning 200 with {"error": ...} breaks retries, monitoring and caching. Use correct codes and ProblemDetails (RFC 9457) bodies.
curl -v https://example.com and read every header.curl -sI --http2 https://example.com vs --http1.1.app.UseOutputCache() or ETag support to an endpoint and test If-None-Match with curl.POST with and without an idempotency key and count created rows.// Idempotent POST using an Idempotency-Key header.
// The unique key in IdempotencyRecords is the real guard: if two retries race,
// the second insert fails and we return the response the first one stored.
app.MapPost("/orders", async (
[FromHeader(Name = "Idempotency-Key")] string? key,
CreateOrder cmd, OrdersDb db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(key))
return Results.Problem("Idempotency-Key header is required", statusCode: 400);
var existing = await db.IdempotencyRecords.AsNoTracking()
.SingleOrDefaultAsync(r => r.Key == key, ct);
if (existing is not null)
return Results.Json(existing.ResponseBody, statusCode: existing.StatusCode);
var order = Order.Create(cmd);
db.Orders.Add(order);
db.IdempotencyRecords.Add(new IdempotencyRecord(key, 201, order.ToDto()));
try
{
// Order and key are saved in one transaction: both or neither.
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
// A concurrent request with the same key won the race.
db.ChangeTracker.Clear();
var winner = await db.IdempotencyRecords.AsNoTracking()
.SingleAsync(r => r.Key == key, ct);
return Results.Json(winner.ResponseBody, statusCode: winner.StatusCode);
}
return Results.Created($"/orders/{order.Id}", order.ToDto());
});
// IsUniqueViolation is a small extension: for SQL Server it checks
// SqlException.Number 2627 or 2601; for PostgreSQL, SqlState "23505".
// Overload protection: the built-in rate limiter returns 429 with Retry-After
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddFixedWindowLimiter("reports", w => { w.PermitLimit = 10; w.Window = TimeSpan.FromSeconds(30); });
o.OnRejected = (ctx, _) =>
{
if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
ctx.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
return ValueTask.CompletedTask;
};
});
app.UseRateLimiter();
app.MapGet("/reports", GetReports).RequireRateLimiting("reports");
// Client: prefer HTTP/2 to an internal API, fall back to HTTP/1.1 if not negotiated
using var http = new HttpClient(new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(5) // pick up DNS and scale-out changes
})
{
BaseAddress = new Uri("https://orders.internal/"),
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
Mistake: Retrying POST automatically without idempotency.
Why it is wrong: A timeout does not mean the server did nothing, so a retry can create a second order or charge.
Instead: Retry only idempotent methods by default, and require an idempotency key for POST operations that must be retried.
Mistake: Assuming every PATCH is safe to retry.
Why it is wrong: Relative updates such as "increment stock" or appending to an array apply twice on retry.
Instead: Design patches to set absolute values, guard them with If-Match and an ETag, or treat them like POST with an idempotency key.
Mistake: Returning 200 with an error payload.
Why it is wrong: Monitoring, caches, SDKs and retry policies all treat it as success.
Instead: Use correct status codes with ProblemDetails (RFC 9457) bodies.
Mistake: Mixing up 401 and 403. Why it is wrong: Clients react differently: 401 triggers re-authentication, 403 means stop. Instead: Return 401 for missing or invalid credentials and 403 for insufficient permissions.
Mistake: Thinking HTTP/2 always makes things faster. Why it is wrong: All streams share one TCP connection, so packet loss stalls them all, and long-lived connections can send all traffic to one backend. Instead: Measure, and use L7 load balancing or connection limits for gRPC and HTTP/2.
HTTP semantics are a contract with every intermediary: CDNs cache by method and Cache-Control, retry policies rely on idempotency, load balancers report 5xx, and SDKs map status codes to exceptions. Respecting them makes resilience and caching almost free.
| Design principle | Example | Benefit |
|---|---|---|
| Idempotent writes | PUT /orders/42, or POST with Idempotency-Key |
Safe client and gateway retries |
| Optimistic concurrency | ETag plus If-Match, 412 on conflict |
No lost updates without locks |
| Conditional reads | If-None-Match, 304 |
Less bandwidth and server work |
| Honest status codes | 429 with Retry-After, 503 during maintenance |
Clients back off instead of hammering |
| Standard error bodies | ProblemDetails |
Consistent client handling and logs |
| Version | Best fit | Watch out for |
|---|---|---|
| HTTP/1.1 | Simple clients, legacy proxies | Connection count, no multiplexing |
| HTTP/2 | Service-to-service, gRPC | L4 balancing pins connections |
| HTTP/3 | Mobile apps, global users on lossy networks (CDNs such as Cloudflare and Azure Front Door support it) | UDP 443 blocked, msquic on Linux |
Underneath, the bigger wins usually come from connection reuse, sensible timeouts, conditional requests and idempotent design.
Use TCP for most APIs, databases and file transfer. Use UDP for DNS, real-time media, games, metrics (StatsD) and as the base for QUIC. HTTP/3 runs on QUIC over UDP so it can combine TLS 1.3 with the transport handshake, avoid TCP's head-of-line blocking between streams, and survive network changes.
CONNECT. HttpClient picks it up from system settings or HTTP_PROXY/HTTPS_PROXY/NO_PROXY.Behind a reverse proxy, the app sees the proxy's IP and often plain http. The proxy passes the originals in X-Forwarded-For and X-Forwarded-Proto, but ASP.NET Core only applies them via the ForwardedHeaders middleware, and by default only trusts proxies on loopback. So UseHttpsRedirection sees http, redirects to https, the proxy forwards as http again: a loop.
Fix: UseForwardedHeaders first, configured with the proxy's IPs or network in KnownProxies/known networks. Do not trust all sources, or clients can spoof their IP and scheme.
A load balancer spreads traffic across healthy instances.
Health checks remove unhealthy instances (in Kubernetes: readiness for traffic, liveness for restarts). Algorithms: round robin, least connections/requests, hashing. Sticky sessions pin a user to one instance, which hurts scaling and failover. Connection draining stops new traffic to an instance before shutdown so in-flight requests finish.
gRPC uses long-lived HTTP/2 connections, so an L4 balancer balances connections, not calls: one client connection carries all its calls to one pod. Use L7 balancing (mesh/Envoy) or client-side load balancing.
How to tell it (STAR)
**S:** During a [Friday peak], customers of our [ordering app] were charged twice. **T:** I owned the orders API and had to stop duplicates. **A:** Logs showed the app retried a slow `POST`, and because POST is not idempotent, the retry created a second order. I added an idempotency key per checkout, stored with the result under a unique constraint, and returned 429 with Retry-After when overloaded. **R:** Duplicate orders went to [zero] and refunds for that issue stopped.
Last updated 23 Oct 2026.