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

295 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `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
```sh
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
```sh
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)
```sh
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
```sh
python -m latprobe --json -n 3 https://example.com
```
```json
[
{
"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.
```sh
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:
```sh
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:
```sh
python -m latprobe --verbose --json https://example.com
```
```json
[
{
"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
```sh
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)
```sh
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
```sh
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
```sh
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
```sh
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) |