404 when nothing matches, a real 502 when the backend dies -- in stead of dropping the socket and leaving the client guessing.Learn Zig Series):Last episode I signed off pointing at data structures as our next stop, and we will get there. But I realised something looking back over the last stretch: we built an HTTP server across episodes 51 to 54, then in 101 and 102 we built a tool to hammer it with honest load -- and we never built the one piece of infrastructure that sits between a client and that server in literaly every production deployment on earth. The reverse proxy. So one more tool first, then we turn the lens inward. Having said that, roll up your sleeves.
A reverse proxy is deceptively humble in its job description: it accepts a client's HTTP request, decides which backend server should actually handle it, forwards the request there, and streams the reply back to the client. That's it. And yet that little middleman is where TLS termination happens, where load balancing lives, where rate limiting and caching and access control get bolted on -- it's the town square of modern web infrastructure. Today we build the first and most fundamental job it does: routing. Given a request, pick the right backend. We'll do load balancing and health checks in the parts that follow, but none of that means anything until the router knows where a request is supposed to go.
The word "proxy" gets overloaded, so let me draw the line sharply, because getting the direction backwards will confuse everything that follows. A forward proxy sits in front of clients -- your company's outbound proxy, a VPN exit node, the thing that hides which of a thousand employees is fetching a given URL. The server on the far side has no idea who the real client is; the proxy speaks for the clients. A reverse proxy sits in front of servers. The client thinks it's talking to one machine (api.example.com), but behind that single address the proxy is quietly fanning requests out to a fleet of backends. The client has no idea how many servers there are, or which one served it. The proxy speaks for the servers.
We built a SOCKS5 forward proxy back in episode 93, so you've seen the other direction. The mechanics rhyme -- accept a connection, open a second one, shovel bytes between them -- but the decision is completely different. SOCKS5 asks the client where it wants to go. A reverse proxy decides that itself, based on rules the operator sets, because the whole point is that the client doesn't get a vote. That decision is what we call routing, and it's usually driven by two pieces of the request: the Host header (which domain?) and the path (which part of that domain?). For this part we route on path, which is the more common and more interesting case -- /api/* goes to the application servers, /static/* goes to the asset servers, everything else to the marketing site. One public address, three very different backends.
Let's name the shape of the problem before writing a line of logic (episode 6 discipline -- get the types right and the code writes itself). A route is a rule: a path prefix, and the address of the backend that should serve anything under it. The router is just a collection of those rules plus the algorithm that picks one.
const std = @import("std");
/// One routing rule: any request whose path STARTS WITH `prefix` is sent to the
/// backend at `upstream`. That's it -- a prefix and a destination. The strings are
/// borrowed from config that outlives us, so a Route is a plain copyable value with
/// no allocator and no deinit (episode 5's slice-as-a-view thinking, applied to URLs).
const Route = struct {
prefix: []const u8,
upstream: std.net.Address,
};
/// The routing table. It owns nothing but a slice of rules, which keeps it a value
/// you can pass and copy freely. The whole intelligence of the proxy's first job
/// lives in `match` -- everything else is just plumbing around this one decision.
const Router = struct {
routes: []const Route,
/// Return the upstream for `path` by LONGEST-prefix match, or null if nothing
/// matches. Pure function of its inputs -- no I/O, no globals, no allocation --
/// so it's the piece we hammer with unit tests (exactly like ep102's frameResponse).
fn match(self: Router, path: []const u8) ?std.net.Address {
var best: ?std.net.Address = null;
var best_len: usize = 0;
for (self.routes) |r| {
// A rule applies if the path starts with its prefix. Among all applicable
// rules, the LONGEST prefix wins -- that's what lets "/api/v2" override a
// broader "/api" without depending on the order rules were declared in.
if (std.mem.startsWith(u8, path, r.prefix) and r.prefix.len >= best_len) {
best = r.upstream;
best_len = r.prefix.len;
}
}
return best;
}
};
Why longest-prefix and not first-match? Because first-match makes your routing table order-dependent, and order-dependent config is a bug generator. If /api is listed before /api/v2, first-match sends everything under /api/v2 to the wrong place, and the failure is invisible until someone reorders the list. Longest-prefix match is declaration-order-independent: the most specific rule always wins, no matter where you wrote it. This is the same instinct routers have used since the dawn of IP -- the most specific route in the table is the one that governs. Nota bene: if you've ever stared at a routing table wondering why traffic went somewhere unexpected, "most specific wins" is the rule to hold in your head.
To route on path, we need the path, which means reading the request line -- that first GET /api/users HTTP/1.1 line we dissected in depth back in episode 84. But here's the crucial mindset shift for a proxy: we parse the minimum we need to make a decision, and we forward everything else untouched. We are not rewriting the request (yet). We are not validating headers. We pull the path out to route on it, and the rest of those bytes get relayed to the backend exactly as they arrived. Less parsing means fewer bugs, and -- more importantly -- fewer ways for a maliciously crafted request to trip us up, because a reverse proxy is exposed to the entire internet by definition.
/// The only two fields routing needs off the request line: the method (handy for
/// logging) and the path (what we actually match on). We ignore the HTTP version
/// and every header -- those get forwarded verbatim, not inspected.
const RequestHead = struct {
method: []const u8,
path: []const u8,
};
/// Pull method and path off the first line of an HTTP request. Returns null if the
/// line is malformed -- and a proxy MUST treat malformed input as routine, not as an
/// exception, because the whole internet can reach it and a fair slice of that traffic
/// is scanners, bots, and garbage. Never trust the bytes; validate, then decide.
fn parseRequestLine(buf: []const u8) ?RequestHead {
const line_end = std.mem.indexOf(u8, buf, "\r\n") orelse return null;
const line = buf[0..line_end];
const sp1 = std.mem.indexOfScalar(u8, line, ' ') orelse return null;
const rest = line[sp1 + 1 ..];
const sp2 = std.mem.indexOfScalar(u8, rest, ' ') orelse return null;
return .{ .method = line[0..sp1], .path = rest[0..sp2] };
}
Notice how the optional return type does the heavy lifting. ?RequestHead forces the caller to confront the "this wasn't a valid request" case at the call site -- there is no path through the code where you accidentally route a garbage request because you forgot to check. In a language without this, that missing check is the classic proxy vulnerability: a half-parsed request line that reads past its bounds, or a null path that matches the wrong rule. Zig makes forgetting it a compile-time impossibility, which is exactly the property you want in code that faces hostile input all day.
We also need to read the full request head off the socket before we can parse it, because a single read() almost never hands you a complete request -- that was the whole painful lesson of episode 102, seen now from the server side of the wire.
/// Read from `stream` until we have the complete request head (through the blank
/// line `\r\n\r\n` that terminates the headers), or the buffer fills. We only need
/// the HEAD to route; the body, if any, is a separate concern handled downstream.
fn readHead(stream: std.net.Stream, buf: []u8) !usize {
var total: usize = 0;
while (total < buf.len) {
const n = try stream.read(buf[total..]);
if (n == 0) break; // client hung up before finishing -- caller decides what that means
total += n;
// The headers are complete the moment we see the blank line. Stop there --
// don't wait for more, don't read into the body we're not parsing.
if (std.mem.indexOf(u8, buf[0..total], "\r\n\r\n") != null) break;
}
return total;
}
Here's the payoff of keeping match and parseRequestLine free of I/O: we can test the entire routing brain without opening a socket, spawning a backend, or touching the network at all. Deterministic input, deterministic output, runs in microseconds. This is the same move that made episode 102's framing logic bulletproof, and it's worth internalising as a reflex -- the bug-prone decisions get isolated into pure functions, and the pure functions get pounded with tests until they beg for mercy.
test "router picks the longest matching prefix, order-independently" {
const a = std.net.Address.parseIp("127.0.0.1", 1) catch unreachable;
const b = std.net.Address.parseIp("127.0.0.1", 2) catch unreachable;
const c = std.net.Address.parseIp("127.0.0.1", 3) catch unreachable;
// Deliberately declared broad-to-narrow AND out of order, to prove that
// longest-prefix match does not care what order we listed the rules in.
const routes = [_]Route{
.{ .prefix = "/", .upstream = a },
.{ .prefix = "/api/v2", .upstream = c },
.{ .prefix = "/api", .upstream = b },
};
const router = Router{ .routes = &routes };
// "/api/v2/users" matches all three; the longest (/api/v2) must win.
try std.testing.expectEqual(c.getPort(), router.match("/api/v2/users").?.getPort());
// "/api/users" matches "/" and "/api" -- "/api" is the longer of the two.
try std.testing.expectEqual(b.getPort(), router.match("/api/users").?.getPort());
// "/index.html" matches only the catch-all "/".
try std.testing.expectEqual(a.getPort(), router.match("/index.html").?.getPort());
}
test "parseRequestLine extracts method and path, rejects garbage" {
const r = parseRequestLine("GET /api/users?id=7 HTTP/1.1\r\nHost: x\r\n\r\n").?;
try std.testing.expectEqualStrings("GET", r.method);
// The query string rides along in the path -- we route on it as-is, we don't parse it.
try std.testing.expectEqualStrings("/api/users?id=7", r.path);
// A line with no spaces is not a request line. Reject it, don't misroute it.
try std.testing.expect(parseRequestLine("garbage-with-no-spaces\r\n") == null);
}
That order-independence test is the one I'd fight to keep in code review. It encodes the reason we chose longest-prefix in the first place, so that if some well-meaning future refactor quietly turns it back into first-match, a red test explains exactly what invariant just broke and why anyone cared.
Routing decides where. Forwarding actually does it: open a connection to the chosen backend (episode 21's tcpConnectToAddress, our old friend), send it the client's request, and pump every byte of the reply straight back to the client. The relay itself is gloriously dumb -- and that dumbness is a feature. The proxy does not need to understand the response. It doesn't care if it's a 200 or a 500, JSON or a JPEG. Routing's job ended the moment bytes started flowing; from here it's honest byte-copying.
/// Relay one exchange: connect to `upstream`, forward the client's request head,
/// then stream every byte the backend returns straight back to the client until the
/// backend closes. Dumb on purpose -- we don't parse the response, we just move it.
fn relay(upstream: std.net.Address, head: []const u8, client: std.net.Stream, scratch: []u8) !void {
const up = try std.net.tcpConnectToAddress(upstream);
defer up.close();
// Forward the request head verbatim. Same method, same path, same headers -- the
// backend sees essentially what the client sent. (A production proxy tweaks a few
// headers here; see the note on Host and X-Forwarded-For further down.)
try up.writeAll(head);
// Stream the reply back. Reading to EOF is CORRECT here because our backends are
// the HTTP servers we built in episodes 51-54, which send `Connection: close` and
// hang up when the response is done. That EOF is our clean end-of-response signal.
// Front a keep-alive backend and this must become ep102's frameResponse instead --
// flagged honestly below rather than hidden.
while (true) {
const n = try up.read(scratch);
if (n == 0) break; // backend closed -- the response is complete
try client.writeAll(scratch[0..n]);
}
}
I want to be dead honest about that read-to-EOF, because it's the one place this Part 1 leans on a simplifying assumption. It is correct against the backends we built in this series, which close the socket after each response. It is NOT correct against a backend that keeps the connection alive -- there, EOF never arrives between responses, and this loop would hang forever waiting for a close that isn't coming. The fix is exactly the machinery we wrote last episode: frame the response with Content-Length or chunked decoding, and stop when one complete response has been relayed. I'm deliberately not dragging all of that back in today, because the routing story is what this episode is about, and piling framing on top would bury it. Knowing precisely where your simplification lives is worth more than pretending it isn't there.
A proxy that drops the socket when something goes wrong is a proxy that's impossible to debug, and infuriating to sit behind. There are two distinct failures, and they map onto two specific HTTP status codes that exist for exactly this situation. If no route matches, that's a 404 Not Found -- the proxy is telling the client "there's nothing here to reach". If a route matches but the backend refuses the connection or dies mid-response, that's a 502 Bad Gateway -- the canonical "I am a gateway and my upstream failed me" status (we met the whole 5xx family in episode 84). Getting these two right is the difference between a proxy you can operate and one that generates support tickets.
/// Write a minimal but correct HTTP/1.1 error response ourselves. Content-Length is
/// mandatory so the client knows exactly where the body ends -- that's episode 102's
/// framing lesson seen from the server's chair: omit the length and a well-behaved
/// client sits there waiting for bytes that never come.
fn sendError(client: std.net.Stream, code: u16, reason: []const u8) void {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf,
"HTTP/1.1 {d} {s}\r\n" ++
"Content-Length: {d}\r\n" ++
"Connection: close\r\n\r\n" ++
"{s}",
.{ code, reason, reason.len, reason },
) catch return;
client.writeAll(msg) catch {};
}
/// Handle ONE client connection start to finish: read its head, decide where it goes,
/// and either relay it to the chosen backend or answer with our OWN error. Every
/// failure path still sends the client a valid HTTP response -- a proxy that stays
/// silent on error is a proxy nobody can diagnose.
fn handleClient(router: Router, client: std.net.Stream, buf: []u8, scratch: []u8) void {
defer client.close();
const head_len = readHead(client, buf) catch return;
if (head_len == 0) return; // empty connection, nothing to do
const head = buf[0..head_len];
const req = parseRequestLine(head) orelse {
sendError(client, 400, "Bad Request");
return;
};
const upstream = router.match(req.path) orelse {
std.log.info("no route for {s} {s} -> 404", .{ req.method, req.path });
sendError(client, 404, "Not Found");
return;
};
relay(upstream, head, client, scratch) catch {
// The backend refused us or died partway through. That's a 502 -- the client
// did nothing wrong, the infrastructure behind us did.
std.log.warn("upstream failed for {s} {s} -> 502", .{ req.method, req.path });
sendError(client, 502, "Bad Gateway");
};
}
For that 404 branch to actually fire, your routing table must not contain a catch-all / rule -- because / is a prefix of every path, so a table with it never fails to match. That's a real config decision, not an accident: a proxy fronting a known set of services often wants the strictness of "if it doesn't match a declared route, reject it", so a stray /wp-admin probe from some bot gets a clean 404 in stead of being forwarded to a backend that might actually do something with it. Whether you include the catch-all is a policy choice, and it's nice that the code makes that choice explicit rather than baking it in.
Now the outermost layer -- listen, accept, dispatch. I'm keeping it deliberately serial for Part 1: one connection handled to completion before the next is accepted. That's not how you'd ship it (a single slow backend would stall every other client), but it lets the routing logic stand naked without a thread pool crowding the frame. Concurrency is a known, flagged next step, and we have all the pieces for it already from episodes 30 and 64.
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
// The routing table. In a real tool this comes from a config file -- and episode
// 20's JSON parsing drops straight in here to build this exact slice. Hard-coded
// for the walkthrough. Note the absence of a "/" catch-all: unmatched paths 404.
const routes = [_]Route{
.{ .prefix = "/api", .upstream = try std.net.Address.parseIp("127.0.0.1", 9001) },
.{ .prefix = "/static", .upstream = try std.net.Address.parseIp("127.0.0.1", 9002) },
};
const router = Router{ .routes = &routes };
const listen_addr = try std.net.Address.parseIp("127.0.0.1", 8080);
var server = try listen_addr.listen(.{ .reuse_address = true });
defer server.deinit();
std.log.info("reverse proxy listening on {any}", .{listen_addr});
// Two big scratch buffers, reused for every connection -- one for the request
// head we route on, one for shovelling the response back. Serial handling is what
// lets us get away with reusing them; the concurrent version needs one pair per worker.
const buf = try alloc.alloc(u8, 64 * 1024);
defer alloc.free(buf);
const scratch = try alloc.alloc(u8, 64 * 1024);
defer alloc.free(scratch);
while (true) {
const conn = server.accept() catch |err| {
std.log.warn("accept failed: {s}", .{@errorName(err)});
continue; // one bad accept must never kill the whole proxy
};
handleClient(router, conn.stream, buf, scratch);
}
}
Fire up two of our episode 51-54 servers on ports 9001 and 9002, point a browser or curl at the proxy on 8080, and watch requests split by path:
$ curl -s localhost:8080/api/users # -> backend on 9001
$ curl -s localhost:8080/static/logo.css # -> backend on 9002
$ curl -si localhost:8080/nope | head -1
HTTP/1.1 404 Not Found
# and with 9001 stopped, to see the gateway error:
$ curl -si localhost:8080/api/users | head -1
HTTP/1.1 502 Bad Gateway
One thing a production proxy does that ours (honestly) skips: it rewrites a couple of headers on the way to the backend. It sets X-Forwarded-For so the backend can see the real client IP (which is otherwise hidden -- the backend only sees the proxy), and it often adjusts the Host header to what the backend expects. Both are small, surgical edits to the head buffer before we forward it, and both are easy to add now that the routing skeleton stands. I'm leaving them as a deliberate exercise for your own tinkering, because the moment you rewrite headers you inherit a pile of subtle rules about which headers a proxy may and may not touch (the "hop-by-hop" headers from the HTTP spec), and that's a rabbit hole for another day.
The routing decision itself is dirt cheap -- a linear scan over a handful of prefixes, a few startsWith comparisons per request. For the tiny tables real proxies use (a few dozen routes at most), linear scan beats anything cleverer, because the constant factors of a hash map or a trie don't pay off until you have hundreds of entries and the branch predictor is already handling the small case beautifully. This is the recurring lesson of episode 34: measure before you optimise, and respect that a simple loop over a short list is often the fastest thing you can do. If you genuinely had thousands of routes you'd reach for a prefix trie (which, funnily enough, is one of the very data structures coming up in this series), but reaching for it now would be seperate-a-solution-looking-for-a-problem engineering.
The real performance story of a reverse proxy isn't the routing -- it's the relaying, and specifically how many connections you can juggle at once. Our serial loop tops out at one client at a time, which for a learning build is exactly right and for anything real is a non-starter. The natural next move is a thread per connection (episode 64) or, better at scale, an event loop multiplexing many connections on a few threads with poll/epoll. And here Zig's explicitness pays a quiet dividend: because every buffer is one we allocated on purpose, scaling to concurrency is a mechanical "give each worker its own pair of buffers" change, not a hunt through hidden per-request allocations. You can see the memory, so you can reason about the scaling. That's the same property that let episode 102's load tester bound its file-descriptor use by worker count and dodge the ProcessFdQuotaExceeded wall.
In C, this is essentially what the guts of nginx and HAProxy look like -- prefix matching over a location table, connect() to an upstream, splice() or a read/write loop to shovel bytes. What Zig bought us for free is the ?RequestHead optional forcing the malformed-request check at the call site; in C, the missing null-check on a half-parsed request is the exact shape of bug that becomes a CVE, because the code faces hostile input by design. The relay loop ports almost character-for-character, but the safety net around the parsing is where Zig quietly earns its keep in code that lives on the open internet.
In Rust, you'd build this on tokio and hyper, or reach straight for the tower ecosystem where routing is a first-class abstraction (tower::Service and friends). You'd get async connection handling for free, real HTTP framing for free, and the borrow checker guaranteeing your per-connection buffers don't alias -- the same non-aliasing property we got by giving each connection its own buffer, just enforced by the compiler in stead of by discipline. The tradeoff is the usual one: a deep, powerful dependency tree between your code and the socket, versus our from-scratch version where you can point at the precise read and know exactly what it does.
In Go, this is almost embarrassingly short, because the standard library ships net/http/httputil.ReverseProxy -- a production-grade reverse proxy in the box, X-Forwarded-For handling and all. A dozen lines gives you what took us an episode. That's not a knock on our build; it's the whole point of building it. Once you've written the router, the relay, and the two error paths by hand, httputil.ReverseProxy stops being magic -- you know precisely what it's doing with your Host header and why it frames responses the way it does. For shipping a proxy tomorrow, reach for the batteries Go includes. For understanding every proxy you'll ever sit behind, build it once from the sockets up ;-)
Look at how little of today was actually new. The upstream connection is episode 21's socket. The request parsing is episode 84's HTTP rules, trimmed to the bone. The pure-function-plus-tests rhythm is episode 102's, and the buffer discipline is the same explicit-memory habit we've leaned on since episode 7. The genuinely new idea was small and portable: longest-prefix match, the rule that makes routing predictable and order-independent, which you'll now recognise in IP routing tables, CDN configs, and every API gateway you ever touch.
But a proxy that routes to exactly one backend per prefix is only half a proxy. The real reason this pattern conquered the web is that a single route can point at many identical backends, and the proxy gets to choose which one handles each request -- spreading load, surviving a dead machine, draining a box for maintenance without anyone noticing. That choosing is where a reverse proxy stops being a glorified switchboard and starts being the thing that keeps a service up. Our router currently holds one address per route; next we teach it to hold a pool, and to pick from that pool intelligently. The switchboard is built -- now we make it smart.
Thanks for reading, and see you in the next one!