Files
http-latency-prober/docs/usage/py-latprobe.md
Jan Novak 24ea9c9e71 feat(py): step 3 — full latprobe package with --verbose flag
Adds the complete Python port of the Go latprobe tool plus diagnostic
verbose output that the Go version does not yet have.

Package (python/latprobe/):
- probe.py: raw-socket HTTP timing with CertInfo/VerboseDetail dataclasses;
  TTFB loop accumulates to \r\n\r\n so headers are parseable without changing
  t_first_byte semantics; captures resolved IP, TLS version/cipher/bits,
  verified cert (via getpeercert()), and all response headers when verbose=True
- aggregate.py: summarize() → per-phase min/avg/max PhaseStats
- cli.py: injectable run(args,stdout,stderr)->int; argparse with injected
  streams; ThreadPoolExecutor concurrency across URLs; four text-rendering
  branches; JSON output; worst-exit-code accumulation; -v/--verbose flag
  appends IP/TLS/cert/header block after every timing table; JSON extended
  with "verbose" object (omitted when flag absent)
- duration.py: parse Go-style duration strings (10s, 500ms, 2m, bare seconds)
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400

Tests:
- test_probe.py: 18 hermetic tests (success, 404, DNS/connect/timeout/scheme
  failures, partial-phase invariants, verbose detail fields)
- test_cli.py: 38 hermetic tests (usage errors, single/aggregate/failure text,
  worst-code, --fail, JSON schema/ordering/grouping, verbose text and JSON)
- test_integration.py: 45 tests against live internet services (badssl.com for
  TLS errors, real cert/IP/header validation in verbose mode)

Makefile: PYTHONPATH fix, fnmatch pattern test_[!i]*.py to exclude
integration tests from py-test, new py-test-integration target

Docs: docs/usage/py-latprobe.md, python/configs/usage-latprobe.md (runnable
reference with captured output), plans for both the package and verbose feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:45:56 +02:00

8.9 KiB
Raw Blame History

latprobe/ — Full Python Port

What it does

A packaged Python CLI that mirrors the Go latprobe tool: per-phase HTTP latency measurement (DNS, TCP connect, TLS, TTFB, Transfer, Total) with configurable sampling, cross-URL concurrency, JSON output, and distinct exit codes per failure class.

Run it from the python/ directory with python -m latprobe.

Phases measured:

Phase What is timed
DNS lookup socket.getaddrinfo() — hostname resolution
TCP connect sock.connect() — SYN to connection established
TLS handshake ssl.wrap_socket() — full handshake (HTTPS only)
Server (TTFB) sendall() return → first recv() byte
Transfer First byte → EOF — body download time
Total DNS start → body EOF — wall-clock end-to-end

Flags / arguments

python -m latprobe [flags] <url> [url ...]
Flag Default Description
url … (positional) required One or more URLs to probe
-n N, --count N 1 Number of requests per URL
-c N, --concurrency N 0 (auto) Max parallel URLs; 0 = min(len(urls), 8)
--timeout DURATION 10s Per-request timeout; supports ms, s, m, or bare seconds
--fail off Exit non-zero when any HTTP status ≥ 400
--json off Output as JSON array instead of text
-v, --verbose off Show resolved IP, TLS version/cipher, certificate details, and response headers
-h, --help Show help and exit 0

Exit codes:

Code Meaning
0 All probes succeeded
1 Usage / argument error
2 DNS failure
3 TCP connect failure
4 Timeout
5 TLS error
6 HTTP status ≥ 400 (--fail only)

The highest exit code across all URLs is used as the process exit.

Examples

Single URL

python -m latprobe https://example.com
https://example.com  (200)
  DNS lookup     :     18.87 ms
  TCP connect    :      9.76 ms
  TLS handshake  :     14.71 ms
  Server (TTFB)  :     67.07 ms
  Transfer       :      0.14 ms
  ─────────────────────────────
  Total          :    118.39 ms

Sampling (-n) — shows min/avg/max table

python -m latprobe -n 5 https://example.com
https://example.com  (200, 5 samples)
                          min        avg        max
  DNS lookup     :    1.80 ms    3.14 ms    5.00 ms
  TCP connect    :    9.50 ms   10.25 ms   11.40 ms
  TLS handshake  :   13.80 ms   15.20 ms   18.90 ms
  Server (TTFB)  :   62.00 ms   66.50 ms   71.30 ms
  Transfer       :    0.10 ms    0.25 ms    0.40 ms
  ─────────────────────────────────────────────────
  Total          :   97.10 ms  101.20 ms  109.80 ms

Multiple URLs (probed in parallel)

python -m latprobe https://example.com https://www.google.com

Output for each URL is separated by a blank line. Exit code = worst across all.

JSON output

python -m latprobe --json -n 3 https://example.com
[
  {
    "url": "https://example.com",
    "status": 200,
    "succeeded": 3,
    "failed": 0,
    "phases": {
      "dns":      {"min_ms": 1.80, "avg_ms": 3.14, "max_ms": 5.00},
      "connect":  {"min_ms": 9.50, "avg_ms": 10.25, "max_ms": 11.40},
      "tls":      {"min_ms": 13.80, "avg_ms": 15.20, "max_ms": 18.90},
      "ttfb":     {"min_ms": 62.00, "avg_ms": 66.50, "max_ms": 71.30},
      "transfer": {"min_ms": 0.10, "avg_ms": 0.25, "max_ms": 0.40},
      "total":    {"min_ms": 97.10, "avg_ms": 101.20, "max_ms": 109.80}
    }
  }
]

