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>
This commit is contained in:
294
docs/usage/py-latprobe.md
Normal file
294
docs/usage/py-latprobe.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# `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 | 0–6 per failure class |
|
||||
| Config file | yes | yes | no (URL args only) |
|
||||
Reference in New Issue
Block a user