Functional Weave
Code in Rust

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 up
  • classify_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 up
  • classify_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
probeHttpProbewhat one request to the target came back with
expectHttpExpectationwhat counts as healthy
returnsCheckOutcome

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(…)
impl/rust.rs · 170 lines · open · raw

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
Download for Rust monitor.http-check-result-1.0.0-rust.fune · 27,362 bytes sha256 ebac4f9f4e9f82d06781b0ac08bb7f083f11cf4ef10e1200567350d11dd76f80

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md3,140
impl/python.py3,857
impl/rust.rs6,275
impl/typescript.ts3,526
vectors.json12,053