phases is omitted when all samples failed. tls key is omitted for http:// URLs. errors is included (and phases omitted) when some samples fail.

Verbose mode (-v / --verbose)

Shows the resolved IP address, TLS version/cipher/bits, certificate details (CN, expiry, issuer), and useful response headers. Appended to the standard timing block.

python -m latprobe --verbose https://example.com
https://example.com  (200)
  DNS lookup     :     16.47 ms
  TCP connect    :      9.76 ms
  TLS handshake  :     13.09 ms
  Server (TTFB)  :     60.31 ms
  Transfer       :      0.20 ms
  ─────────────────────────────
  Total          :    112.76 ms
  IP             :  104.20.23.154
  TLS            :  TLSv1.3  TLS_AES_256_GCM_SHA384  256 bit
  Cert           :  CN=example.com  valid until 2026-08-29  SSL Corporation
  Server         :  cloudflare
  Content-Type   :  text/html

For TLS failures, the verbose block still shows the IP (DNS + TCP succeeded) so you can tell which server you actually reached:

python -m latprobe --verbose https://expired.badssl.com/
https://expired.badssl.com/  (FAILED)
  DNS lookup     :     30.98 ms
  TCP connect    :    126.58 ms
  TLS handshake  :    293.58 ms
  ─────────────────────────────
  Total          :    463.37 ms
  ✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
  IP             :  104.154.89.105

For DNS failures, the verbose block is empty (IP unknown), so it is not printed.

Combined with --json, verbose detail appears in a "verbose" object:

python -m latprobe --verbose --json https://example.com
[
  {
    "url": "https://example.com",
    "status": 200,
    "succeeded": 1,
    "failed": 0,
    "phases": { ... },
    "verbose": {
      "ip": "104.20.23.154",
      "tls_version": "TLSv1.3",
      "tls_cipher": "TLS_AES_256_GCM_SHA384",
      "tls_bits": 256,
      "cert": {
        "cn": "example.com",
        "sans": ["example.com", "*.example.com"],
        "expiry": "2026-08-29",
        "issuer_cn": "SSL Corporation",
        "verified": true
      },
      "headers": {
        "Content-Type": "text/html",
        "Server": "cloudflare",
        ...
      }
    }
  }
]

The "verbose" key is omitted when --verbose is not set. The "cert" key is omitted for http:// URLs and when TLS fails (Python's stdlib does not expose parsed cert data from a failed handshake). "headers" contains all parsed response headers (the text block shows a curated priority list).

DNS failure

python -m latprobe http://no.such.host.invalid
echo "exit: $?"
http://no.such.host.invalid  (FAILED)
  Total          :      0.65 ms
  ✗ dns: [Errno 8] nodename nor servname provided, or not known
exit: 2

--fail flag (exit non-zero on HTTP 4xx/5xx)

python -m latprobe --fail https://www.google.com/this-page-does-not-exist-at-all
echo "exit: $?"
https://www.google.com/this-page-does-not-exist-at-all  (404 ✗)
  DNS lookup     :      3.10 ms
  TCP connect    :      9.60 ms
  TLS handshake  :     14.20 ms
  Server (TTFB)  :    118.50 ms
  Transfer       :      0.12 ms
  ─────────────────────────────
  Total          :    145.50 ms
exit: 6

Mixed — some URLs succeed, some fail

python -m latprobe https://example.com http://no.such.host.invalid
echo "exit: $?"
https://example.com  (200)
  ...
  Total          :    118.39 ms

http://no.such.host.invalid  (FAILED)
  Total          :      0.65 ms
  ✗ dns: ...
exit: 2

Timeout

python -m latprobe --timeout 500ms http://10.255.255.1/
echo "exit: $?"
http://10.255.255.1/  (FAILED)
  DNS lookup     :      0.20 ms
  TCP connect    :    500.18 ms
  ─────────────────────────────
  Total          :    500.40 ms
  ✗ timeout: timed out
exit: 4

Makefile targets

make py-run ARGS="-n 3 https://example.com"   # run the package
make py-test                                   # run test suite
make py-check                                  # alias for py-test

Limitations

  • DNS timeout is OS-controlled. Python's socket.getaddrinfo does not accept a timeout parameter. The --timeout flag applies to TCP connect and subsequent phases. DNS failures from an unreachable or NXDOMAIN host still happen quickly in practice.
  • HTTP 1.1 + Connection: close only. No keep-alive, HTTP/2, auth, custom headers, or redirect following. HTTP 3xx is shown with its raw status code.
  • Body fully drained. Transfer time is real download time; large bodies affect the Transfer and Total phases.

Comparison with simple.py and phases.py

simple.py phases.py latprobe/
Phases total only all phases all phases
Sampling no no -n flag
Concurrency no no -c flag
JSON no no --json flag
--fail implicit (urlopen raises on 4xx) no --fail flag
Exit codes 0 or 1 0 or 1 06 per failure class
Config file yes yes no (URL args only)