I originally wrote this script after discovering that curl can independently measure each phase of an HTTP connection. I've since vibe-coded it into something considerably more complete and robust.
A live, per-phase HTTP timing probe, like curl -w on steroids using
pycurl. Each timing field prints the moment it becomes available, so a hung
request visibly stalls at exactly the phase where it's stuck rather than
silently timing out.
Built for checking internal and external endpoints from the outside in,
especially the ones you don't control or can't get shell access to: a
third-party API, a partner's service, a backend hidden behind a load balancer or
CDN. When the endpoint owner insists "everything looks fine on our end" but your
users say otherwise, check-endpoint gives you independent, client-side,
per-phase evidence of exactly where the time goes (or where it breaks), so you
can point at the real problem instead of guessing. Add --assert-status /
--max-* to turn that evidence into a pass/fail check, or --prometheus to
watch it continuously.
This is how it would be done using only curl.

My script provides the same functionality as curl, but because it's built with
pycurl, I have much finer programmatic control over the output and can customize
it in ways that aren't as convenient with the curl command-line interface.

- Live streaming output - each phase prints as it completes, not all at once at the end
- Per-phase deltas - every column is the duration of that phase only, not a cumulative total
- Redirect accounting - a
REDIRECTcolumn shows count and total time when redirects are followed, explaining whyTOTAL_TIMEcan exceed the sum of the other columns - Failure markers -
<DNS-FAIL>,<CONN-FAIL>,<TLS-FAIL>,<TO>, and more - printed at exactly the phase that failed - Clear empty-cell conventions - a dim
n/amarks a phase that structurally doesn't apply (e.g.TLS_HANDSHAKEon plainhttp://, orREDIRECTwhen none occurred); a dim-marks a field that's empty for any other reason - IP pinning - pin repeated requests to one IP to avoid measuring different backends across a DNS round-robin
- Streaming / chunked-transfer testing -
-S/--streamtimes every chunk as it arrives (not just first/last byte) and reportsCHUNKS,AVG_GAP, andMAX_GAPcolumns - the gaps measured are strictly between chunks, not including the first chunk's arrival (that span is already covered by the DNS/TCP/TLS/PRE-TRANSFER/1ST_BYTE columns) - so you can see whether an SSE or chunked response streams smoothly or stalls mid-transfer - Catppuccin Mocha color theme - timing magnitude encoded in color (cool blues for fast, warm peach/red for slow); auto-disabled when output is piped
- curl-compatible flags -
-H,-d,-X,-4/-6,-F,-a,-p/-P,-S - HTTP/2 support -
--http2requests HTTP/2 via ALPN negotiation; aPROTOcolumn (printed last, afterTOTAL_BYTES) shows the protocol actually used (h1,h1.0,h2, orh3); falls back gracefully to HTTP/1.1 - Body and header support - POST payloads, auth headers, custom content types; works against authenticated and stateful endpoints
- Percentile summary -
--statsreports min / p50 / p90 / p95 / p99 / max / mean / stdev per phase across-c Nruns, so you see tail latency and jitter, not just a single sample - Built-in assertions (CI / cron ready) -
--assert-status,--max-total,--max-ttfb,--max-dns/--max-tcp/--max-tls/--max-downloadmake the probe exit non-zero if any request breaches, so it drops straight into pipelines and alerting - Response body validation -
--expect-bodyand--expect-regexfail the run when the body is wrong, not just when the status code is - TLS certificate inspection -
--tls-infoprints the certificate issuer, expiry with days remaining (colored yellow as it nears expiry, red once expired), and Subject Alternative Names - Response header capture -
--show-headersprints selected response headers and a detected cacheHIT/MISSverdict, handy for debugging CDNs and proxies - Per-request provenance -
--server-hintsprints, for every request, the headers that reveal which server, edge, CDN, or backend answered (server,via,x-served-by,x-cache,cf-ray,x-amz-cf-pop,x-backend, and more), then classifies each header as constant, varied, or per-request so you can see at a glance which backend served each of your-c Nrequests - Custom header capture -
--capture-header NAME(repeatable) tracks any specific response header you name and shows its value per request in the same summary; missing values render as- - CDN hop flattening - by default, comma-chained CDN/cache headers
(
x-served-by,x-cache,x-cache-hits,via) are collapsed to just the final hop (the edge that actually served you) with the chain depth noted, making multi-hop Fastly/Varnish output readable; pass--full-cdnto see every hop in the chain - Prometheus exporter mode -
--prometheusruns as a pull-based exporter daemon that re-probes on every scrape; see contrib/check-endpoint-exporter
Some of the cases below are ones I have run into myself. The others are common or fairly obvious issues that are simply worth having written down. Either way this is not an exhaustive list, and each column can point at plenty of things not covered here. If you know of a root cause that belongs under one of these columns, whether it drives up latency, produces intermittent failures, or just makes a column read strangely, please open an issue and I will add it.
Run with -c 10 or -c 20 to surface patterns invisible in a single request.
Sections below follow the left-to-right order of the output columns, so you can read a row of output and jump straight to the section for whichever column looks wrong. Every column has a section; the two at the end are cross-column and are driven by flags rather than by a single field.
| Column | Section |
|---|---|
# |
Run count, warm-up & outliers |
IP_ADDRESS |
Load balancing & round-robin |
DNS |
DNS & resolution |
TCP_CONNECT |
TCP & network |
TLS_HANDSHAKE |
TLS & security |
PRE-TRANSFER |
Client-side & proxy setup |
1ST_BYTE |
Server processing |
REDIRECT |
Redirect chains |
BODY_DL |
Body transfer & server-side IO |
TOTAL_TIME |
End-to-end budget |
HTTP_CODE |
Status codes & flakiness |
TOTAL_BYTES |
Response size & content drift |
PROTO |
HTTP version |
CHUNKS / AVG_GAP / MAX_GAP |
Streaming responses (-S) |
- Warm-up on run 1 - the first request pays for cold DNS, a fresh TCP
handshake, and a full TLS negotiation; runs 2+ reuse all three. Compare run 1
against the rest before concluding anything is slow. If runs 2+ don't drop,
that itself is the finding (see the
DNS,TCP_CONNECTandTLS_HANDSHAKEsections) - Outlier requests - a single request dramatically slower than the rest reveals cold cache misses, JVM garbage collection pauses, or lock contention
- Intermittent timeouts - one or two
<TO>markers among otherwise successful requests indicate connection pool exhaustion, GC pauses, or health check races - How many runs you need -
-c 10is enough to spot round-robin and obvious flakiness;--statsp95/p99 only become meaningful around-c 20and up
- Uneven backends - without
-P, different IPs per request show which backends are in rotation; timing differences per IP identify the slow ones - Isolate one backend - use
-Pto pin all requests to a single IP; then switch IPs to compare them individually - Backend-specific errors - correlate the
IP_ADDRESScolumn withHTTP_CODEto see which backend is misbehaving - Rotation mid-test - the IP changing partway through a
-c Nrun means a DNS TTL expired and the resolver handed back a different member of the pool - One IP is not one server - behind a CDN or an anycast address, every
request hits the same IP while landing on different edge nodes.
IP_ADDRESScannot see that;--server-hintscan - IPv4 vs IPv6 paths differ - run
-4and-6separately against the same host; a large gap points at a misconfigured or unoptimised AAAA path
- Slow or flaky resolvers - high or variable DNS times across runs
- Missing local DNS cache - DNS stays high every request instead of dropping to ~0ms after the first lookup
- Short TTLs - DNS spikes when the record expires mid-test
- libcurl's own cache hides the real cost - runs 2+ normally show ~0ms
because libcurl caches within the process, not because your resolver is fast.
Use
-Fto force a fresh lookup every run and measure the true cost - Deep CNAME chains - a hostname pointing through several CNAMEs before the final A/AAAA record costs extra round-trips, showing as consistently elevated DNS even on a healthy resolver
<DNS-FAIL>- hostname cannot be resolved at all
- Geographic latency - high TCP_CONNECT reveals round-trip time to the server
- Connection backlog - TCP time grows as the server runs out of accept queue capacity under load
- Firewall / filtering -
<CONN-FAIL>on specific ports or from specific network paths - Connection reuse not happening - TCP should collapse to ~0ms from run 2
onwards. If it stays high on every run without
-F, something is closing the connection each time:Connection: close, a proxy, or a load-balancer idle timeout - Packet loss - an occasional TCP time several times the median, with the rest steady, suggests a lost SYN being retransmitted
- A proxy shortens what you're measuring - with a proxy in the path this column is the time to the proxy, not to the origin
- Missing session resumption - TLS time stays high on every repeat request instead of dropping after the first; compare run 1 vs run 2+
- Slow OCSP validation or long cert chains - consistently elevated TLS time even without load
- TLS 1.2 vs 1.3 - 1.3 completes in one round-trip and 1.2 needs two, so a handshake at roughly twice the TCP time suggests the server negotiated 1.2
n/aon anhttp://URL is expected; a value here on anhttp://URL means the request was redirected to HTTPS - check theREDIRECTcolumn- Certificate expiry - pair with
--tls-infofor issuer, SANs, and days remaining before the certificate lapses <TLS-FAIL>- expired cert, hostname mismatch, or untrusted CA
- Non-zero
PRE-TRANSFER- this phase is internal libcurl bookkeeping and is normally ~0ms; consistently high values indicate CPU pressure on the machine running the script - Proxy tunnel setup - when connecting through an HTTP proxy, the
CONNECTexchange lands in this column rather than inTCP_CONNECT - A useful control - because it should be ~0ms on a direct connection, a non-zero value warns that the measurements themselves may be distorted by local load; treat the rest of that row with suspicion
The most diagnostic column in the table.
- Slow backend - high 1ST_BYTE reveals heavy server work: DB queries, auth checks, computation, rendering
- Queue depth behind a reverse proxy - fast TCP but slow 1ST_BYTE means the proxy accepted the connection but the backend was busy
- Backend inconsistency - variable 1ST_BYTE across runs reveals hot/cold cache states, uneven DB load, or connection pool exhaustion
- Classic pattern: high
1ST_BYTE+ fastBODY_DL- server is slow to produce the response but fast to deliver it; the bottleneck is computation or IO server-side, not the network - Slow DB providing response data - consistently high 1ST_BYTE while BODY_DL is fast points directly at backend data retrieval time
- Turn it into a check -
--max-ttfb 300msfails the run when the backend crosses your threshold, the single most useful assertion for CI
- Why
TOTAL_TIMEexceeds the sum of the other columns - every other column describes the final connection only. Redirect round-trips are accounted for here and nowhere else - The cost of an
http://→https://upgrade - hitting the plain-HTTP URL pays for an extra DNS + TCP round-trip before the real request starts. Request thehttps://URL directly and this column drops ton/a - Redirects to a different host - when the redirect crosses hostnames, the
DNS,TCP_CONNECTandTLS_HANDSHAKEcolumns describe the destination, not the URL you asked for, andIP_ADDRESSwill not match the original hostname - Cross-region redirects - a
.comthat redirects to a country-specific domain can add latency invisible in any other column <RDR-FAIL>- a redirect loop, or a chain longer than libcurl will follow
- Slow server IO - high BODY_DL relative to content size (slow disk reads, DB result streaming)
- Bandwidth throttling - BODY_DL scales disproportionately with response size
- Responses are uncompressed by default - the probe does not send
Accept-Encoding, so servers return identity encoding. Add-H "Accept-Encoding: gzip"to measure what a browser actually experiences;BODY_DLandTOTAL_BYTESshould both drop sharply, and if they don't, compression isn't configured, which is the finding - TCP slow-start on large bodies - the first response over a fresh connection transfers more slowly than later ones; compare run 1 against runs 2+ before blaming the server
- The only cumulative column - every other timing column is that phase alone. Use this one for SLOs and user-facing budgets
- When it doesn't add up - if
TOTAL_TIMEis much larger than the sum of the phases, the difference is almost always inREDIRECT - Tail latency, not averages -
--statsreports p50/p90/p95/p99; a healthy p50 alongside a p99 several times higher is the signature of an intermittent problem that averages hide - Turn it into a check -
--max-total 1sexits non-zero when breached, so the probe drops straight into CI or cron
- Mixed response codes - running
-c 20surfaces occasional 502/503 mixed with 200s, revealing backend instability, pods cycling in Kubernetes, or upstream timeouts - Rate limiting under repetition - 429s appearing partway through a
-c 20run mean you found the rate limit, not an outage; slow the probe down before reading anything else into the results - Auth problems - 401/403, or the
<AUTH-FAIL>marker, when testing protected endpoints with-H "Authorization: ..." - A 3xx here means redirects were followed - the code shown is the final
response; check the
REDIRECTcolumn for what happened on the way - Assert on it -
--assert-status 200fails the run on anything else
- Inconsistent content size -
TOTAL_BYTESvaries across-c Nruns, revealing A/B tests, CDN inconsistencies, partial or truncated responses, or outright payload bugs - Suspiciously small 200s - a successful status with a tiny body is often a
soft error page or an empty JSON envelope;
--expect-bodyor--expect-regexturn that into a real failure - Truncated transfers - a byte count well below the rest of the run,
especially alongside
<RECV-FAIL>, means the response was cut short - Compression state - see the
BODY_DLnote above; byte counts are for the encoding actually received
- Verify HTTP/2 is actually active -
--http2with thePROTOcolumn confirms whether the server is servingh2or falling back toh1. Useful to verify CDN or load balancer HTTP/2 configuration - Connection reuse visible in timing - on repeated
-c Nruns with--http2, TCP_CONNECT and TLS_HANDSHAKE drop to<1msfrom run 2 onwards, confirming the persistent connection is being reused, one of HTTP/2's main performance benefits - Detect HTTP/2 connection issues - if
PROTOshowsh1despite--http2, the server or an intermediate proxy is downgrading the connection - Values you may see -
h1(HTTP/1.1),h1.0(HTTP/1.0),h2(HTTP/2),h3(HTTP/3). Anh1.0is worth investigating on its own: it usually means an old proxy in the path, and HTTP/1.0 disables keep-alive by default
Only present with -S/--stream.
Without -S, a streaming response is still measured meaningfully: 1ST_BYTE is
the time until the first chunk/token arrives, and BODY_DL is the total
duration of the whole stream. What's missing without -S is the rhythm of the
stream, whether it arrives steadily or in bursts with stalls.
AVG_GAP and MAX_GAP measure the time strictly between chunks. The first
chunk's arrival is deliberately excluded, since that span is already the DNS +
TCP + TLS + PRE-TRANSFER + 1ST_BYTE columns; counting it again here would
misreport ordinary connection setup as if it were an in-stream stall. With fewer
than 2 chunks there's no inter-chunk gap to measure, so both columns correctly
show n/a rather than a misleading number.
This makes AVG_GAP functionally the same metric LLM serving benchmarks call
Inter-Token Latency (ITL), the average time between successive tokens.
Measuring it over the wire, rather than trusting server-side logs, captures what
the client actually experiences: network jitter, reverse-proxy buffering, and
load-balancer hops are all included, not just model-side generation time.
- Token stutter / uneven generation - a large gap between
AVG_GAPandMAX_GAPmeans the stream paused somewhere in the middle, even thoughBODY_DLandTOTAL_TIMElook fine in aggregate. This is exactly the kind of thing that makes a chat UI feel like it "hangs then dumps text." - Buffering misconfigurations - if a reverse proxy is accidentally buffering
the whole response before forwarding it (a common
nginx proxy_bufferingmisconfiguration),CHUNKScollapses to 1 or 2,AVG_GAP/MAX_GAPshown/a, and1ST_BYTEballoons to roughly equalTOTAL_TIME, so the "stream" isn't actually streaming. - Inconsistency across backend replicas - combine with
IP_ADDRESSto see whether one particular backend produces the stutter (uneven load, resource pressure) while others stream smoothly. - ITL benchmarking without server-side instrumentation - if you don't have
access to your model server's internal metrics (or you're testing someone
else's API),
-c 20 -Sgives you a client-side ITL measurement for free:AVG_GAPis your typical inter-token latency,MAX_GAPis your worst-case, and running multiple requests shows whether ITL is consistent or degrades under concurrent load. - Works with auth and POST bodies -
-Scomposes with-H/-d/-X, so you can test real chat-completion or SSE endpoints directly:-X POST -d '{"stream": true, ...}' -H "Authorization: Bearer ..." -S
These two aren't tied to a single column. They're driven by flags, and read from the response headers or from what you send.
When a single IP hides many backends (a CDN or a reverse proxy in front of a
pool), the IP_ADDRESS column alone cannot tell them apart. --server-hints
reads the response headers that do, one row per request, then rolls each header
up as constant (same every run), varied (a few distinct values, the real
"which backend served it" signal), or per-request (a different value every
run, typically a trace or request id).
- Which edge/PoP served each request -
x-served-by,x-amz-cf-pop, andcf-rayreveal CDN point-of-presence and cache-node rotation across-c Nruns, even though every request hit the same anycast IP - Cache hit ratio over the wire -
x-cache/cf-cache-statusvaryingHITvsMISSacross runs shows how often you are actually served from cache - Which backend pod answered - track your own routing headers with
--capture-header x-backend --capture-header x-pod-name; a header that varies between a handful of values maps directly to the pods in rotation - Confirm a header is present at all - a
--capture-headervalue that shows-on every run tells you the server never sent it - Readable multi-hop chains - Fastly/Varnish chains like
x-served-by = shield-IAD, shield-IAD, edge-PAOcollapse by default to justx-served-by(final) = edge-PAO [3 hops in chain]; add--full-cdnwhen you want the whole chain
- Authenticated APIs - use
-H "Authorization: Bearer token"to test protected endpoints;<AUTH-FAIL>or 401/403 reveals auth configuration problems - POST/PUT/PATCH endpoints - use
-d @payload.json -H "Content-Type: application/json" -X PUTto test write endpoints with real payloads - Token expiry under load - combine auth headers with
-c 20to observe if validation degrades or fails on repeated calls - Header-conditional behavior - send routing or feature-flag headers
(
-H "X-Feature: beta") to test conditional server logic - Content negotiation - the probe sends
Accept: */*unless you override it;-H "Accept: application/json"reveals endpoints that serve HTML to generic clients and JSON to specific ones
- Python 3.9+ - the only hard floor in the code is 3.8 (
statistics.fmean), but 3.8 is end-of-life, so 3.9 or newer is recommended. - pycurl - the only Python dependency; everything else the script uses is in the standard library.
- System libcurl - pycurl links against it (
brew install curlon macOS,apt install libcurl4-openssl-devon Debian/Ubuntu). - libcurl built with nghttp2 - only needed for
--http2. Check withcurl --version | grep -i HTTP2; without it,--http2simply falls back to HTTP/1.1 (the tool prints how to rebuild if you ask for it).
If you package this (for example a pyproject.toml), set
requires-python = ">=3.9" so the badge, these docs, and tooling such as ruff
all agree on the minimum version.
# Recommended: install pycurl in a pyenv virtualenv
pyenv virtualenv 3.12.0 check-endpoint-env
pyenv activate check-endpoint-env
pip install pycurl
# Or install into the system Python directly
pip install pycurl --break-system-packages
# macOS may need: brew install curl
# Linux may need: apt install libcurl4-openssl-dev
chmod +x check-endpoint.py./check-endpoint.py <url>
./check-endpoint.py [options] <url>
# Single request
./check-endpoint.py https://example.com
# 10 requests with a 5-second timeout
./check-endpoint.py -c 10 -t 5 https://example.com
# Force IPv6, use Chrome's User-Agent
./check-endpoint.py -6 -a chrome https://example.com
# Custom auth header
./check-endpoint.py -H "Authorization: Bearer xyz123" https://api.example.com/v1/data
# Multiple headers
./check-endpoint.py -H "X-Trace-Id: 42" -H "Accept: application/json" https://example.com
# POST a JSON body (implies POST automatically)
./check-endpoint.py -d '{"foo":"bar"}' -H "Content-Type: application/json" https://example.com/api
# POST from a file (curl-style @file)
./check-endpoint.py -d @payload.json -H "Content-Type: application/json" https://example.com/api
# Force a specific method
./check-endpoint.py -X PUT https://example.com/api/resource/1
# Force a fresh DNS lookup + new connection on every repeat
./check-endpoint.py -c 10 -F https://example.com
# Pin all repeats to the first resolved IP (avoids round-robin drift)
./check-endpoint.py -c 10 -P https://example.com
# Pin to a specific known IP
./check-endpoint.py -c 10 -p 93.184.216.34 https://example.com
# Test an SSE / chunked-streaming endpoint and see per-chunk cadence
./check-endpoint.py -c 10 -S -H "Accept: text/event-stream" https://example.com/stream
# Stream mode against a real chat-completion endpoint (auth + POST body)
./check-endpoint.py -X POST -d '{"stream": true, "prompt": "hi"}' \
-H "Content-Type: application/json" -H "Authorization: Bearer xyz123" \
-S https://api.example.com/v1/chat
# Percentile summary across 20 runs
./check-endpoint.py -c 20 --stats https://example.com
# CI health check: exit non-zero if not 200, or slower than the thresholds
./check-endpoint.py --assert-status 200 --max-ttfb 300ms --max-total 1s https://example.com/health
# Validate the response body contains a substring, and inspect the TLS certificate
./check-endpoint.py --expect-body '"status":"ok"' --tls-info https://example.com/health
# Validate the body against a regex (Python re syntax, searched anywhere in the body)
./check-endpoint.py --expect-regex '"status"\s*:\s*"(ok|healthy)"' https://example.com/health
# Case-insensitive match using an inline flag at the start of the pattern
./check-endpoint.py --expect-regex '(?i)service is up' https://example.com/status
# Anchor to the whole body (^ and $ are body start/end unless you add (?m))
./check-endpoint.py --expect-regex '^\s*OK\s*$' https://example.com/ping
# Show selected response headers and the cache HIT/MISS verdict
./check-endpoint.py --show-headers https://example.com
# See which server/edge/backend answered each of 10 requests
./check-endpoint.py -c 10 --server-hints https://example.com
# Track specific headers per request (repeatable, case-insensitive)
./check-endpoint.py -c 10 --capture-header x-backend --capture-header x-pod-name https://example.com
# Show the full multi-hop CDN chain (final hop only is the default)
./check-endpoint.py -c 10 --server-hints --full-cdn https://example.com
# Force fresh connections so each run can land on a different backend
./check-endpoint.py -c 10 -F --server-hints https://example.com
# Run as a Prometheus exporter that re-probes on every scrape
./check-endpoint.py --prometheus --prometheus-port 9109 https://example.com| Flag | Description |
|---|---|
-c N / --count N |
Number of requests to perform (default: 1) |
-t N / --timeout N |
Per-request timeout in seconds (default: 10) |
-4 / --ipv4 |
Force IPv4 resolution (default) |
-6 / --ipv6 |
Force IPv6 resolution |
-a ALIAS / --user-agent ALIAS |
Use a baked-in UA string: chrome, firefox, edge, safari, googlebot |
-H 'K: V' / --header |
Custom request header, repeatable |
-d DATA / --data |
Request body (POST); prefix with @ to read from a file |
-X METHOD / --request |
Force an HTTP method (e.g. PUT, DELETE) |
-F / --force-dns |
Disable libcurl's DNS cache and connection reuse |
-P / --auto-pin |
Resolve once, then pin all repeats to that IP |
-p IP / --pin-ip IP |
Pin all repeats to a specific IP address |
-S / --stream |
Time the gaps between chunks as they arrive and report CHUNKS/AVG_GAP/MAX_GAP - for testing SSE or chunked-transfer streaming responses |
--http2 |
Request HTTP/2 via ALPN (HTTPS); falls back to HTTP/1.1 if unsupported |
--http2-prior-knowledge |
Send HTTP/2 over cleartext http:// (h2c); only when the server is known to speak it |
--stats |
Print a percentile summary (min/p50/p90/p95/p99/max/mean/stdev) per phase; needs -c 2 or more |
--assert-status CODE |
Fail (exit 1) if the HTTP status is not CODE |
--max-total DUR |
Fail if TOTAL_TIME exceeds DUR (500ms, 1s, 1.5s) |
--max-ttfb DUR |
Fail if 1ST_BYTE (time to first byte) exceeds DUR |
--max-dns / -tcp / -tls / -download DUR |
Fail if that individual phase exceeds DUR |
--expect-body STR |
Fail if the response body does not contain STR |
--expect-regex RE |
Fail if the response body does not match regex RE |
--tls-info |
After the run, print TLS certificate details (issuer, expiry with days left, SANs) |
--show-headers |
After the run, print selected response headers and the cache HIT/MISS verdict |
--server-hints |
After the run, print a per-request summary of server/edge/CDN/backend-identifying headers, flagging which values stay constant, vary, or change every request |
--capture-header NAME |
Capture a specific response header by name and show its value per request in that summary (repeatable, case-insensitive) |
--full-cdn |
Show the full comma-chained CDN/cache headers (x-served-by, x-cache, x-cache-hits, via); by default these collapse to just the final serving hop with the chain depth noted |
--prometheus |
Run as a Prometheus exporter daemon; re-probes on every scrape (see contrib) |
--prometheus-port PORT |
Port for the --prometheus exporter (default: 9109) |
--prometheus-bind ADDR |
Bind address for the --prometheus exporter (default: all interfaces) |
With -c N, add --stats to print a footer with min / p50 / p90 / p95 / p99 /
max / mean / stdev for every phase (plus total bytes). It appears only with 2 or
more successful requests, since percentiles are meaningless below that; p95 and
p99 get useful once you have roughly 20+ runs.
Set any assertion and check-endpoint becomes a pass/fail check: if any single
request breaches, the process exits non-zero, so it slots straight into CI
pipelines and cron-driven monitoring.
--assert-status CODErequires an exact HTTP status--max-total,--max-ttfb,--max-dns,--max-tcp,--max-tls,--max-downloadset per-phase time ceilings (DURis500ms,1s,1.5s, and so on)--expect-body STRmatches a literal substring;--expect-regex REmatches a regular expression (both validate the response body)
Exit codes: 0 all good, 1 an assertion breached, 2 bad arguments.
./check-endpoint.py --assert-status 200 --max-ttfb 300ms --max-total 1s \
--expect-body '"status":"ok"' https://example.com/health
echo $? # 0 = healthy, 1 = something breachedRegex flavor (--expect-regex): patterns use Python's
re syntax and are evaluated with
re.search, so the pattern matches anywhere in the body (it is not anchored).
By default it is case-sensitive and . does not cross newlines. Change that
with an inline flag placed at the start of the pattern: (?i)
case-insensitive, (?s) dotall (. also matches newlines), (?m) multiline
(^ and $ match at each line rather than only the whole-body start/end). The
body is read as UTF-8 (invalid bytes replaced) up to a 5 MiB cap, and an invalid
pattern exits with code 2 before any request is sent. --expect-body, by
contrast, is a plain substring check with no regex interpretation.
Prints the server certificate's subject, issuer, expiry date with days remaining (yellow under 30 days, orange under 15, red once expired), and its Subject Alternative Names. Useful for catching a certificate that is about to lapse before your users do.
Prints a curated set of response headers (server, content type, caching headers,
and so on) from the final response, plus a detected cache HIT/MISS verdict.
Handy when you suspect a CDN or proxy is the difference between "works on their
end" and "broken from here."
Where --show-headers shows only the final response, --server-hints walks
every request and prints the headers that reveal which server, edge, CDN, or
backend produced each one. Each successful run gets a row (number, IP, and the
headers as key=value), followed by a rollup that classifies each header:
- constant - the same value on every run (for example
server=nginx) - varied - a few distinct values with per-value counts (the real signal for
which backend or PoP served each request, for example
x-cache = HIT x6, MISS x4) - per-request - a different value every run, which usually means a request
or trace id such as
cf-rayrather than a backend hint
Add --capture-header NAME (repeatable, case-insensitive) to also track your
own headers, for example a backend or pod id; a header that is absent shows -.
Pair with -c N, and optionally -F to avoid connection reuse, to expose
load-balancer rotation and CDN point-of-presence selection.
Some CDN/cache headers are a comma-separated chain of hops (Fastly/Varnish
x-served-by, x-cache, x-cache-hits, via), oldest shield first and the
edge that actually served you last. By default the summary collapses those to
just the final hop and appends the chain depth, so a noisy value like
x-served-by = cache-iad-...-IAD, cache-iad-...-IAD, cache-pao-kpao1770024-PAO
reads as x-served-by(final) = cache-pao-kpao1770024-PAO [3 hops in chain],
and x-cache = MISS, HIT, HIT collapses to the edge verdict HIT. Only those
known chained headers are collapsed; pass --full-cdn to see every hop in the
raw chain instead.
# who served each of 10 requests, plus your own backend id (final hop only)
./check-endpoint.py -c 10 --server-hints \
--capture-header x-backend https://example.com
# same, but show the full CDN hop chain instead of just the final hop
./check-endpoint.py -c 10 --server-hints --full-cdn \
--capture-header x-backend https://example.com--prometheus turns the tool into a small pull-based Prometheus exporter
instead of printing the table. It serves metrics over HTTP and re-probes the
target on every scrape, so Prometheus always pulls fresh per-phase timing,
HTTP status, response size, and TLS certificate expiry. It runs in the
foreground until Ctrl+C. Use --prometheus-port (default 9109) and
--prometheus-bind (default all interfaces) to control the listener.
# serve metrics on :9109, probing example.com on each scrape
./check-endpoint.py --prometheus --prometheus-port 9109 https://example.com
# then, from anywhere that can reach it:
curl localhost:9109/metricsEach scrape runs -c probes (default 1), so -c > 1 also exposes per-scrape
total-time percentiles. Exposed series include check_endpoint_up, the
per-phase *_seconds gauges, check_endpoint_http_response_code,
check_endpoint_response_bytes, and (over HTTPS)
check_endpoint_tls_expiry_days.
Deploying it: a ready-to-use Docker image, Helm chart, and raw Kubernetes manifests live in contrib/check-endpoint-exporter, together with instructions for wiring it into Prometheus (via a ServiceMonitor or scrape annotations) and example alert rules.
| Column | Description |
|---|---|
# |
Request number |
IP_ADDRESS |
IP address libcurl connected to |
DNS |
Duration of DNS lookup (phase only) |
TCP_CONNECT |
Duration of TCP handshake (phase only) |
TLS_HANDSHAKE |
Duration of TLS negotiation; n/a for plain http:// |
PRE-TRANSFER |
Time from connect-ready to request-send-ready; typically ~0ms on direct HTTPS |
1ST_BYTE |
Time from request sent to first byte of response - the clearest indicator of server-side processing time |
REDIRECT |
Count and total time of any redirects followed; n/a when none. This is why TOTAL_TIME can exceed the sum of other columns. |
BODY_DL |
Time to receive the complete response body after the first byte |
TOTAL_TIME |
End-to-end wall-clock time including all redirects (the only cumulative column) |
HTTP_CODE |
HTTP response status code |
TOTAL_BYTES |
Response body size received |
PROTO |
HTTP version actually used - h1 (HTTP/1.1), h1.0 (HTTP/1.0), h2 (HTTP/2), or h3 (HTTP/3). Teal for h2, dim for h1. |
CHUNKS |
(only with -S) Number of chunks the response body arrived in |
AVG_GAP |
(only with -S) Average time between consecutive chunks, excluding the first chunk's arrival (already covered by 1ST_BYTE and the columns before it); n/a with fewer than 2 chunks |
MAX_GAP |
(only with -S) Longest of those inter-chunk gaps - a high MAX_GAP relative to AVG_GAP reveals a mid-stream stall; n/a with fewer than 2 chunks |
CHUNKS, AVG_GAP, and MAX_GAP only appear when -S/--stream is passed;
without it, the columns end at PROTO and the rest of the table is unaffected.
Note on
PRE-TRANSFER = 0ms: This is correct behavior for direct HTTPS connections. Once TLS completes, libcurl is immediately ready to transfer - the gap between those two timers is genuinely near zero.Note on
TLS_HANDSHAKEappearing onhttp://URLs: This is correct when the URL redirected tohttps://. The TLS column shows the handshake for the final connection; the redirect itself appears in theREDIRECTcolumn.Note on empty cells: a dim
n/ameans the phase structurally doesn't apply to this request (e.g.TLS_HANDSHAKEon plainhttp://, orREDIRECTwhen none were followed). A dim-means the field is empty for any other reason (e.g. truncated by a failure mid-transfer).Note on
-Sand request bodies:-Sonly changes how the response is measured - it has no effect on what you send. Combine it with-X/-das usual to test POST/PUT streaming endpoints with a body.Note on
AVG_GAP/MAX_GAPexcluding the first chunk: these two columns intentionally start counting from the second chunk onward. Including the first chunk's arrival would double-count the same span already shown by DNS/TCP/TLS/PRE-TRANSFER/1ST_BYTE, which would misreport ordinary connection setup time as an in-stream stall. With fewer than 2 chunks there's nothing to measure a gap between, so both columns shown/a.
| Marker | Meaning |
|---|---|
<TO> |
Request timed out (-t/--timeout exceeded) |
<DNS-FAIL> |
DNS resolution failed |
<CONN-FAIL> |
TCP connection refused or failed |
<TLS-FAIL> |
TLS handshake or certificate verification failed |
<NO-DATA> |
Connection succeeded but server sent nothing back |
<SEND-FAIL> |
Failed to send the request mid-transfer |
<RECV-FAIL> |
Failed to receive the response mid-transfer |
<RDR-FAIL> |
Too many redirects |
<BAD-URL> |
Malformed URL |
<AUTH-FAIL> |
Authentication denied |
<DENIED> |
Remote access denied |
<ERR> |
Any other libcurl error |
Markers are printed at the phase where failure occurred. All subsequent columns
for that row are left blank (-), and the next request (if -c N > 1) still
runs.
Colors are auto-disabled when output is piped to a file or another command.
| Element | Color |
|---|---|
| Header row | Bold blue |
| Odd rows | Primary text |
| Even rows | Slightly dimmed |
<1ms |
Dim (sub-millisecond) |
1-9ms |
Sky blue - fast |
10-99ms |
Teal - moderate |
≥100ms |
Yellow/peach - getting slow |
| Seconds | Bold peach - slow |
| Minutes | Bold red - very slow |
REDIRECT |
Peach |
| Error markers | Bold red |
n/a and - |
Dim overlay (same shade as the row number) |
2xx codes |
Green |
3xx codes |
Mauve |
4xx codes |
Maroon |
5xx codes |
Bold red |
| Bytes | Green → yellow → peach → red (B → KB → MB → GB) |
| IP address | Lavender |
| Row number | Dim |
CHUNKS |
Primary text (row color) |
AVG_GAP / MAX_GAP |
Same magnitude-based timing colors as other duration columns |
If you find this useful, please consider starring the repo ⭐, it helps others find it.
MIT
