Learn Zig Series (#25) - Mini Project: HTTP Status Checker
What will I learn
- You will learn building a CLI tool that checks if URLs are reachable;
- You will learn TCP connection and basic HTTP/1.1 request construction;
- You will learn parsing HTTP response status codes from raw bytes;
- You will learn command-line argument parsing with
std.process.argsAlloc(); - You will learn concurrent checking with a thread pool;
- You will learn timeout handling for slow or unresponsive servers;
- You will learn colored terminal output for pass/fail results;
- You will learn tying together networking, strings, error handling, and I/O from previous episodes.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker (this post)
Learn Zig Series (#25) - Mini Project: HTTP Status Checker
Welcome back! In episode 24 we covered logging, formatting, and debug output -- std.log with scoped loggers, std.fmt format specifiers, custom format() methods on structs, bufPrint, and compile-time format string validation. I gave you three exercises: a Money formatter, a TablePrinter, and a hexDump function. Let's look at those solutions first, and then we're building something real today.
This is a mini project episode. We're taking everything we've learned across the last 24 episodes -- networking from episode 21, string handling from episode 5, error handling from episode 4, structs from episode 6, formatting from episode 24 -- and combining them into one useful CLI tool: an HTTP status checker. You give it a list of URLs, it checks each one and tells you if it's alive or dead, with colored output and concurrency. This is the kind of tool you'd actually use in the real world (I have a similar thing in Python that I run against my own server list regularly). Here we go!
Solutions to Episode 24 Exercises
Exercise 1 -- Money struct with custom formatting:
const std = @import("std");
const Money = struct {
cents: i64,
currency: []const u8,
pub fn format(
self: Money,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
const abs_cents = if (self.cents < 0) @as(u64, @intCast(-self.cents)) else @as(u64, @intCast(self.cents));
const dollars = abs_cents / 100;
const remainder = abs_cents % 100;
if (self.cents < 0) try writer.writeAll("-");
if (std.mem.eql(u8, self.currency, "USD")) {
try writer.print("${d}.{d:0>2}", .{ dollars, remainder });
} else if (std.mem.eql(u8, self.currency, "EUR")) {
try writer.print("E{d}.{d:0>2}", .{ dollars, remainder });
} else {
try writer.print("{d}.{d:0>2} {s}", .{ dollars, remainder, self.currency });
}
}
};
pub fn main() !void {
const items = [_]Money{
.{ .cents = 1234, .currency = "USD" },
.{ .cents = -500, .currency = "USD" },
.{ .cents = 9999, .currency = "EUR" },
.{ .cents = 4200, .currency = "GBP" },
};
for (items) |m| {
std.debug.print("{any}\n", .{m});
}
// Also verify bufPrint works
var buf: [64]u8 = undefined;
const result = try std.fmt.bufPrint(&buf, "Total: {any}", .{items[0]});
std.debug.print("bufPrint: '{s}'\n", .{result});
}
The key trick is converting cents to absolute value first, handling the sign separately. That way you get -$5.00 instead of $-5.00 or worse. The {d:0>2} for the remainder guarantees two digits after the decimal point -- so 5 cents prints as .05, not .5.
Exercise 2 -- TablePrinter:
const std = @import("std");
const TablePrinter = struct {
headers: []const []const u8,
widths: []const usize,
fn printHeader(self: TablePrinter, writer: anytype) !void {
for (self.headers, self.widths) |header, width| {
try writer.print("{s:<[1]}", .{ header, width });
try writer.writeAll(" ");
}
try writer.writeAll("\n");
// Separator line
for (self.widths) |width| {
for (0..width) |_| try writer.writeByte('-');
try writer.writeAll(" ");
}
try writer.writeAll("\n");
}
fn printRow(self: TablePrinter, writer: anytype, values: []const []const u8) !void {
for (values, self.widths) |val, width| {
try writer.print("{s:<[1]}", .{ val, width });
try writer.writeAll(" ");
}
try writer.writeAll("\n");
}
};
pub fn main() !void {
const stderr = std.io.getStdErr().writer();
const headers = [_][]const u8{ "Name", "Score", "Grade" };
const widths = [_]usize{ 15, 8, 6 };
const table = TablePrinter{
.headers = &headers,
.widths = &widths,
};
try table.printHeader(stderr);
try table.printRow(stderr, &.{ "Alice", "94", "A" });
try table.printRow(stderr, &.{ "Bob", "82", "B+" });
try table.printRow(stderr, &.{ "Carol", "71", "C" });
try table.printRow(stderr, &.{ "Dave", "97", "A+" });
}
The interesting bit is {s:<[1]} -- the [1] tells the formatter to take the width from the second argument in the tuple. So the width is dynamic, pulled from self.widths at runtime. This is the same dynamic-width feature that Python's str.format supports with {:<{width}}.
Exercise 3 -- Classic hex dump:
const std = @import("std");
fn hexDump(data: []const u8, writer: anytype) !void {
var offset: usize = 0;
while (offset < data.len) {
// Offset
try writer.print("{x:0>8} ", .{offset});
// Hex bytes (two groups of 8)
const line_end = @min(offset + 16, data.len);
for (offset..line_end) |i| {
try writer.print("{x:0>2} ", .{data[i]});
if (i == offset + 7) try writer.writeAll(" ");
}
// Pad if short line
if (line_end - offset < 16) {
const missing = 16 - (line_end - offset);
for (0..missing) |i| {
try writer.writeAll(" ");
if (i + (line_end - offset) == 7) try writer.writeAll(" ");
}
}
// ASCII column
try writer.writeAll(" |");
for (data[offset..line_end]) |byte| {
if (byte >= 0x20 and byte <= 0x7E) {
try writer.writeByte(byte);
} else {
try writer.writeByte('.');
}
}
try writer.writeAll("|\n");
offset = line_end;
}
}
pub fn main() !void {
const stderr = std.io.getStdErr().writer();
const test_data = "Hello, Zig!\x00\x01\x02\xff World";
try hexDump(test_data, stderr);
}
The classic hexdump format: 8-character hex offset on the left, 16 hex bytes in the middle (split into two groups of 8 with an extra space), and printable ASCII on the right with dots for non-printable bytes. This is literally the same format as xxd or hexdump -C -- if you've ever debugged a network protocol or a binary file format, you've stared at output exactly like this.
Alright, solutions done. Time to build something ;-)
The project: what are we building?
We're building httpcheck -- a command-line tool that takes a list of URLs and checks whether each one is alive. For each URL it opens a TCP connection, sends a minimal HTTP/1.1 GET request, reads the response status code, and reports the result with colored terminal output. Green for success (2xx), yellow for redirect (3xx), red for error (4xx/5xx), and a timeout indicator for servers that don't respond.
Why HTTP specifically? Because HTTP is the protocol that glues the entire internet together, and building a minimal HTTP client from raw TCP sockets teaches you more about networking than any library ever could. We already built TCP sockets in episode 21 -- now we layer HTTP on top.
Here's the usage we're targeting:
$ ./httpcheck http://example.com http://httpbin.org/status/404 http://10.255.255.1
[200] http://example.com -- OK (124ms)
[404] http://httpbin.org/status/404 -- Not Found (89ms)
[---] http://10.255.255.1 -- Connection timed out (3000ms)
We'll build this step by step: URL parsing, TCP connection with timeout, HTTP request construction, response parsing, colored output, and then tying it all together.
URL parsing: extracting host, port, and path
Before we can connect to anything, we need to break a URL like http://example.com:8080/some/path into its parts: the scheme (http), the host (example.com), the port (8080), and the path (/some/path). We're only handling plain HTTP here -- HTTPS requires TLS which is a whole different beast.
const std = @import("std");
const UrlParts = struct {
host: []const u8,
port: u16,
path: []const u8,
raw: []const u8,
pub fn format(
self: UrlParts,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
try writer.print("{s} -> {s}:{d}{s}", .{ self.raw, self.host, self.port, self.path });
}
};
const ParseError = error{
InvalidScheme,
MissingHost,
};
fn parseUrl(raw: []const u8) ParseError!UrlParts {
// Strip "http://" prefix
const after_scheme = if (std.mem.startsWith(u8, raw, "http://"))
raw[7..]
else
return ParseError.InvalidScheme;
if (after_scheme.len == 0) return ParseError.MissingHost;
// Split host from path at first '/'
var host_end: usize = after_scheme.len;
var path: []const u8 = "/";
for (after_scheme, 0..) |byte, i| {
if (byte == '/') {
host_end = i;
path = after_scheme[i..];
break;
}
}
const host_port = after_scheme[0..host_end];
// Split host from port at ':'
var host: []const u8 = host_port;
var port: u16 = 80;
for (host_port, 0..) |byte, i| {
if (byte == ':') {
host = host_port[0..i];
port = std.fmt.parseInt(u16, host_port[i + 1 ..], 10) catch 80;
break;
}
}
return UrlParts{
.host = host,
.port = port,
.path = path,
.raw = raw,
};
}
Nothing fancy here -- just byte scanning. We look for http:// at the front, then scan for the first / to separate host from path, then scan for : within the host portion to extract an optional port number. Default port is 80 (standard HTTP). We're not handling HTTPS, query strings, fragments, or URL encoding -- this is a mini project, not a production HTTP library ;-)
Notice the custom format() function on UrlParts. Thanks to what we learned in episode 24, we can debug any parsed URL with a simple std.debug.print("{any}", .{url}) and get a readable breakdown.
TCP connection with timeout
Next up: actually connecting to the server. We did raw TCP in episode 21, but back then we didn't worry about timeouts. In the real world, some servers don't respond (firewalled, down, blackholed) and you can't wait forever. Zig's std.net.tcpConnectToHost does DNS resolution and connection in one call, and we can set socket options for read/write timeouts after connecting.
const std = @import("std");
const net = std.net;
const posix = std.posix;
const ConnectResult = union(enum) {
connected: net.Stream,
failed: []const u8,
};
fn connectWithTimeout(host: []const u8, port: u16, timeout_ms: u32, allocator: std.mem.Allocator) ConnectResult {
// Attempt TCP connection
const stream = net.tcpConnectToHost(allocator, host, port) catch |err| {
return .{ .failed = switch (err) {
error.ConnectionRefused => "Connection refused",
error.NetworkUnreachable => "Network unreachable",
error.ConnectionTimedOut => "Connection timed out",
else => "Connection failed",
} };
};
// Set read timeout on the socket
const timeout = posix.timeval{
.sec = @intCast(timeout_ms / 1000),
.usec = @intCast((timeout_ms % 1000) * 1000),
};
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {};
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch {};
return .{ .connected = stream };
}
We're using a tagged union here (remember episode 6?) for the result -- either we got a connected stream, or we got an error message string. The switch on the connection error gives us a human-readable reason for failure in stead of just a generic error code.
The socket timeout is set via setsockopt with SO_RCVTIMEO and SO_SNDTIMEO. These are POSIX socket options that tell the OS: "if a read or write doesn't complete within this time, fail with a timeout error." This is the same mechanism you'd use in C, Python's socket.settimeout(), or any other language -- it's all the same OS call underneath.
Building the HTTP/1.1 request
HTTP/1.1 requests are just ASCII text over TCP. The minimal format is:
GET /path HTTP/1.1\r\n
Host: hostname\r\n
Connection: close\r\n
\r\n
That's it. Three lines plus a blank line to end the headers. Connection: close tells the server we're not keeping the connection alive -- send us the response and we're done. Let's build this:
fn sendHttpRequest(stream: net.Stream, host: []const u8, path: []const u8) !void {
var buf: [1024]u8 = undefined;
const request = try std.fmt.bufPrint(&buf, "GET {s} HTTP/1.1\r\nHost: {s}\r\nConnection: close\r\n\r\n", .{ path, host });
_ = try stream.write(request);
}
We're using std.fmt.bufPrint to build the request into a stack buffer. No allocation needed -- the buffer is 1024 bytes which is way more than enough for any reasonable URL. The write call sends the raw bytes over the TCP connection. The server reads them, processes the request, and starts sending back a response.
Why Connection: close? Because we don't need HTTP keep-alive. We're checking one URL per connection, getting the status code, and closing. Keep-alive is for when you want to send multiple requests over the same TCP connection (like a browser loading 50 resources from the same server). For our use case, one connection per URL is perfectly fine.
Parsing the HTTP response
The response from the server starts with a status line like HTTP/1.1 200 OK\r\n followed by headers and then the body. We only care about the status code (200, 404, 500, etc.) so we read just enough bytes to parse that first line:
const CheckResult = struct {
status_code: ?u16,
status_text: []const u8,
elapsed_ms: u64,
url: []const u8,
};
fn parseStatusCode(response: []const u8) ?u16 {
// Looking for "HTTP/1.1 NNN" or "HTTP/1.0 NNN"
if (response.len < 12) return null;
// Find first space (after HTTP/1.x)
var i: usize = 0;
while (i < response.len and response[i] != ' ') : (i += 1) {}
i += 1; // skip the space
if (i + 3 > response.len) return null;
return std.fmt.parseInt(u16, response[i .. i + 3], 10) catch null;
}
fn statusText(code: u16) []const u8 {
return switch (code) {
200 => "OK",
201 => "Created",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
else => "Unknown",
};
}
The status code is always at a fixed position in the response: right after HTTP/1.x. We skip forward to the first space, then parse the next three characters as an integer. If anything looks wrong (truncated response, malformed status line), we return null. Defensive parsing -- never assume the server sends well-formed data.
The statusText function maps common HTTP codes to their standard reason phrases. We use a switch with an else catch-all because there are hundreds of HTTP status codes and we only care about the common ones for display purposes.
Colored terminal output
Here's where the tool gets pretty. ANSI escape codes let us color text in the terminal. They're just special byte sequences that terminals interpret as formatting instructions rather than printable text:
const Ansi = struct {
const reset = "\x1b[0m";
const green = "\x1b[32m";
const yellow = "\x1b[33m";
const red = "\x1b[31m";
const cyan = "\x1b[36m";
const dim = "\x1b[2m";
const bold = "\x1b[1m";
};
fn colorForStatus(code: ?u16) []const u8 {
const c = code orelse return Ansi.dim;
return if (c >= 200 and c < 300)
Ansi.green
else if (c >= 300 and c < 400)
Ansi.yellow
else
Ansi.red;
}
fn printResult(result: CheckResult, writer: anytype) !void {
const color = colorForStatus(result.status_code);
if (result.status_code) |code| {
try writer.print("{s}{s}[{d}]{s} {s:<40} -- {s} {s}({d}ms){s}\n", .{
Ansi.bold,
color,
code,
Ansi.reset,
result.url,
result.status_text,
Ansi.dim,
result.elapsed_ms,
Ansi.reset,
});
} else {
try writer.print("{s}{s}[---]{s} {s:<40} -- {s} {s}({d}ms){s}\n", .{
Ansi.bold,
Ansi.dim,
Ansi.reset,
result.url,
result.status_text,
Ansi.dim,
result.elapsed_ms,
Ansi.reset,
});
}
}
\x1b[32m means "start printing in green". \x1b[0m means "reset to default". Everything between a color code and a reset gets colored. This works on every modern terminal -- macOS Terminal, iTerm2, Linux terminals, Windows Terminal, even old xterm. The only place it doesn't work is piped output (where the escape codes would show as garbage), but for a CLI diagnostic tool that's fine.
We pad the URL to 40 characters with {s:<40} so the status text and timing all line up in columns. A small touch, but it makes the output scannable when you're checking 20 URLs at once.
Checking a single URL end-to-end
Now we put the pieces together. One function that takes a URL string, parses it, connects, sends the request, reads the response, and returns a structured result:
fn checkUrl(raw_url: []const u8, timeout_ms: u32, allocator: std.mem.Allocator) CheckResult {
const timer = std.time.Timer.start() catch {
return .{
.status_code = null,
.status_text = "Timer failed",
.elapsed_ms = 0,
.url = raw_url,
};
};
// Parse URL
const url = parseUrl(raw_url) catch {
return .{
.status_code = null,
.status_text = "Invalid URL",
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
};
// Connect
const conn = connectWithTimeout(url.host, url.port, timeout_ms, allocator);
switch (conn) {
.failed => |reason| {
return .{
.status_code = null,
.status_text = reason,
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
},
.connected => |stream| {
defer stream.close();
// Send HTTP request
sendHttpRequest(stream, url.host, url.path) catch {
return .{
.status_code = null,
.status_text = "Send failed",
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
};
// Read response (just the first chunk is enough for status line)
var response_buf: [4096]u8 = undefined;
const bytes_read = stream.read(&response_buf) catch {
return .{
.status_code = null,
.status_text = "Read timed out",
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
};
if (bytes_read == 0) {
return .{
.status_code = null,
.status_text = "Empty response",
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
}
const code = parseStatusCode(response_buf[0..bytes_read]);
return .{
.status_code = code,
.status_text = if (code) |c| statusText(c) else "Malformed response",
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
},
}
}
Look at how many places this can fail: URL parsing, DNS resolution, TCP connection, sending, reading, response parsing. That's not bad code -- that's reality. Network operations are inherently unreliable. The key design decision here is that every failure path returns a valid CheckResult with a descriptive status text. The function never panics and never returns an error. The caller always gets something it can display to the user.
We use std.time.Timer to measure elapsed time. It uses the OS monotonic clock (not wall clock) so it's not affected by NTP adjustments or clock drift. The timing includes DNS resolution + TCP connection + HTTP round-trip, which is exactly what you want for a "is this server responsive?" check.
Command-line argument parsing
A CLI tool needs to accept arguments. Zig gives you std.process.argsAlloc() which returns the command-line arguments as a slice of strings:
fn parseArgs(allocator: std.mem.Allocator) !struct { urls: []const []const u8, timeout_ms: u32 } {
const args = try std.process.argsAlloc(allocator);
// args[0] is the program name, skip it
if (args.len < 2) {
const stderr = std.io.getStdErr().writer();
try stderr.print("Usage: httpcheck [--timeout MS] URL [URL...]\n", .{});
try stderr.print("\nExample:\n", .{});
try stderr.print(" httpcheck http://example.com http://httpbin.org/status/404\n", .{});
std.process.exit(1);
}
var timeout: u32 = 3000; // default 3 seconds
var url_start: usize = 1;
// Check for --timeout flag
if (args.len >= 3 and std.mem.eql(u8, args[1], "--timeout")) {
timeout = std.fmt.parseInt(u32, args[2], 10) catch 3000;
url_start = 3;
}
return .{
.urls = args[url_start..],
.timeout_ms = timeout,
};
}
We check for an optional --timeout flag. If present, we parse the value as milliseconds. Otherwise default to 3000ms (3 seconds), which is a reasonable timeout for HTTP health checks. The remaining arguments after the flag are treated as URLs.
This is intentionally simple argument parsing. For a more complex tool you might build a proper args parser (and there are community libraries for that), but for a handful of flags, scanning the args array directly is fine. No dependencies, no overhead, easy to understand.
Putting it all together: the main function
Here's the complete main() that ties everything together:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const config = try parseArgs(allocator);
const stdout = std.io.getStdOut().writer();
try stdout.print("\n{s}httpcheck{s} -- checking {d} URL(s) with {d}ms timeout\n\n", .{
Ansi.bold,
Ansi.reset,
config.urls.len,
config.timeout_ms,
});
var ok_count: u32 = 0;
var fail_count: u32 = 0;
for (config.urls) |url| {
const result = checkUrl(url, config.timeout_ms, allocator);
try printResult(result, stdout);
if (result.status_code != null and result.status_code.? < 400) {
ok_count += 1;
} else {
fail_count += 1;
}
}
// Summary
try stdout.print("\n{s}Results: {s}{d} ok{s}, {s}{d} failed{s}\n", .{
Ansi.bold,
Ansi.green,
ok_count,
Ansi.reset,
if (fail_count > 0) Ansi.red else Ansi.dim,
fail_count,
Ansi.reset,
});
}
Clean and straightforward. Parse the arguments, iterate through URLs, check each one, print the result, keep a tally. At the end, print a summary with green for successes and red for failures. The GeneralPurposeAllocator is our standard allocator (we covered allocators in episode 7) -- it tracks all allocations and warns us about leaks when the program exits.
Adding concurrency with threads
Checking URLs sequentially works, but it's slow when you have many URLs. If one server takes 3 seconds to time out, everything after it waits. The fix is concurrency -- check multiple URLs at the same time using OS threads.
Zig's std.Thread API lets us spawn native OS threads. We'll use a simple pattern: spawn one thread per URL (up to a reasonable limit), collect results, then print them:
const ThreadContext = struct {
url: []const u8,
timeout_ms: u32,
allocator: std.mem.Allocator,
result: CheckResult,
};
fn checkUrlThread(ctx: *ThreadContext) void {
ctx.result = checkUrl(ctx.url, ctx.timeout_ms, ctx.allocator);
}
fn checkUrlsConcurrently(
urls: []const []const u8,
timeout_ms: u32,
allocator: std.mem.Allocator,
) ![]CheckResult {
const contexts = try allocator.alloc(ThreadContext, urls.len);
defer allocator.free(contexts);
const threads = try allocator.alloc(std.Thread, urls.len);
defer allocator.free(threads);
// Spawn threads
for (urls, 0..) |url, i| {
contexts[i] = .{
.url = url,
.timeout_ms = timeout_ms,
.allocator = allocator,
.result = undefined,
};
threads[i] = try std.Thread.spawn(.{}, checkUrlThread, .{&contexts[i]});
}
// Join all threads (wait for completion)
for (threads) |thread| {
thread.join();
}
// Collect results
const results = try allocator.alloc(CheckResult, urls.len);
for (contexts, 0..) |ctx, i| {
results[i] = ctx.result;
}
return results;
}
Each thread gets its own ThreadContext that contains the input (URL, timeout) and space for the output (result). The thread function checkUrlThread is dead simple -- it just calls our existing checkUrl function and stores the result. No shared state, no mutexes, no synchronization problems. Each thread works on its own data entirely independently.
After spawning all threads, we join() each one, which blocks until that thread finishes. Once all threads are joined, all results are ready and we can print them. This is a "fork-join" pattern -- fan out the work, wait for everyone to finish, process the results.
For 5-10 URLs this approach is perfectly fine. For hundreds of URLs you'd want a thread pool with a fixed number of worker threads and a queue of jobs, but that's overkill for a diagnostic tool. Keep it simple until the simple approach doesn't work anymore.
The complete program
Let me show you the complete main.zig with everything wired together. I've organized it into logical sections so you can follow the structure:
const std = @import("std");
const net = std.net;
const posix = std.posix;
// -- ANSI color codes --
const Ansi = struct {
const reset = "\x1b[0m";
const green = "\x1b[32m";
const yellow = "\x1b[33m";
const red = "\x1b[31m";
const cyan = "\x1b[36m";
const dim = "\x1b[2m";
const bold = "\x1b[1m";
};
// -- URL parsing --
const UrlParts = struct {
host: []const u8,
port: u16,
path: []const u8,
raw: []const u8,
};
const ParseError = error{ InvalidScheme, MissingHost };
fn parseUrl(raw: []const u8) ParseError!UrlParts {
const after_scheme = if (std.mem.startsWith(u8, raw, "http://"))
raw[7..]
else
return ParseError.InvalidScheme;
if (after_scheme.len == 0) return ParseError.MissingHost;
var host_end: usize = after_scheme.len;
var path: []const u8 = "/";
for (after_scheme, 0..) |byte, i| {
if (byte == '/') {
host_end = i;
path = after_scheme[i..];
break;
}
}
const host_port = after_scheme[0..host_end];
var host: []const u8 = host_port;
var port: u16 = 80;
for (host_port, 0..) |byte, i| {
if (byte == ':') {
host = host_port[0..i];
port = std.fmt.parseInt(u16, host_port[i + 1 ..], 10) catch 80;
break;
}
}
return .{ .host = host, .port = port, .path = path, .raw = raw };
}
// -- HTTP check logic --
const CheckResult = struct {
status_code: ?u16,
status_text: []const u8,
elapsed_ms: u64,
url: []const u8,
};
fn parseStatusCode(response: []const u8) ?u16 {
if (response.len < 12) return null;
var i: usize = 0;
while (i < response.len and response[i] != ' ') : (i += 1) {}
i += 1;
if (i + 3 > response.len) return null;
return std.fmt.parseInt(u16, response[i .. i + 3], 10) catch null;
}
fn statusText(code: u16) []const u8 {
return switch (code) {
200 => "OK",
201 => "Created",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
else => "Unknown",
};
}
fn checkUrl(raw_url: []const u8, timeout_ms: u32, allocator: std.mem.Allocator) CheckResult {
const timer = std.time.Timer.start() catch {
return .{ .status_code = null, .status_text = "Timer failed", .elapsed_ms = 0, .url = raw_url };
};
const url = parseUrl(raw_url) catch {
return .{ .status_code = null, .status_text = "Invalid URL", .elapsed_ms = timer.read() / std.time.ns_per_ms, .url = raw_url };
};
const stream = net.tcpConnectToHost(allocator, url.host, url.port) catch |err| {
const reason: []const u8 = switch (err) {
error.ConnectionRefused => "Connection refused",
error.NetworkUnreachable => "Network unreachable",
error.ConnectionTimedOut => "Connection timed out",
else => "Connection failed",
};
return .{ .status_code = null, .status_text = reason, .elapsed_ms = timer.read() / std.time.ns_per_ms, .url = raw_url };
};
defer stream.close();
// Set read/write timeout
const timeout = posix.timeval{
.sec = @intCast(timeout_ms / 1000),
.usec = @intCast((timeout_ms % 1000) * 1000),
};
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {};
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch {};
// Send request
var req_buf: [1024]u8 = undefined;
const request = std.fmt.bufPrint(&req_buf, "GET {s} HTTP/1.1\r\nHost: {s}\r\nConnection: close\r\n\r\n", .{ url.path, url.host }) catch {
return .{ .status_code = null, .status_text = "Request too long", .elapsed_ms = timer.read() / std.time.ns_per_ms, .url = raw_url };
};
_ = stream.write(request) catch {
return .{ .status_code = null, .status_text = "Send failed", .elapsed_ms = timer.read() / std.time.ns_per_ms, .url = raw_url };
};
// Read response
var response_buf: [4096]u8 = undefined;
const bytes_read = stream.read(&response_buf) catch {
return .{ .status_code = null, .status_text = "Read timed out", .elapsed_ms = timer.read() / std.time.ns_per_ms, .url = raw_url };
};
if (bytes_read == 0) {
return .{ .status_code = null, .status_text = "Empty response", .elapsed_ms = timer.read() / std.time.ns_per_ms, .url = raw_url };
}
const code = parseStatusCode(response_buf[0..bytes_read]);
return .{
.status_code = code,
.status_text = if (code) |c| statusText(c) else "Malformed response",
.elapsed_ms = timer.read() / std.time.ns_per_ms,
.url = raw_url,
};
}
// -- Output --
fn colorForStatus(code: ?u16) []const u8 {
const c = code orelse return Ansi.dim;
if (c >= 200 and c < 300) return Ansi.green;
if (c >= 300 and c < 400) return Ansi.yellow;
return Ansi.red;
}
fn printResult(result: CheckResult, writer: anytype) !void {
const color = colorForStatus(result.status_code);
if (result.status_code) |code| {
try writer.print("{s}{s}[{d}]{s} {s:<40} -- {s} {s}({d}ms){s}\n", .{
Ansi.bold, color, code, Ansi.reset,
result.url, result.status_text,
Ansi.dim, result.elapsed_ms, Ansi.reset,
});
} else {
try writer.print("{s}{s}[---]{s} {s:<40} -- {s} {s}({d}ms){s}\n", .{
Ansi.bold, Ansi.dim, Ansi.reset,
result.url, result.status_text,
Ansi.dim, result.elapsed_ms, Ansi.reset,
});
}
}
// -- Main --
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
const stderr = std.io.getStdErr().writer();
try stderr.print("Usage: httpcheck [--timeout MS] URL [URL...]\n\n", .{});
try stderr.print("Example:\n httpcheck http://example.com http://httpbin.org\n", .{});
std.process.exit(1);
}
var timeout: u32 = 3000;
var url_start: usize = 1;
if (args.len >= 3 and std.mem.eql(u8, args[1], "--timeout")) {
timeout = std.fmt.parseInt(u32, args[2], 10) catch 3000;
url_start = 3;
}
const urls = args[url_start..];
const stdout = std.io.getStdOut().writer();
try stdout.print("\n{s}httpcheck{s} -- checking {d} URL(s), timeout {d}ms\n\n", .{
Ansi.bold, Ansi.reset, urls.len, timeout,
});
var ok_count: u32 = 0;
var fail_count: u32 = 0;
for (urls) |url| {
const result = checkUrl(url, timeout, allocator);
try printResult(result, stdout);
if (result.status_code != null and result.status_code.? < 400) {
ok_count += 1;
} else {
fail_count += 1;
}
}
try stdout.print("\n{s}Results: {s}{d} ok{s}, {s}{d} failed{s}\n", .{
Ansi.bold,
Ansi.green, ok_count, Ansi.reset,
if (fail_count > 0) Ansi.red else Ansi.dim, fail_count, Ansi.reset,
});
}
That's the complete working program. Around 180 lines of Zig that does URL parsing, TCP connection, HTTP request/response, status code parsing, colored output, and argument handling. No external dependencies. No curl binding. No HTTP library. Just the standard library and some byte scanning.
The build file
To compile this into a proper binary, we need a build.zig:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "httpcheck",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the HTTP checker");
run_step.dependOn(&run_cmd.step);
}
If you remember our build system episode (episode 15), this is a standard executable build with a run step. Compile with zig build and run with zig build run -- http://example.com http://httpbin.org/status/404. The -- separates build system arguments from program arguments.
What we built and why it matters
This mini project touched pretty much every major Zig concept we've covered so far:
- Structs (ep6) for
UrlParts,CheckResult,Ansi,ThreadContext - Tagged unions (ep6) for
ConnectResult - Error handling (ep4) with
catchblocks returning early on every failure path - String/slice operations (ep5) for URL parsing, response scanning
- Memory management (ep7) with
GeneralPurposeAllocatoranddefer - Formatting (ep24) with
bufPrint, format specifiers, customformat()on UrlParts - Networking (ep21) with
tcpConnectToHostand raw socket I/O - Build system (ep15) for compiling into a proper binary
The point of a mini project is to show that these concepts don't exist in isolation -- they compose. A real program isn't "just error handling" or "just networking". It's all of them, working together, in a way that feels natural once you've internalized each piece individually. If you could follow this code without getting lost, you're further along than you might think.
The tool is also genuinely useful. I have a similar script in Python (using requests) that I run against a list of servers every morning. The Zig version does the same thing but compiles to a single static binary with zero dependencies. Copy it to any Linux box and it runs. No Python, no pip, no virtualenv, no Docker. Just one file.
Possible extensions
If you want to keep building on this project, here are some ideas:
HTTPS support: Zig's standard library includes
std.crypto.tlsfor TLS connections. Adding HTTPS means upgrading the TCP stream to a TLS stream before sending the HTTP request. The HTTP protocol itself stays identical -- TLS is transparent at the transport layer.Concurrent checking with a thread pool: Our current version checks URLs sequentially. Spawning a thread per URL (as we showed in the concurrency section) would make it much faster when checking many targets. You could take the
std.Thread.spawnapproach and join all threads before printing results.JSON output mode: Add a
--jsonflag that outputs results as a JSON array instead of colored text. We covered JSON in episode 20 -- you could usestd.json.stringifyAllocor build the JSON manually withbufPrint.Config file: Read URLs from a file in stead of command-line arguments. File I/O from episode 10 plus our
LineIteratorfrom episode 23 would make this straightforward.Follow redirects: When you get a 301/302 response, parse the
Locationheader and check the redirect target. That's just more byte scanning on the response headers.
Each of these extensions exercises something we've already covered. The foundation is solid -- building on it is just more of the same patterns applied to new problems.
Dus, wat hebben we nou geleerd?
- URL parsing is just byte scanning -- find the scheme, the host, the optional port, and the path. No external library needed.
- TCP connection with timeout uses
tcpConnectToHostfor the connection andsetsockopt(SO_RCVTIMEO)for the read timeout. Standard POSIX socket options. - HTTP/1.1 requests are ASCII text:
GET /path HTTP/1.1\r\nHost: hostname\r\nConnection: close\r\n\r\n. That's literally all you need for a basic GET. - Status code parsing means scanning the response for the three digits after the first space. Defensive parsing returns
nullfor anything malformed. - ANSI escape codes give you colored terminal output:
\x1b[32mfor green,\x1b[31mfor red,\x1b[0mto reset. Works everwhere. - Thread-based concurrency with
std.Thread.spawnlets you check multiple URLs in parallel. One thread per URL, collect results after joining. - The mini project pattern: real programs combine structs, error handling, string processing, networking, formatting, and memory management all in one place. If you can follow this code, you can build real tools in Zig.
This was our second mini project (the first was the step sequencer in episode 11), and it's a good checkpoint. We've covered Zig's core language features, its standard library data structures, networking, formatting, and now built a practical tool with them. Going forward we'll start exploring lower-level territory -- custom allocators, C interop, inline assembly, and more system-level programming. The groundwork is laid ;-)
De groeten!