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
sleepthat parks the thread and asend()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 asyncreqwestlet 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
500as a failure.200is healthy;503is not. Branch onresp.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.