Request and Response
Read HTTP contracts, reuse connections, validate inputs, and distinguish a failed response from a failed operation.
A request can reach the server, change state, and still fail at the client. HTTP gives the two programs a shared message contract; it does not make the network behave like a function call inside one process. Understanding that boundary helps you decide what a response proves, how long to wait, and what to do when no response arrives.
Read the message before the handler
An HTTP message carries control information, fields, and optional content. The URL identifies a resource; the method describes the requested action. JSON is one representation of content, not HTTP itself. Content-Type describes the representation being sent; Accept describes response media types the client can receive.
The examples use a small reservation server. Download the complete runnable fixture, extract it, and run commands from its directory. You need Node.js 22+ and curl, with no packages to install. Run node verify.mjs for assertions or start node server.mjs in another terminal. It binds to 127.0.0.1:8081; all state is process-local and disappears when it exits. It deliberately omits authentication and TLS.
Create a reservation and inspect the response:
curl --http1.1 -i http://127.0.0.1:8081/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"BOOK-1","quantity":2}'
# 201 Created; Location: /reservations/1; X-Request-Id: <server UUID>
curl --http1.1 -I http://127.0.0.1:8081/reservations/1
# 200 OK; Content-Length: 38; no response content for HEAD
curl --http1.1 -i http://127.0.0.1:8081/reservations \
-H 'Content-Type: text/plain' --data '{"sku":"BOOK-1","quantity":2}'
# 415 Unsupported Media Type, with a JSON error and request IDThe important parts of the first exchange look like this; incidental fields are omitted. This readable HTTP/1.1 layout is not the wire encoding of HTTP/2 or HTTP/3:
POST /reservations HTTP/1.1
Host: 127.0.0.1:8081
Content-Type: application/json
Content-Length: 29
{"sku":"BOOK-1","quantity":2}
HTTP/1.1 201 Created
Location: /reservations/1
Content-Type: application/json
Content-Length: 38
X-Request-Id: <server-generated UUID>
{"id":"1","sku":"BOOK-1","quantity":2}Content-Length counts bytes, not JavaScript characters. HEAD returns the fields describing the GET response without its content, so a client cannot blindly treat every positive length as bytes that follow. The fixture checks that boundary explicitly.
Methods establish expectations before an endpoint's detailed schema is considered:
| Method | Intended effect | Consequence for a caller |
|---|---|---|
| GET | Retrieve a representation without requesting a state change | Reading a page should not reserve inventory. |
| PUT | Create or replace state at the target | Repetition has the same intended effect; response codes can differ. |
| POST | Submit endpoint-defined processing | Repetition may create another reservation. |
Idempotence concerns intended effects, not identical responses or the absence of access logs. The fixture implements POST and GET/HEAD, not PUT.
A status narrows what is known: 201 identifies creation, 202 means accepted but unfinished, and 204 means success without content. 202 does not promise eventual success. 400 can describe malformed input, 415 an unsupported representation, and 503 a temporary inability to serve. A JSON body might accompany a failure, so inspect the status before using the representation.
Reuse the connection, then find the remaining wait
A typical new HTTPS exchange over TCP follows this path: resolve the host through DNS, establish TCP, negotiate TLS, then send HTTP. DNS may already be cached; an existing connection may skip setup. TLS protects the exchange and normally authenticates the server's identity, not the application's end user. HTTP/3 instead runs over QUIC with integrated TLS, using UDP.
HTTP/1.1 can carry multiple sequential exchanges on one connection. HTTP/2 multiplexes exchanges, but missing TCP bytes can stall unrelated streams sharing that connection. HTTP/3's QUIC streams remove that particular dependency. Congestion, stream limits, and application queues still constrain progress. Changing the transport can help many small exchanges on a lossy path while doing little for a handler waiting on storage.
Observe reuse with connection counts instead of a localhost speed claim. The fixture's reuse.mjs uses this client:
import http from 'node:http';
import { readOnce } from './reuse.mjs';
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
try {
const url = 'http://127.0.0.1:8081/reservations/1';
console.log(await readOnce(url, agent)); // false: fresh socket
console.log(await readOnce(url, agent)); // true: reused socket
} finally {
agent.destroy();
}
// readOnce calls res.resume(), awaits 'end', then returns req.reusedSocket.Run node reuse.mjs against the manual server. Its first pair accepts one connection; its second pair uses agent: false and accepts two. The assertion script verifies those counts independently. Consuming or closing response bodies releases capacity; leaving bodies unread can hold a connection. Bound connection pools as well as request concurrency, or reuse merely moves overload into another queue.
A deadline can end the wait after the write
These two sequences leave different business outcomes even when a caller sees failure:
Rejected: client sends → server validates → rejects → 422 reaches client
No reservation was created.
Ambiguous: client sends → server commits → response connection breaks
Client sees a transport error; the write already happened.The fixture creates the second sequence without a timing race: it records an operation result, then destroys the socket before sending response headers. Run node outcomes.mjs once per server process. The relevant server and recovery code is:
// Server: execute these statements in order.
operations.set('demo-1', { state: 'committed', sku: 'BOOK-1', quantity: 2 });
req.socket.destroy();
// Client: a failed attempt can be followed by a separate lookup.
const result = await fetch('http://127.0.0.1:8081/operations/demo-1');
console.log(await result.json()); // { state: 'committed', ... }
try {
const response = await fetch('http://127.0.0.1:8081/slow-body', {
signal: AbortSignal.timeout(100),
});
await response.json(); // Keep response reading inside the same error boundary.
} catch (error) {
console.log(error.name); // Client stops waiting for the delayed content.
}The lookup is a recovery hook, not durable deduplication: restarting this server loses the record. An operation identifier names business work across attempts; a request identifier names one attempt. A real retry contract must define what repeating the operation means. The Queues and Retries lesson develops durable duplicate handling and retry budgets.
A total request budget includes connection setup, upload, queueing, dependency calls, and reading the response. A socket inactivity timeout instead limits a period without activity; a server sending occasional bytes can remain active beyond the total budget. Give nested calls the remaining budget rather than a fresh full allowance each time.
Cancellation asks work to stop; the application must connect it to dependency cancellation and cleanup. It cannot reverse a committed write. The assertions deliberately hold server work behind an explicit release, abort the client's body read, then complete that work. This separates ending the wait from stopping execution.
RPC and HTTP versions describe different choices. gRPC adds generated service/message contracts, streaming, statuses, and deadline tooling over HTTP. It can suit controlled service integrations; HTTP/JSON often offers simpler direct browser and public-client integration. Generated types do not make a call local or establish permissions. gRPC sets no deadline by default, and automatic propagation depends on the implementation: Java and Go enable it by default, while C++ requires enabling it.
Turn untrusted bytes into a bounded operation
Parsing is only one stage of accepting a request. Constrain the bytes retained and work spent reading, check the representation type, decode safely, validate shape and values, then enforce authenticated identity and authorization before business work. Valid JSON such as {"quantity":-1} can still be an invalid command.
The fixture caps content at 1,024 bytes before parsing, including requests without a declared length. Its pure parser rejects invalid UTF-8 and malformed JSON with 400, then rejects commands outside its schema with 422. The central shape check is:
// After strict UTF-8 decoding and JSON.parse:
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new InputError(422, 'invalid_command');
}
if (Object.keys(value).some(key => key !== 'sku' && key !== 'quantity') ||
typeof value.sku !== 'string' || !/^[A-Z0-9-]{1,32}$/.test(value.sku) ||
!Number.isSafeInteger(value.quantity) || value.quantity < 1 || value.quantity > 100) {
throw new InputError(422, 'invalid_command');
}
// A caller-supplied tenant or user field is rejected before any write.Rejecting writable identity fields prevents this small schema from accidentally accepting them. It does not authenticate a caller. Production code must derive its principal from verified credentials and authorize the operation separately; a tenant name in JSON or a header is only caller input.
Every application-handled success and failure receives a fresh server request ID, with the same ID in error content. HTTP parsing failures before the handler are outside that guarantee. A caller correlation value, if retained, needs its own length/format limits and separate field. Neither value grants permission or suppresses duplicate work.
Run node verify.mjs and inspect the rejected negative quantity alongside the disconnected write. The first has a known rejection and unchanged state; the second requires an operation lookup. For each exchange, identify the method and target expressing intent, the status and fields carrying evidence, and the point where the client's knowledge ends.