Functional Weave
Code in Python

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.

def 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 = Literal["timeout", "dns", "connection", "tls", "protocol"]

@dataclass(frozen=True)
class HttpProbe:
    """What one HTTP request came back with: a response, or the error that prevented one."""

    #: the response status, 100 to 599; null when no response arrived
    status_code: Optional[int]
    #: time to the full response in milliseconds, 0 or more; null when not measured
    latency_ms: Optional[int]
    #: the response body, or the part of it the prober kept
    body: Optional[str]
    #: why no usable response arrived; wins over everything else
    error: Optional[ProbeError]

@dataclass(frozen=True)
class HttpExpectation:
    """What counts as a healthy response."""

    #: accepted status codes; null accepts any 2xx (the Prometheus blackbox_exporter default)
    statuses: Optional[List[int]]
    #: text the body must contain, case-sensitive
    body_contains: Optional[str]
    #: at or above this the target is degraded, at least 1
    degraded_latency_ms: Optional[int]
    #: at or above this the target is down, at least degradedLatencyMs
    down_latency_ms: Optional[int]

CheckReason = Literal["ok", "slow", "too-slow", "unexpected-status", "body-mismatch", "timeout", "dns", "connection", "tls", "protocol"]

@dataclass(frozen=True)
class CheckOutcome:
    """The status of one probe and why, ready to store as a Check and show in an alert."""

    status: CheckStatus
    reason: CheckReason
    #: short fixed English, e.g. "HTTP 503, expected 2xx"
    message: str

Your code names it in one line, in the file that uses it

from fune.monitor.http_check_result import classify_http_check  # monitor.http-check-result@^1
impl/python.py · 72 lines · open · raw
from typing import Optional, Sequence

from .monitor_http_check_result_types import CheckOutcome, HttpExpectation, HttpProbe

_ERROR_MESSAGES = {
    "timeout": "timed out",
    "dns": "DNS lookup failed",
    "connection": "connection failed",
    "tls": "TLS handshake failed",
    "protocol": "protocol error",
}


def _whole(v: object) -> bool:
    return isinstance(v, int) and not isinstance(v, bool)


def _expected_text(statuses: Optional[Sequence[int]]) -> str:
    if statuses is None:
        return "2xx"
    if len(statuses) == 1:
        return str(statuses[0])
    return "%s or %d" % (", ".join(str(s) for s in statuses[:-1]), statuses[-1])


def _threshold(name: str, v: Optional[int]) -> None:
    if v is not None and (not _whole(v) or v < 1):
        raise ValueError("%s must be a whole number 1 or more, received %r" % (name, v))


def classify_http_check(probe: HttpProbe, expect: HttpExpectation) -> CheckOutcome:
    """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."""
    status_code, latency_ms, body, error = probe.status_code, probe.latency_ms, probe.body, probe.error
    if status_code is not None and (not _whole(status_code) or status_code < 100 or status_code > 599):
        raise ValueError("statusCode must be 100 to 599, received %r" % (status_code,))
    if error is not None and error not in _ERROR_MESSAGES:
        raise ValueError("unknown probe error: %s" % (error,))
    if status_code is None and error is None:
        raise ValueError("probe must have a statusCode or an error")
    if latency_ms is not None and (not _whole(latency_ms) or latency_ms < 0):
        raise ValueError("latencyMs must be a whole number 0 or more, received %r" % (latency_ms,))
    statuses, body_contains = expect.statuses, expect.body_contains
    degraded, down = expect.degraded_latency_ms, expect.down_latency_ms
    if statuses is not None:
        if len(statuses) == 0:
            raise ValueError("statuses must not be empty; use null to accept any 2xx")
        for s in statuses:
            if not _whole(s) or s < 100 or s > 599:
                raise ValueError("expected statuses must be 100 to 599, received %r" % (s,))
    _threshold("degradedLatencyMs", degraded)
    _threshold("downLatencyMs", down)
    if degraded is not None and down is not None and down < degraded:
        raise ValueError("downLatencyMs must not be below degradedLatencyMs: %d < %d" % (down, degraded))

    if error is not None:
        return CheckOutcome(status="down", reason=error, message=_ERROR_MESSAGES[error])
    assert status_code is not None
    accepted = 200 <= status_code <= 299 if statuses is None else status_code in statuses
    if not accepted:
        return CheckOutcome(status="down", reason="unexpected-status", message="HTTP %d, expected %s" % (status_code, _expected_text(statuses)))
    if body_contains is not None and (body is None or body_contains not in body):
        return CheckOutcome(status="down", reason="body-mismatch", message='body does not contain "%s"' % (body_contains,))
    if latency_ms is not None:
        if down is not None and latency_ms >= down:
            return CheckOutcome(status="down", reason="too-slow", message="latency %d ms at or above %d ms" % (latency_ms, down))
        if degraded is not None and latency_ms >= degraded:
            return CheckOutcome(status="degraded", reason="slow", message="latency %d ms at or above %d ms" % (latency_ms, degraded))
    message = "HTTP %d" % status_code if latency_ms is None else "HTTP %d in %d ms" % (status_code, latency_ms)
    return CheckOutcome(status="up", reason="ok", message=message)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python 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. Or pin a range in fune.project and build in one step:

fune add monitor.http-check-result
Download for Python monitor.http-check-result-1.0.0-python.fune · 24,816 bytes sha256 3ff0edc00ff1a04461b3e2b2dec7d71819d944159a5814b8267e257bafa60f2f

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./monitor.http-check-result-1.0.0-python.fune, or fetch it from a terminal with fune pull monitor.http-check-result@1.0.0:python.

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