GUIDE · RUST · FREE

Monitor Rust scheduled tasks: tokio-cron-scheduler and reqwest ping on Ok/Err

In a tokio-cron-scheduler async job closure, call your job function and match the Result: on Ok GET the ping URL; on Err POST the error string to /fail. Spawn with tokio::task::spawn to catch panics via the JoinHandle. Absence detection handles OOM kills and crashed processes. Free, no monitor cap.

The snippets

Create a monitor in the console, set the schedule plus a grace window, and add these crates to Cargo.toml: tokio-cron-scheduler, reqwest, tokio.

tokio-cron-scheduler — match on Ok/Err

// Cargo.toml: // tokio = { version = "1", features = ["full"] } // tokio-cron-scheduler = "0.13" // reqwest = { version = "0.12", features = ["rustls-tls"] } use reqwest::Client; use tokio_cron_scheduler::{Job, JobScheduler}; const PING: &str = "https://ping.lastping.dev/<your-monitor-id>"; async fn ping(client: &Client, suffix: &str, body: Option<String>) { let url = format!("{}{}", PING, suffix); let result = if let Some(b) = body { client.post(&url).body(b).send().await } else { client.get(&url).send().await }; if let Err(e) = result { eprintln!("lastping error: {e}"); } } async fn run_job() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { // your job logic Ok(()) } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let sched = JobScheduler::new().await?; let client = Client::new(); sched.add(Job::new_async("0 0 2 * * *", move |_uuid, _lock| { let c = client.clone(); Box::pin(async move { match run_job().await { Ok(_) => ping(&c, "", None).await, Err(e) => { let msg = e.to_string(); let body = msg[..msg.len().min(10_000)].to_string(); ping(&c, "/fail", Some(body)).await; } } }) })?).await?; sched.start().await?; tokio::signal::ctrl_c().await?; Ok(()) }

Catch panics via JoinHandle

Rust panics in async tasks unwind the task but the scheduler keeps running. Spawning the job body as a separate task and awaiting the JoinHandle lets you detect a panic and send a /fail ping:

sched.add(Job::new_async("0 0 2 * * *", move |_uuid, _lock| { let c = client.clone(); Box::pin(async move { let handle = tokio::task::spawn(async { run_job().await }); match handle.await { Ok(Ok(_)) => ping(&c, "", None).await, Ok(Err(e)) => { let body = e.to_string()[..e.to_string().len().min(10_000)].to_string(); ping(&c, "/fail", Some(body)).await; } Err(join_err) => { // task panicked let body = format!("panic: {:?}", join_err); ping(&c, "/fail", Some(body[..body.len().min(10_000)].to_string())).await; } } }) })?).await?;

Plain tokio interval (no cron syntax)

If you don't need cron syntax, a tokio::time::interval loop is simpler and avoids an extra crate. The same match pattern applies:

use std::time::Duration; use tokio::time; #[tokio::main] async fn main() { let client = Client::new(); let mut interval = time::interval(Duration::from_secs(24 * 3600)); interval.tick().await; // skip the immediate first tick loop { interval.tick().await; match run_job().await { Ok(_) => ping(&client, "", None).await, Err(e) => { let msg = e.to_string(); ping(&client, "/fail", Some(msg[..msg.len().min(10_000)].to_string())).await; } } } }

Why OOM kills still need absence detection

Even Rust can be OOM-killed. The spawned task and the scheduler both vanish with the process; no async code runs. Absence detection is the correct safety net: no ping within schedule+grace → incident. See also the Go monitoring guide for a similar defer-based pattern, and the LastPing MCP server for AI-agent provisioning.


What this catches

The match reports what it can; the silence reports the rest.

  • A returned Err — the /fail ping opens an incident immediately with the error string stored.
  • A panic — caught via JoinHandle::await returning Err(JoinError); the payload is POST'd to /fail.
  • OOM kill or SIGKILL — no async code runs; the missing ping opens the incident after the grace window.
  • The binary never started — missing cron entry, deployment failure. Absence → incident.
  • Recovery — the next successful run closes the incident and sends a recovery notice automatically.

Questions people ask

Front-loaded answers — the most important fact first.

  • What crates do I need?

    tokio-cron-scheduler for scheduling, reqwest for HTTP, and tokio with the full feature set. A plain tokio::time::interval loop removes the scheduler dependency if cron syntax isn't needed.

  • How do I catch panics in async Rust?

    Spawn the job body with tokio::task::spawn and await the returned JoinHandle. If the handle returns Err(JoinError) the task panicked; format the error and POST it to /fail.

  • Can I use a different HTTP client?

    Yes. hyper, ureq (blocking), or the standard library's std::net::TcpStream with a manual HTTP request all work. The ping is a plain GET or POST — no special client required.

  • Is this free?

    Yes. LastPing is free and fully hosted with no monitor cap and no paid tier. See how it compares to Healthchecks.io and Cronitor.

FREE · FULLY HOSTED · NO PAID TIER

Even Rust can be OOM-killed. Absence detection is the safety net.

One match arm now beats debugging a silent batch window later. First alert wired up in about a minute — and LastPing monitors itself the same way.