← All stories

Is the Site Still Up? Teaching Rust to Keep a Finger on the Pulse

Is the Site Still Up? Teaching Rust to Keep a Finger on the Pulse cover

A site monitor is the smallest useful network tool you can build: it pokes a URL on a timer and tells you whether the server is alive and how sluggish it is. But hidden in those few lines is a tour of the most important ideas in Rust — an HTTP client, error handling that survives the night, latency measurement, and a real CLI. We'll grow the monitor from a single GET request into a tool you can leave running in a terminal for a week — all on the simple blocking model, where every call waits for its result right in the thread.

Blocking — one request, straight through

Rust's HTTP client is reqwest, and we take it in blocking mode: enable the blocking feature and the client runs the request right on the current thread, with no futures or runtime. It's the shortest path to a result — call, wait, get the response.

Start with the client and a target:

use reqwest::blocking::Client;

fn main() {
    let client = Client::new();
    let url = "https://knowledge.dev";
    let resp = client.get(url).send();
}

Client::new() builds a reusable client with a connection pool — you create it once and share it, rather than spawning one per request. .get(url) assembles a GET request, and .send() blocks the thread until the server responds and returns a finished Result. No async machinery — just one straight line of control that's easy to read.

Now decode what came back. send() yields a Result, and match forces you to handle both arms:

match resp {
    Ok(resp) => println!("{}", resp.status()),
    Err(e) => println!("ERROR — {e}"),
}

We write resilient code from the start — no unwrap() to blow the monitor up on the first DNS hiccup. On Ok we hold a real response and print the HTTP status; on Err the {e} formatter uses the Display impl of the reqwest error, which already carries the URL and the cause. A failure becomes a log line, not a crash.

The pulse: loop, sleep, measure

A one-shot check is a debugging aid. A monitor checks forever. We name an interval, wrap the request in an infinite loop, pause between rounds with std::thread::sleep, and time each request with Instant:

let interval = Duration::from_secs(5);
loop {
    let start = Instant::now();
    let resp = client.get(url).send();
    match resp {
        Ok(resp) => println!("{}{:.0?}", resp.status(), start.elapsed()),
        Err(e) => println!("ERROR — {e}"),
    }
    sleep(interval);
}

Instant::now() reads the monotonic clock — the one that never jumps backward when the system time is adjusted — so start.elapsed() is always sane; we take the snapshot before send() so the measurement spans the whole round trip. {:.0?} prints a Duration compactly, as something like 120ms. sleep from std::thread blocks the thread for the given span — and for a synchronous monitor that has nothing to do between checks anyway, that's exactly what we want. Now the terminal shows a rolling pulse.

Configuration belongs to the user

For now the URL and interval are hard-coded. Recompiling every time you want to watch a different site is absurd. We hand control to the user through clap with its derive feature, which turns a plain struct into a full argument parser — flags, defaults, validation, and --help text all generated for you:

#[derive(Parser)]
#[command(about = "Simple site monitor")]
pub struct Args {
    /// URL to monitor
    #[arg(short, long, default_value = "https://knowledge.dev")]
    pub url: String,

    /// Polling interval in seconds
    #[arg(short, long, default_value_t = 3)]
    pub interval: u64,
}

#[derive(Parser)] binds the struct to clap, #[command(about = ...)] becomes the one-line description in --help. On a field, short gives -u/-i, long gives --url/--interval, and default_value (a string) and default_value_t (a typed value, here a real u64) make the flags optional. Putting the struct in its own src/args.rs module keeps main.rs about monitoring, not argument parsing.

Wire it into main:

let args = Args::parse();
let interval = Duration::from_secs(args.interval);
println!("Monitoring {} every {}s\n", args.url, args.interval);

Args::parse() reads the process arguments, applies the defaults, and exits with a friendly message if something is malformed. We drop the hard-coded url, reach the site through &args.url, and print a startup banner. The result is a real CLI:

$ cargo run -- --url https://example.com -i 5
Monitoring https://example.com every 5s

200 OK — 132ms

…and --help works without a single extra line.

Where to go next

The tool is honest, but production wants more:

  • Watch many sites at once. This is where the blocking model hits its ceiling: a sleep that parks the thread and a send() that waits for the response are fine for one site, but you can't carry hundreds of targets on threads. The answer is async: the Tokio runtime and an async reqwest let a single thread juggle many waits at once. That's the course's next big step.
  • Timeouts. A hung server can stall the poll for a long time. Client::builder().timeout(...) caps how long any request may wait.
  • Treat 500 as a failure. 200 is healthy; 503 is not. Branch on resp.status().is_success() instead of trusting any response.
  • Alert, don't just print. Wrap a state change (up→down) into a webhook, an email, or a log aggregator. The polling loop is already the perfect place to catch that transition.

Why build this

A site monitor is small enough to finish in an evening and rich enough that, by the end, the HTTP client, Result-based error handling, latency measurement, and clap stop being words you've read and become things you've soldered together yourself. You started with one blocking request and finished with a resilient, configurable service — and you can see every line that got you there.

Practise this in a playground
Minimal Site Monitor in Rust
Open playground →