Skip to content

Probe URLs on a cron schedule

Every five minutes, probe a set of target URLs, classify each as healthy or not, store the result, and serve the last recorded status of any target over HTTP.

src/status.bynk
commons status
---
The KV key for a target's status. Namespacing keeps these entries separate from
anything else stored in the same namespace.
---
fn statusKey(name: String) -> String {
"status:\(name)"
}
---
Is an HTTP status code healthy? A `2xx`/`3xx` code is; `code == 0` is the
convention for "the request never completed" (a network error), which is not.
This is the pure health policy of the monitor — `Int` in, `Bool` out — so it
lives in `commons` and is unit-tested directly (see `tests/status.bynk`), while
the platform `Response`/`FetchError` types and the stored `Status` record stay in
the context that produces them.
---
fn isHealthy(code: Int) -> Bool {
code >= 200 && code < 400
}
src/monitor.bynk
context monitor
uses status
consumes bynk { Fetch, Logger }
consumes bynk.cloudflare { Kv } -- locks this unit to Cloudflare
---
The last observed status of a monitored target. Stored as JSON in KV and read
back by the status endpoint. `code` is `0` when the request never completed.
The record stays in the context (records carry a context identity, so they are
defined where they are stored/served); the pure health policy that fills `ok`
is `isHealthy` in `commons status`.
---
type Status = {
name: String,
ok: Bool,
code: Int,
at: Int,
}
---
Every five minutes, probe each target and record its status. `at` is the
schedule-aligned instant in epoch-ms — cron has no ambient clock, so the time is
handed in.
The fetch/store steps are written inline per target: capabilities (`given`) live
on handlers, not on free functions, and an external type like `Request` can only
be constructed where it is used — so the effectful work stays in the handler. The
platform-typed `match` (over `Result[Response, FetchError]`) reduces each outcome
to an HTTP code; `isHealthy` (from `commons status`) classifies it.
---
service checks from cron {
on schedule("*/5 * * * *") (at: Int) -> Effect[Result[(), String]] given Fetch, Kv, Logger {
let exampleRes <- Fetch.send(Request {
method: Get, url: "https://example.com",
contentType: None, authorization: None, body: None,
})
let exampleCode = match exampleRes {
Ok(r) => r.status
Err(_) => 0
}
let example = Status { name: "example", ok: isHealthy(exampleCode), code: exampleCode, at: at }
let _ <- Kv.put(statusKey("example"), Json.encode(example))
let _ <- Logger.info("checked example: \(example.code)")
let cfRes <- Fetch.send(Request {
method: Get, url: "https://www.cloudflare.com",
contentType: None, authorization: None, body: None,
})
let cfCode = match cfRes {
Ok(r) => r.status
Err(_) => 0
}
let cf = Status { name: "cloudflare", ok: isHealthy(cfCode), code: cfCode, at: at }
let _ <- Kv.put(statusKey("cloudflare"), Json.encode(cf))
let _ <- Logger.info("checked cloudflare: \(cf.code)")
Ok(())
}
}
---
Read the last recorded status for a target by name.
---
service api from http {
on GET("/status/:name") by Visitor (name: String) -> Effect[HttpResult[Status]] given Kv {
let stored <- Kv.get(statusKey(name))
match stored {
None => NotFound
Some(s) => match Json.decode[Status](s) {
Ok(st) => Ok(st)
Err(_) => ServerError("stored status is corrupt")
}
}
}
}
Open the full project ↗

This example reaches Workers-only shapes (storage bindings, agents, or cron), so it runs with bynk dev rather than in the browser playground. See Install to get started.

context monitor consumes bynk { Fetch, Logger } and bynk.cloudflare { Kv }. The pure parts live in commons status: statusKey(name) namespaces a KV key as status:…, and isHealthy(code) is the health policy — Int in, Bool out, true for 2xx/3xx, false for 4xx/5xx and for code == 0 (the convention for a request that never completed). Both are unit-tested without Fetch or Kv.

The cron entry point is service checks from cron { on schedule("*/5 * * * *") (at: Int) … }. Cron has no ambient clock, so the schedule-aligned instant arrives as the at parameter (epoch-ms). For each target the handler builds a Request { method: Get, … } and calls Fetch.send, which returns a Result[Response, FetchError] — a network failure is a value, not an exception. A match reduces each outcome to an HTTP code (0 on Err), isHealthy classifies it, and the resulting Status record is written with Kv.put and Json.encode, with a Logger.info line per check. The fetch/store steps are written inline per target because capabilities live on handlers, not free functions, and a Request can only be built where it is used.

The read side is a separate HTTP service: GET /status/:name reads the stored JSON back through Json.decode[Status], returning NotFound for a missing key and ServerError for a corrupt value.