monitor.http-check-result
Classify one HTTP probe as up, degraded or down, with a reason and message, from status, body and latency.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 39 tests, run in TypeScript, Python and Rust.
What it does
Turns what one HTTP probe came back with into a `CheckOutcome`: a `CheckStatus` (`up`, `degraded`, `down`, from `monitor.check-status`), a machine-readable `reason`, and a short English `message` for the alert. Store `{ at, status }` as a `Check` and the rest of `monitor.*` (uptime, incidents, status pages) works from it.
The prober itself (making the request, timing it) is the caller's I/O; this is the pure decision afterwards, so every language classifies a probe the same way.
For example
classify_http_check(status code 200, latency ms 123, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms —)→ status up, reason ok, message HTTP 200 in 123 ms a 200 with no conditions is upclassify_http_check(status code 204, latency ms —, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms —)→ status up, reason ok, message HTTP 204 a 204 without a latency measurement is upclassify_http_check(status code 503, latency ms 40, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms —)→ status down, reason unexpected-status, message HTTP 503, expected 2xx a 503 is down against the default any-2xx
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn classify_http_check(probe: &HttpProbe, expect: &HttpExpectation) -> CheckOutcome
| probe | HttpProbe | what one request to the target came back with |
| expect | HttpExpectation | what counts as healthy |
| returns | CheckOutcome |
The types it declares, generated into your project
// ProbeError is a string in Rust, one of: "timeout", "dns", "connection", "tls", "protocol".
// Parameters take it as &str and results hold it as String.
/// What one HTTP request came back with: a response, or the error that prevented one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpProbe {
/// the response status, 100 to 599; null when no response arrived
pub status_code: Option<i64>,
/// time to the full response in milliseconds, 0 or more; null when not measured
pub latency_ms: Option<i64>,
/// the response body, or the part of it the prober kept
pub body: Option<String>,
/// why no usable response arrived; wins over everything else
pub error: Option<String>,
}
/// What counts as a healthy response.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpExpectation {
/// accepted status codes; null accepts any 2xx (the Prometheus blackbox_exporter default)
pub statuses: Option<Vec<i64>>,
/// text the body must contain, case-sensitive
pub body_contains: Option<String>,
/// at or above this the target is degraded, at least 1
pub degraded_latency_ms: Option<i64>,
/// at or above this the target is down, at least degradedLatencyMs
pub down_latency_ms: Option<i64>,
}
// CheckReason is a string in Rust, one of: "ok", "slow", "too-slow", "unexpected-status", "body-mismatch", "timeout", "dns", "connection", "tls", "protocol".
// Parameters take it as &str and results hold it as String.
/// The status of one probe and why, ready to store as a Check and show in an alert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckOutcome {
pub status: String,
pub reason: String,
/// short fixed English, e.g. "HTTP 503, expected 2xx"
pub message: String,
}
Your code names it in one line, in the file that uses it
fune!(monitor.http-check-result@^1); // then call classify_http_check(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
fn error_message(error: &str) -> &'static str {
match error {
"timeout" => "timed out",
"dns" => "DNS lookup failed",
"connection" => "connection failed",
"tls" => "TLS handshake failed",
"protocol" => "protocol error",
other => panic!("unknown probe error: {}", other),
}
}
fn expected_text(statuses: Option<&[i64]>) -> String {
match statuses {
None => "2xx".to_string(),
Some([one]) => one.to_string(),
Some(list) => {
let head: Vec<String> = list[..list.len() - 1].iter().map(|s| s.to_string()).collect();
format!("{} or {}", head.join(", "), list[list.len() - 1])
}
}
}
fn threshold(name: &str, v: Option<i64>) {
if let Some(t) = v {
if t < 1 {
panic!("{} must be a whole number 1 or more, received {}", name, t);
}
}
}
fn outcome(status: &str, reason: &str, message: String) -> CheckOutcome {
CheckOutcome { status: status.to_string(), reason: reason.to_string(), message }
}
/// One probe, one verdict, checked in a fixed order so the reason is the most
/// fundamental thing wrong: a transport error, then the status code, then the
/// body, then latency. A 503 that also took ten seconds is reported as the
/// 503, because that is what someone has to fix.
///
/// # Panics
/// Panics on a probe with neither status nor error, out-of-range numbers, or
/// inconsistent thresholds.
pub fn classify_http_check(probe: &HttpProbe, expect: &HttpExpectation) -> CheckOutcome {
if let Some(code) = probe.status_code {
if !(100..=599).contains(&code) {
panic!("statusCode must be 100 to 599, received {}", code);
}
}
let error_text = probe.error.as_deref().map(error_message);
if probe.status_code.is_none() && probe.error.is_none() {
panic!("probe must have a statusCode or an error");
}
if let Some(l) = probe.latency_ms {
if l < 0 {
panic!("latencyMs must be a whole number 0 or more, received {}", l);
}
}
if let Some(list) = &expect.statuses {
if list.is_empty() {
panic!("statuses must not be empty; use null to accept any 2xx");
}
for s in list {
if !(100..=599).contains(s) {
panic!("expected statuses must be 100 to 599, received {}", s);
}
}
}
let (degraded, down) = (expect.degraded_latency_ms, expect.down_latency_ms);
threshold("degradedLatencyMs", degraded);
threshold("downLatencyMs", down);
if let (Some(dg), Some(dn)) = (degraded, down) {
if dn < dg {
panic!("downLatencyMs must not be below degradedLatencyMs: {} < {}", dn, dg);
}
}
if let (Some(error), Some(text)) = (&probe.error, error_text) {
return outcome("down", error, text.to_string());
}
let code = probe.status_code.unwrap();
let statuses = expect.statuses.as_deref();
let accepted = match statuses {
None => (200..=299).contains(&code),
Some(list) => list.contains(&code),
};
if !accepted {
return outcome("down", "unexpected-status", format!("HTTP {}, expected {}", code, expected_text(statuses)));
}
if let Some(needle) = &expect.body_contains {
let found = probe.body.as_deref().map_or(false, |b| b.contains(needle.as_str()));
if !found {
return outcome("down", "body-mismatch", format!("body does not contain \"{}\"", needle));
}
}
if let Some(l) = probe.latency_ms {
if let Some(dn) = down {
if l >= dn {
return outcome("down", "too-slow", format!("latency {} ms at or above {} ms", l, dn));
}
}
if let Some(dg) = degraded {
if l >= dg {
return outcome("degraded", "slow", format!("latency {} ms at or above {} ms", l, dg));
}
}
}
let message = match probe.latency_ms {
None => format!("HTTP {}", code),
Some(l) => format!("HTTP {} in {} ms", code, l),
};
outcome("up", "ok", message)
}
/// An optional int field, refusing a fractional number with the same text the
/// other languages use for it.
fn opt_int(v: &Value, fractional: &dyn Fn(f64) -> String) -> Option<i64> {
match v {
Value::Null => None,
Value::Int(i) => Some(*i),
Value::Float(f) => panic!("{}", fractional(*f)),
_ => panic!("{}", fractional(f64::NAN)),
}
}
fn opt_str(v: &Value) -> Option<String> {
if v.is_null() { None } else { Some(v.as_str().to_string()) }
}
pub fn http_probe_from_value(v: &Value) -> HttpProbe {
HttpProbe {
status_code: opt_int(&v.get("statusCode"), &|f| format!("statusCode must be 100 to 599, received {}", f)),
latency_ms: opt_int(&v.get("latencyMs"), &|f| format!("latencyMs must be a whole number 0 or more, received {}", f)),
body: opt_str(&v.get("body")),
error: opt_str(&v.get("error")),
}
}
pub fn http_expectation_from_value(v: &Value) -> HttpExpectation {
let statuses = v.get("statuses");
HttpExpectation {
statuses: if statuses.is_null() {
None
} else {
Some(
statuses
.as_arr()
.iter()
.map(|s| opt_int(s, &|f| format!("expected statuses must be 100 to 599, received {}", f)).unwrap())
.collect(),
)
},
body_contains: opt_str(&v.get("bodyContains")),
degraded_latency_ms: opt_int(&v.get("degradedLatencyMs"), &|f| format!("degradedLatencyMs must be a whole number 1 or more, received {}", f)),
down_latency_ms: opt_int(&v.get("downLatencyMs"), &|f| format!("downLatencyMs must be a whole number 1 or more, received {}", f)),
}
}
pub fn check_outcome_to_value(o: &CheckOutcome) -> Value {
Value::obj(vec![
("status", Value::str(&o.status)),
("reason", Value::str(&o.reason)),
("message", Value::str(&o.message)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
check_outcome_to_value(&classify_http_check(&http_probe_from_value(&args[0]), &http_expectation_from_value(&args[1])))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add monitor.http-check-result
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./monitor.http-check-result-1.0.0-rust.fune, or fetch it from a terminal with fune pull monitor.http-check-result@1.0.0:rust.
The whole function, every language, is one file too: monitor.http-check-result-1.0.0.fune, 35,051 bytes, sha256 86ccd509db6060458338a7b87a7c3b2049f7bbd1758e551bb3411a8dbea3b456. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before monitor.http-check-result
after — your function gets the result and the arguments, and returns the final result.
// fune: after monitor.http-check-result
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace monitor.check-status in monitor.http-check-result
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.http-check-result --steps.
// fune: step monitor.http-check-result after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a 200 with no conditions is up | status code 200, latency ms 123, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status up, reason ok, message HTTP 200 in 123 ms |
| a 204 without a latency measurement is up | status code 204, latency ms —, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status up, reason ok, message HTTP 204 |
| a 503 is down against the default any-2xx | status code 503, latency ms 40, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status down, reason unexpected-status, message HTTP 503, expected 2xx |
| a redirect is not a 2xx: down unless listed | status code 301, latency ms 12, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status down, reason unexpected-status, message HTTP 301, expected 2xx |
| 299 is still a 2xx | status code 299, latency ms 5, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status up, reason ok, message HTTP 299 in 5 ms |
| two accepted codes are listed with or | status code 404, latency ms 30, body —, error —, statuses 200, 204, body contains —, degraded latency ms —, down latency ms — | → | status down, reason unexpected-status, message HTTP 404, expected 200 or 204 |
| three accepted codes are listed with commas and or | status code 503, latency ms 30, body —, error —, statuses 200, 201, 204, body contains —, degraded latency ms —, down latency ms — | → | status down, reason unexpected-status, message HTTP 503, expected 200, 201 or 204 |
| a single accepted code | status code 201, latency ms 30, body —, error —, statuses 200, body contains —, degraded latency ms —, down latency ms — | → | status down, reason unexpected-status, message HTTP 201, expected 200 |
| an explicit list replaces 2xx: a 404 can be the healthy answer | status code 404, latency ms 50, body —, error —, statuses 404, body contains —, degraded latency ms —, down latency ms — | → | status up, reason ok, message HTTP 404 in 50 ms |
| the body must contain the text | status code 200, latency ms 80, body status: fail, error —, statuses —, body contains ok, degraded latency ms —, down latency ms — | → | status down, reason body-mismatch, message body does not contain "ok" |
Show the other 29 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the body match is case-sensitive | status code 200, latency ms 80, body OK, error —, statuses —, body contains ok, degraded latency ms —, down latency ms — | → | status down, reason body-mismatch, message body does not contain "ok" |
| no body at all does not contain the text | status code 200, latency ms 80, body —, error —, statuses —, body contains ok, degraded latency ms —, down latency ms — | → | status down, reason body-mismatch, message body does not contain "ok" |
| a body containing the text is up | status code 200, latency ms 80, body {"status":"ok"}, error —, statuses —, body contains "ok", degraded latency ms —, down latency ms — | → | status up, reason ok, message HTTP 200 in 80 ms |
| latency just under the degraded threshold is up | status code 200, latency ms 999, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 3,000 | → | status up, reason ok, message HTTP 200 in 999 ms |
| latency exactly at the degraded threshold is degraded | status code 200, latency ms 1,000, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 3,000 | → | status degraded, reason slow, message latency 1000 ms at or above 1000 ms |
| slow but not too slow is degraded | status code 200, latency ms 1,500, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 3,000 | → | status degraded, reason slow, message latency 1500 ms at or above 1000 ms |
| latency at the down threshold is down | status code 200, latency ms 3,000, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 3,000 | → | status down, reason too-slow, message latency 3000 ms at or above 3000 ms |
| only a down threshold: one millisecond under is up | status code 200, latency ms 1,999, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms 2,000 | → | status up, reason ok, message HTTP 200 in 1999 ms |
| only a down threshold: at it is down | status code 200, latency ms 2,000, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms 2,000 | → | status down, reason too-slow, message latency 2000 ms at or above 2000 ms |
| a wrong status is reported before the slowness | status code 500, latency ms 5,000, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 3,000 | → | status down, reason unexpected-status, message HTTP 500, expected 2xx |
| a body mismatch is reported before the slowness | status code 200, latency ms 5,000, body maintenance, error —, statuses —, body contains ok, degraded latency ms 1,000, down latency ms 3,000 | → | status down, reason body-mismatch, message body does not contain "ok" |
| equal thresholds skip degraded | status code 200, latency ms 1,000, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 1,000 | → | status down, reason too-slow, message latency 1000 ms at or above 1000 ms |
| a timeout is down | status code —, latency ms 10,000, body —, error timeout, statuses —, body contains —, degraded latency ms 1,000, down latency ms 3,000 | → | status down, reason timeout, message timed out |
| a DNS failure is down | status code —, latency ms —, body —, error dns, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status down, reason dns, message DNS lookup failed |
| a refused connection is down | status code —, latency ms 3, body —, error connection, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status down, reason connection, message connection failed |
| a TLS failure is down | status code —, latency ms 20, body —, error tls, statuses —, body contains —, degraded latency ms —, down latency ms — | → | status down, reason tls, message TLS handshake failed |
| an error wins even over a 200 that arrived before it | status code 200, latency ms 20, body ok, error protocol, statuses —, body contains ok, degraded latency ms —, down latency ms — | → | status down, reason protocol, message protocol error |
| neither a status code nor an error is an error | status code —, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: probe must have a statusCode or an error |
| a status code below 100 is an error | status code 99, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: statusCode must be 100 to 599, received 99 |
| a status code above 599 is an error | status code 600, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: statusCode must be 100 to 599, received 600 |
| a fractional status code is an error | status code 200.5, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: statusCode must be 100 to 599, received 200.5 |
| a negative latency is an error | status code 200, latency ms -1, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: latencyMs must be a whole number 0 or more, received -1 |
| a fractional latency is an error | status code 200, latency ms 1.5, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: latencyMs must be a whole number 0 or more, received 1.5 |
| an unknown error kind is an error | status code —, latency ms —, body —, error refused, statuses —, body contains —, degraded latency ms —, down latency ms — | → | error: unknown probe error: refused |
| a down threshold below the degraded one is an error | status code 200, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms 1,000, down latency ms 500 | → | error: downLatencyMs must not be below degradedLatencyMs: 500 < 1000 |
| a zero degraded threshold is an error | status code 200, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms 0, down latency ms — | → | error: degradedLatencyMs must be a whole number 1 or more, received 0 |
| a zero down threshold is an error | status code 200, latency ms 10, body —, error —, statuses —, body contains —, degraded latency ms —, down latency ms 0 | → | error: downLatencyMs must be a whole number 1 or more, received 0 |
| an empty list of accepted codes is an error | status code 200, latency ms 10, body —, error —, statuses , body contains —, degraded latency ms —, down latency ms — | → | error: statuses must not be empty; use null to accept any 2xx |
| an accepted code outside 100 to 599 is an error | status code 200, latency ms 10, body —, error —, statuses 200, 42, body contains —, degraded latency ms —, down latency ms — | → | error: expected statuses must be 100 to 599, received 42 |
More from the author
## Order of checks
The first thing wrong is the reason, in this order, so the alert names what someone has to fix rather than a symptom of it:
1. `error` set (a transport failure): **down**, reason = the error kind. An error wins even when a status code also arrived (a protocol error after the headers). 2. Status code not accepted: **down**, `unexpected-status`. With `statuses` null any 2xx is accepted, the Prometheus blackbox_exporter default for `valid_status_codes`; a redirect (3xx) is therefore down unless listed, since a health endpoint that redirects is usually a misconfiguration. An explicit list replaces 2xx entirely (`[404]` for a page that should stay gone). 3. `bodyContains` not found: **down**, `body-mismatch`. Case-sensitive substring; a null body never contains anything. (blackbox_exporter's `fail_if_body_not_matches_regexp` is the regex version; this is a plain substring so all three languages agree exactly.) 4. `latencyMs >= downLatencyMs`: **down**, `too-slow`. 5. `latencyMs >= degradedLatencyMs`: **degraded**, `slow`. 6. Otherwise **up**, `ok`.
Latency thresholds are "at or above" and apply only when `latencyMs` is non-null; either threshold may be null to skip that stage, and equal thresholds skip degraded.
## Messages
Fixed English, identical in every language:
| reason | message | | --- | --- | | `ok` | `HTTP 200 in 123 ms`, or `HTTP 200` when latency is null | | `unexpected-status` | `HTTP 503, expected 2xx`; `HTTP 404, expected 200 or 204`; `expected 200, 201 or 204` | | `body-mismatch` | `body does not contain "ok"` | | `slow`, `too-slow` | `latency 1500 ms at or above 1000 ms` (the threshold crossed) | | `timeout` | `timed out` | | `dns` | `DNS lookup failed` | | `connection` | `connection failed` | | `tls` | `TLS handshake failed` | | `protocol` | `protocol error` |
## Errors
- `probe must have a statusCode or an error` - `statusCode must be 100 to 599, received X` - `latencyMs must be a whole number 0 or more, received X` - `unknown probe error: X` - `statuses must not be empty; use null to accept any 2xx` (an empty list would accept nothing and mark every probe down) - `expected statuses must be 100 to 599, received X` - `degradedLatencyMs must be a whole number 1 or more, received X` (and the same for `downLatencyMs`) - `downLatencyMs must not be below degradedLatencyMs: D < G`
## Sources
- Prometheus blackbox_exporter configuration, `http_probe`: `valid_status_codes` "Defaults to 2xx", and `fail_if_body_not_matches_regexp`: https://github.com/prometheus/blackbox_exporter/blob/master/CONFIGURATION.md
Files
| Path | Bytes |
|---|---|
| README.md | 3,140 |
| impl/python.py | 3,857 |
| impl/rust.rs | 6,275 |
| impl/typescript.ts | 3,526 |
| vectors.json | 12,053 |