Compare commits

..

10 Commits

Author SHA1 Message Date
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
16fff2f964 docs: add usage-phases.md beside usage-simple.md in python/configs/
- Rename python/configs/usage.md → usage-simple.md so both scripts have a
  parallel runnable reference file in the same directory
- Add python/configs/usage-phases.md: runnable sh commands + real expected
  output for phases.py against every error-path config, including a note on
  the key difference from simple.py (HTTP 4xx exits 0 in phases.py vs 1 in
  simple.py because phases.py completes at the network level)
- Update doc cross-links in docs/usage/py-simple.md and py-phases.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:11:53 +02:00
f609d8124f feat(py): step 2 — per-phase latency measurement (phases.py)
Hand-drives raw sockets to time each HTTP phase individually — DNS
(getaddrinfo), TCP connect, TLS handshake (ssl.wrap_socket, https only),
TTFB (sendall → first recv), Transfer (first byte → EOF), Total.

Partial phases are preserved on failure (same invariant as Go's probe.go).
Error classification mirrors Go's priority: dns → timeout → tls → connect.
Output format matches Go's single-sample text layout.
Input: bare URL args or plain-text config file (same format as simple.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:07:44 +02:00
6db2abb713 feat(py): step 1 refinement — error-path example configs for simple.py
- python/configs/: 7 purpose-built config files, one per error class:
  all-ok.txt, dns-failure.txt, connection-refused.txt, timeout.txt,
  tls-errors.txt (badssl.com), http-errors.txt (real 404 paths on stable
  hosts), mixed.txt (one of each, no timeout)
- python/configs/usage.md: runnable sh commands + expected output per config,
  plus quick-sweep and full-sweep loops; commands written for running from
  the python/ directory
- docs/custom-usage-examples.md: example 11 added (all failure classes at once)
- docs/usage/py-simple.md: added pointer to python/configs/ and corrected
  limitation note (4xx/5xx are FAIL, not OK)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:58:20 +02:00
49838e9e4c feat(py): step 1 — simple reachability checker (simple.py)
- python/simple.py: reads a plain-text site list (one URL/line, # comments),
  issues a GET per site, prints aligned OK/FAIL + elapsed ms, exits 0/1
- Config format forward-compatible with future key=value annotations
- HTTPError caught separately from URLError so HTTP status code appears in
  the FAIL message (e.g. "HTTP Error 404: Not Found")
- python/sites.txt: committed example config
- Makefile: PYTHON/PY_DIR/SITES vars; py-simple-run, py-phases-run, py-run,
  py-test, py-check, py-clean targets; umbrella test/check/clean include Python
- docs/plans/2026-07-01-10-39-py-simple.md: design plan
- docs/usage/py-simple.md: flags, format, examples, limitations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:58:10 +02:00
9a69ba946f Add URL-level concurrency (-c flag, Step 7)
Probe multiple URLs in parallel with a bounded worker pool; the N samples
of each URL remain sequential to preserve accurate min/avg/max statistics.
Default auto-concurrency is min(numURLs, 8); -c 1 restores serial mode.
Output is always buffered and printed in original input order. Verified
clean with go test -race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 01:33:03 +02:00
45583ac2be docs: add 10 custom usage examples covering all failure modes
Examples 1-3: clean global sweep (single, 10 samples, JSON).
Examples 4-8: one failure type each (DNS, refused, timeout, HTTP, TLS).
Example 9: full matrix, all 5 failure types in one run.
Example 10: quick smoke test (-n 1, JSON, --timeout 2s, all types).
Includes runtime estimates and jq filter snippets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 01:16:39 +02:00
0cf5b54070 chore: add root Makefile with namespaced targets
Namespaced go-* targets (build, run, test, test-verbose, check, cover,
fmt, vet, tidy, install, lint, clean) plus umbrella targets that delegate
to them. help is the default. py-* targets will slot in during the Python
port. Updates .gitignore with coverage artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 01:08:51 +02:00
a9534ec2c1 test(go): step 6 — integration tests covering all exit codes and output
Extract run(args, stdout, stderr) int from main() for in-process
testability. Fix TLS failure classification (tlsErr now captured from
TLSHandshakeDone hook). Add run_test.go with 14 table-driven in-process
tests and cli_test.go with TestMain + 3 subprocess smoke tests. All
servers use httptest; .invalid TLD for deterministic DNS failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 01:00:20 +02:00
c323d879d0 feat(go): step 5 — failure handling with classified exit codes
Classify network failures (dns/connect/timeout/tls) with distinct exit
codes 2-5. Add --timeout (default 10s) via context.WithTimeout. Add
--fail for exit 6 on HTTP status >= 400. Preserve partial phase timing
up to the failure point. -n sampling continues on network failures,
aggregating successes and reporting fail counts. JSON extended with
succeeded/failed/errors fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:45:56 +02:00
47 changed files with 6826 additions and 105 deletions

5
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Go binaries
# Go binaries and artifacts
go/latprobe
python/latprobe
go/coverage.out
go/coverage.html
# Python
__pycache__/

View File

@@ -4,6 +4,141 @@ All completed features are logged here in reverse-chronological order.
---
## 2026-07-01 12:55 — `--verbose` / `-v` flag for `latprobe` (Python)
- `-v`/`--verbose` flag added to the `latprobe` CLI
- `probe.py`: new `CertInfo` + `VerboseDetail` dataclasses; `Options.verbose`;
`Result.detail`; TTFB loop now accumulates until `\r\n\r\n` (unchanged TTFB
semantics — `t_first_byte` stamped on first `recv()`, not when headers complete);
captures resolved IP (from `getaddrinfo`), TLS version/cipher/bits
(from `sock.version()`/`sock.cipher()`), verified cert (from `getpeercert()`),
and all response headers
- `cli.py`: verbose text block appended after timing rows in all four output
branches (single, aggregate, all-failed, mixed); `_VERBOSE_HEADERS` priority
list controls which headers appear in text mode; JSON `"verbose"` object
includes all parsed headers, full cert fields, TLS metadata; `"verbose"` key
omitted when flag is absent
- 9 new hermetic probe tests, 15 new CLI tests covering verbose text and JSON
across success, connect-fail, and DNS-fail scenarios
- 15 new integration tests for real TLS cert fields (CN, expiry, issuer),
IP format, TLS version string, header presence, and verbose text/JSON output
- `python/configs/usage-latprobe.md`: runnable reference with real output for
all features including verbose and `--verbose --json`
- `docs/usage/py-latprobe.md`: updated with `--verbose` flag and examples
- Plan: `docs/plans/2026-07-01-12-55-py-latprobe-verbose.md`
---
## 2026-07-01 12:23 — Full `latprobe` Python package (Python, Step 3)
- `python/latprobe/` package: full port of the Go CLI, runnable as `python -m latprobe`
- `probe.py`: raw-socket HTTP measurement with `Options(timeout)` parameter; mirrors
`phases.py` technique; partial phases preserved on failure
- `aggregate.py`: `summarize(List[Result]) -> Aggregate` with per-phase `PhaseStats(min_ms, avg_ms, max_ms)`
- `duration.py`: parses Go-style duration strings (`10s`, `500ms`, `2m`, bare seconds)
- `cli.py`: injectable `run(args, stdout, stderr) -> int`; argparse with injected streams
(`_Parser` subclass); `ThreadPoolExecutor` concurrency across URLs; four text-rendering
branches (single, aggregate, all-failed, mixed); JSON output; worst-exit-code accumulation
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400 (`--fail`)
- `python/tests/test_probe.py`: 9 tests — success, 404, DNS fail, connect refused, TTFB
timeout, bad scheme, partial phase invariants; uses `http.server` + daemon threads
- `python/tests/test_cli.py`: 23 tests — drives `cli.run()` in-process; covers usage
errors, single/aggregate text, failure exit codes, worst-code, `--fail`, JSON schema,
JSON ordering, JSON error grouping
- Makefile `py-test`: added `PYTHONPATH=$(PY_DIR)`, removed `|| true`
- User doc: `docs/usage/py-latprobe.md`
---
## 2026-07-01 12:04 — Per-phase latency measurement (Python, Step 2)
- `python/phases.py`: self-contained script; hand-drives raw sockets to time
each HTTP phase individually — DNS (`getaddrinfo`), TCP connect, TLS handshake
(`ssl.wrap_socket`, HTTPS only), TTFB (sendall → first recv), Transfer, Total
- Partial phases preserved on failure (same invariant as Go's `probe.go`)
- Error classification mirrors Go's priority: dns → timeout → tls → connect
- Output format matches Go's single-sample text layout (14-char labels, `─`
separator, `%8.2f ms` alignment)
- Input: bare URL args or plain-text config file (same format as `simple.py`)
- Exits 0 if all URLs complete without network error; 1 if any failed
- User doc: `docs/usage/py-phases.md`
---
## 2026-07-01 11:30 — Error-path example configs for simple.py (Python, Step 1 refinement)
- `python/configs/` directory with 7 purpose-built config files, one per error class:
`all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`, `timeout.txt`,
`tls-errors.txt` (badssl.com), `http-errors.txt` (httpstat.us), `mixed.txt`
- `python/configs/usage.md`: runnable shell commands + expected output for every config
- Fixed `docs/usage/py-simple.md`: 4xx/5xx responses are reported as `FAIL` (not `OK`),
because `urlopen` raises `HTTPError` for non-2xx; added pointer to the example configs
---
## 2026-07-01 10:39 — Simple reachability checker (Python, Step 1)
- `python/simple.py`: reads a plain-text site list (one URL per line, `#` comments),
issues a GET to each, prints aligned `OK / FAIL + elapsed ms` per site
- Plain-text config format forward-compatible with future `key=value` annotations
- Exits 0 if all sites responded, 1 if any failed or config is missing
- `python/sites.txt`: committed example config
- Makefile `py-*` targets added: `py-simple-run`, `py-phases-run`, `py-run`,
`py-test`, `py-check`, `py-clean`; umbrella `test`, `check`, `clean` now include Python
- User doc: `docs/usage/py-simple.md`
---
## 2026-07-01 01:23 — Concurrency (Go, Step 7)
- Added `-c`/`--concurrency` flag: max URLs probed in parallel (0 = auto)
- Default auto-concurrency: `min(numURLs, 8)` — scales with workload, caps at 8
- Parallelism is **across URLs only**; the N samples of each URL stay sequential
to preserve the accuracy of per-URL min/avg/max statistics
- Output buffered and printed in original input order (deterministic for both
text and JSON), exit-code accumulation unchanged
- Implementation: semaphore channel + `sync.WaitGroup`, each goroutine writes
only its own indexed result slot — verified clean with `go test -race`
- Added `make go-test-race` target to the Makefile
- 3 new tests: `TestRunConcurrentOrder`, `TestConcurrentWorstCode`,
`TestConcurrentJSONOrder`
- Updated runtime estimates in `docs/custom-usage-examples.md`
- User doc: `docs/usage/step-7-concurrency.md`
---
## 2026-07-01 01:05 — Root Makefile
- Namespaced targets: `go-build`, `go-run`, `go-test`, `go-test-verbose`, `go-check`, `go-cover`, `go-fmt`, `go-vet`, `go-tidy`, `go-install`, `go-lint`, `go-clean`
- Umbrella targets (`build`, `test`, `check`, `fmt`, `vet`, `clean`, `all`) delegate to Go now; `py-*` will slot in during the Python port
- `help` is the default target; auto-generated from `##` comments
- `go-run` accepts `ARGS=` for passing flags, `go-lint` guards for `golangci-lint`
- `.gitignore` updated with coverage artifacts
---
## 2026-07-01 00:49 — Integration tests (Go, Step 6)
- Extracted `run(args, stdout, stderr) int` from `main()` to make the CLI testable in-process
- Fixed TLS classification bug: `TLSHandshakeDone` fires with the error on cert rejection, so `tlsErr` is now captured and checked before the stale `tlsStart.IsZero()` guard
- `run_test.go`: 14 in-process tests covering exit codes 06, multi-URL, sampling, and JSON structure assertions
- `cli_test.go`: `TestMain` builds the real binary; 3 subprocess smoke tests exercise the actual `os.Exit` path
- All test servers use `httptest` + stdlib; `.invalid` TLD for deterministic DNS failures; no external network dependency
---
## 2026-07-01 00:38 — Failure handling (Go, Step 5)
- Classified network failures into `dns` / `connect` / `timeout` / `tls` with distinct exit codes (25)
- Partial timing preserved up to the failure point (e.g. DNS phase shown on NXDOMAIN)
- `--timeout` flag (default 10s) applied via `context.WithTimeout`
- `--fail` flag: HTTP status ≥ 400 → exit code 6 (curl-style)
- `-n` sampling continues on network failure; aggregates successes, reports fail count + cause
- JSON output extended with `succeeded`, `failed`, `errors[]` fields
- Highest exit code across all URLs/failure types is used as the process exit
---
## 2026-07-01 00:08 — JSON output flag (Go, Step 4)
- `--json` flag emits a JSON array with one entry per URL

128
Makefile Normal file
View File

@@ -0,0 +1,128 @@
GO_DIR := go
PY_DIR := python
BINARY := latprobe
PYTHON := python3.14
ARGS ?=
SITES ?= $(PY_DIR)/sites.txt
.DEFAULT_GOAL := help
# ── help ──────────────────────────────────────────────────────────────────────
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*##"}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' \
| sort
# ── umbrella targets (delegate to Go now; py-* will be added during Python port) ──
.PHONY: all
all: check build ## Run checks then build
.PHONY: build
build: go-build ## Build binary (delegates to go-build)
.PHONY: test
test: go-test py-test ## Run all tests (Go + Python)
.PHONY: check
check: go-check py-check ## Run fmt + vet + test gate (Go + Python)
.PHONY: fmt
fmt: go-fmt ## Format source code (delegates to go-fmt)
.PHONY: vet
vet: go-vet ## Run go vet (delegates to go-vet)
.PHONY: clean
clean: go-clean py-clean ## Remove build and coverage artifacts
# ── Go targets ────────────────────────────────────────────────────────────────
.PHONY: go-build
go-build: ## go: build binary → go/latprobe
go -C $(GO_DIR) build -o $(BINARY) .
.PHONY: go-run
go-run: ## go: build and run (pass flags via ARGS="…")
go -C $(GO_DIR) run . $(ARGS)
.PHONY: go-test
go-test: ## go: run all tests
go -C $(GO_DIR) test ./...
.PHONY: go-test-verbose
go-test-verbose: ## go: run all tests with per-case output
go -C $(GO_DIR) test -v ./...
.PHONY: go-test-race
go-test-race: ## go: run tests with the race detector enabled
go -C $(GO_DIR) test -race ./...
.PHONY: go-check
go-check: go-fmt go-vet go-test ## go: fmt + vet + test (pre-commit gate)
.PHONY: go-cover
go-cover: ## go: run tests with coverage → go/coverage.html
go -C $(GO_DIR) test -coverprofile=coverage.out ./...
go -C $(GO_DIR) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: $(GO_DIR)/coverage.html"
.PHONY: go-fmt
go-fmt: ## go: format all Go source files with gofmt
gofmt -w $(GO_DIR)
.PHONY: go-vet
go-vet: ## go: run go vet on all packages
go -C $(GO_DIR) vet ./...
.PHONY: go-tidy
go-tidy: ## go: run go mod tidy
go -C $(GO_DIR) mod tidy
.PHONY: go-install
go-install: ## go: install binary to $GOBIN / $GOPATH/bin
go -C $(GO_DIR) install .
.PHONY: go-lint
go-lint: ## go: run golangci-lint (must be installed)
@command -v golangci-lint >/dev/null 2>&1 || { \
echo "golangci-lint not installed — see https://golangci-lint.run/usage/install/"; \
exit 1; \
}
cd $(GO_DIR) && golangci-lint run
.PHONY: go-clean
go-clean: ## go: remove binary and coverage artifacts
rm -f $(GO_DIR)/$(BINARY) $(GO_DIR)/coverage.out $(GO_DIR)/coverage.html
# ── Python targets ────────────────────────────────────────────────────────────
.PHONY: py-simple-run
py-simple-run: ## py: run simple.py (pass config via SITES=path/to/sites.txt)
$(PYTHON) $(PY_DIR)/simple.py $(SITES)
.PHONY: py-phases-run
py-phases-run: ## py: run phases.py (pass URLs via ARGS="url …" or SITES=path)
$(PYTHON) $(PY_DIR)/phases.py $(if $(ARGS),$(ARGS),$(SITES))
.PHONY: py-run
py-run: ## py: run the full latprobe package (pass flags via ARGS="…")
cd $(PY_DIR) && $(PYTHON) -m latprobe $(ARGS)
.PHONY: py-test
py-test: ## py: run Python unit tests (local, hermetic)
PYTHONPATH=$(PY_DIR) $(PYTHON) -m unittest discover -s $(PY_DIR)/tests -p 'test_[!i]*.py' -v $(ARGS)
.PHONY: py-test-integration
py-test-integration: ## py: run integration tests against live internet services (~30 s)
PYTHONPATH=$(PY_DIR) $(PYTHON) -m unittest discover -s $(PY_DIR)/tests -p 'test_integration.py' -v $(ARGS)
.PHONY: py-check
py-check: py-test ## py: run Python test gate (hermetic only)
.PHONY: py-clean
py-clean: ## py: remove Python bytecode and __pycache__ dirs
@find $(PY_DIR) -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null; \
find $(PY_DIR) -name '*.pyc' -delete 2>/dev/null; true

View File

@@ -28,12 +28,16 @@ Both implementations produce identical CLI behaviour and output formats.
latprobe [flags] <url> [url ...]
Flags:
-n, --count int Number of requests per URL (default 1)
--json Output results as JSON instead of text
-n, --count int Number of requests per URL (default 1)
-c, --concurrency int Max URLs probed in parallel, 0 = auto (default min(numURLs,8))
--timeout duration Request timeout, e.g. 10s, 500ms (default 10s)
--fail Exit non-zero on HTTP status >= 400 (exit code 6)
--json Output results as JSON instead of text
Examples:
latprobe https://example.com
latprobe -n 5 https://example.com https://www.google.com
latprobe -c 1 -n 10 https://example.com # serial, most accurate
latprobe --json https://example.com | jq .
```
@@ -94,6 +98,9 @@ https://example.com (5 samples)
| 2 | Per-phase breakdown — DNS, TCP, TLS, TTFB, transfer (`net/http/httptrace`) | ✅ Done |
| 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ✅ Done |
| 4 | `--json` output flag | ✅ Done |
| 5 | Failure handling — `--timeout`, `--fail`, distinct exit codes, partial timing | ✅ Done |
| 6 | Integration tests — in-process matrix + subprocess smoke tests | ✅ Done |
| 7 | Concurrency — `-c` worker pool across URLs; samples stay serial per URL | ✅ Done |
### Python
@@ -103,6 +110,27 @@ connection timings via custom socket wrap or `httpx`/`urllib3` hooks).
---
## Development
A `Makefile` at the repo root provides all common tasks:
```sh
make # list all targets
make build # build go/latprobe
make test # run all tests
make check # fmt + vet + test (pre-commit gate)
make go-run ARGS="https://example.com"
make go-test-verbose
make go-cover # coverage report → go/coverage.html
make go-test-race # run tests with race detector
make go-lint # golangci-lint
make clean # remove build artifacts
```
See [`docs/usage/makefile.md`](docs/usage/makefile.md) for the full target reference.
---
## Development Environment
- Go 1.26.4 / darwin arm64

View File

@@ -0,0 +1,333 @@
# Custom Usage Examples
All examples assume the binary has been built first:
```sh
make build
# binary is now at go/latprobe
```
**Concurrency note:** Since Step 7, `latprobe` probes URLs in parallel by default
(`min(numURLs, 8)` workers). Estimated runtimes below reflect this — they are
roughly _slowest-single-URL × samples_ rather than the sum across all URLs.
Use `-c 1` to restore serial execution for the most accurate per-phase numbers.
The timeout flag is shortened to `--timeout 3s` wherever non-routable IPs are
used, so each timed-out sample fails in 3 s instead of the default 10 s.
---
## Example 1 — Clean global sweep, text output (single sample)
10 sites distributed across continents, one request each.
**All sites accessible. Exit code: 0.**
```sh
./go/latprobe \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://www.thehindu.com \
https://www.timeslive.co.za
```
*Estimated runtime: ~58 s — all 10 sites measured in parallel; shows per-phase breakdown once per site.*
---
## Example 2 — Clean global sweep, 10 samples, text output
Same 10 sites, 10 requests each — reveals real latency distribution (min/avg/max).
**All sites accessible. Exit code: 0.**
```sh
./go/latprobe -n 10 \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://www.thehindu.com \
https://www.timeslive.co.za
```
*Estimated runtime: ~2030 s — 8 workers run in parallel; wall time ≈ slowest single URL × 10 samples.*
---
## Example 3 — Clean global sweep, 10 samples, JSON output
Same as Example 2, machine-readable. Pipe into `jq` to extract specific phases.
**All sites accessible. Exit code: 0.**
```sh
./go/latprobe -n 10 --json \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://www.thehindu.com \
https://www.timeslive.co.za
# Extract average total latency per site:
./go/latprobe -n 10 --json \
https://www.google.com https://www.bbc.co.uk https://www.lemonde.fr \
https://www.spiegel.de https://www.yahoo.co.jp https://www.alibaba.com \
https://www.globo.com https://www.abc.net.au https://www.thehindu.com \
https://www.timeslive.co.za \
| jq '.[] | {url, avg_total_ms: .phases.total.avg_ms}'
```
*Estimated runtime: ~2030 s.*
---
## Example 4 — DNS failures mixed in (exit code 2)
8 accessible sites + 2 non-existent domains.
The `.invalid` TLD is guaranteed NXDOMAIN by RFC 6761.
**Expected exit code: 2.**
```sh
./go/latprobe -n 10 \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://nonexistent-host-one.invalid \
https://nonexistent-host-two.invalid
```
*Estimated runtime: ~2030 s — DNS failures resolve near-instantly; 8 workers run in parallel.*
---
## Example 5 — Connection refused mixed in (exit code 3)
8 accessible sites + 2 localhost ports with nothing listening.
**Expected exit code: 3.**
```sh
./go/latprobe -n 10 \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
http://127.0.0.1:1 \
http://127.0.0.1:19999
```
*Estimated runtime: ~2030 s — refused connections fail immediately; 8 workers run in parallel.*
---
## Example 6 — Timeout failures mixed in (exit code 4)
8 accessible sites + 2 non-routable IPs (RFC 5737 documentation range,
packets are dropped by the network). `--timeout 3s` keeps each failed
sample to 3 s instead of the default 10 s.
**Expected exit code: 4.**
```sh
./go/latprobe -n 10 --timeout 3s \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://192.0.2.1 \
https://203.0.113.1
```
*Estimated runtime: ~3540 s — all 10 URLs measured in parallel; the 2 timeout IPs each add 3 s × 10 samples = 30 s and are the bottleneck.*
---
## Example 7 — HTTP error status with `--fail` (exit code 6)
8 accessible sites + 2 URLs that return 4xx/5xx. Without `--fail` these
would exit 0; with it, exit code becomes 6.
**Expected exit code: 6.**
```sh
./go/latprobe -n 10 --fail \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://httpbin.org/status/404 \
https://httpbin.org/status/503
```
*Estimated runtime: ~2030 s — full timing captured even for error responses; all 10 URLs measured in parallel.*
---
## Example 8 — TLS failures mixed in (exit code 5)
8 accessible sites + 2 HTTPS servers with bad certificates.
`badssl.com` is a purpose-built TLS testing service.
**Expected exit code: 5.**
```sh
./go/latprobe -n 10 \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://self-signed.badssl.com \
https://expired.badssl.com
```
*Estimated runtime: ~2030 s — TLS failures surface DNS + connect timing; all 10 URLs measured in parallel.*
---
## Example 9 — All failure types, full matrix (exit code 5)
10 accessible sites + one of each failure class. Exercises every code path:
DNS (exit 2), connect (exit 3), timeout (exit 4), TLS (exit 5),
HTTP error with `--fail` (exit 6). Highest code wins → **exit 5** (TLS is
5, HTTP error is 6 — but exit 6 if httpbin is reachable).
**Expected exit code: 6 (with --fail).**
```sh
./go/latprobe -n 10 --fail --timeout 3s \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://www.thehindu.com \
https://www.timeslive.co.za \
https://nonexistent-host.invalid \
http://127.0.0.1:1 \
https://192.0.2.1 \
https://httpbin.org/status/500 \
https://self-signed.badssl.com
```
*Estimated runtime: ~3545 s — 15 URLs measured with 8 workers; the 2 timeout IPs (30 s × each) are the bottleneck.*
---
## Example 10 — Quick smoke test, single sample, JSON, all failure types
Same 15 targets as Example 9 but `-n 1` for a fast sanity check.
JSON output lets you pipe results to `jq` for filtering.
**Expected exit code: 6 (with --fail).**
```sh
./go/latprobe -n 1 --fail --timeout 2s --json \
https://www.google.com \
https://www.bbc.co.uk \
https://www.lemonde.fr \
https://www.spiegel.de \
https://www.yahoo.co.jp \
https://www.alibaba.com \
https://www.globo.com \
https://www.abc.net.au \
https://www.thehindu.com \
https://www.timeslive.co.za \
https://nonexistent-host.invalid \
http://127.0.0.1:1 \
https://192.0.2.1 \
https://httpbin.org/status/500 \
https://self-signed.badssl.com
# Show only failed entries:
./go/latprobe -n 1 --fail --timeout 2s --json \
https://www.google.com \
https://nonexistent-host.invalid \
http://127.0.0.1:1 \
https://192.0.2.1 \
https://httpbin.org/status/500 \
https://self-signed.badssl.com \
| jq '.[] | select(.failed > 0 or .status >= 400)'
```
*Estimated runtime: ~46 s — 15 URLs probed in parallel with `-n 1`; only the 2 s timeout IPs add meaningful delay.*
---
## Example 11 — 3 working sites, one of each error kind (exit code 6)
Minimal URL set that exercises every failure class simultaneously.
Three real sites succeed; five targets each trigger a distinct error.
`--fail` is required to surface the HTTP 4xx as an exit code.
**Expected exit code: 6** (highest code wins; HTTP error = 6 > TLS = 5 > timeout = 4 > refused = 3 > DNS = 2).
```sh
./go/latprobe -n 5 --fail --timeout 3s \
https://www.cloudflare.com \
https://www.github.com \
https://www.wikipedia.org \
https://nonexistent-host.invalid \
http://127.0.0.1:1 \
https://192.0.2.1 \
https://self-signed.badssl.com \
https://httpbin.org/status/404
```
| URL | Expected outcome | Exit-code contribution |
| --- | --------------- | ---------------------- |
| cloudflare.com | success | — |
| github.com | success | — |
| wikipedia.org | success | — |
| nonexistent-host.invalid | DNS failure | 2 |
| 127.0.0.1:1 | connection refused | 3 |
| 192.0.2.1 | timeout (3 s × 5 samples) | 4 |
| self-signed.badssl.com | TLS handshake failure | 5 |
| httpbin.org/status/404 | HTTP 404 (with --fail) | 6 |
_Estimated runtime: ~1822 s — 8 workers cover all URLs in parallel; the non-routable IP (192.0.2.1) drives the wall time at 3 s × 5 samples = 15 s._
---
## Exit code reference
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded |
| 1 | Usage error |
| 2 | DNS resolution failure |
| 3 | Connection failure |
| 4 | Timeout |
| 5 | TLS handshake failure |
| 6 | HTTP status ≥ 400 (only with `--fail`) |
When multiple failure types occur, the process exits with the **highest** code.

View File

@@ -0,0 +1,139 @@
# Plan: Step 5 — Failure handling (Go)
## Context
The Go implementation of `latprobe` (Steps 04) is complete and committed: it
measures per-phase HTTP latency, supports multiple URLs, `-n` sampling with
min/avg/max, and `--json` output. Today failures are handled crudely — any error
prints a one-line message to stderr, the result is discarded, and `-n` sampling
aborts the whole URL on the first error.
We now want to handle failures deliberately, distinguishing the three real-world
causes the user identified:
1. **DNS failure** — non-existent record (NXDOMAIN / no such host)
2. **Connection failure / unresponsive host** — refused, unreachable, reset, or hanging (timeout)
3. **HTTP error status** — server answered, but with 4xx/5xx
The goal: when a probe fails, show *where* it broke (partial timing up to the
failure point), classify the cause, and signal it through a meaningful exit code.
### Confirmed design decisions
- **Exit codes — distinct per failure type** (see table below).
- **Default timeout: 10s**, overridable via `--timeout`.
- **`-n` sampling on a network failure: continue**, aggregate the successful
samples, and report the failure count + cause. HTTP 4xx/5xx are valid
measurements and aggregate normally (they are not network failures).
- **`--fail` flag** (curl-style): HTTP status ≥ 400 only affects the exit code
when `--fail` is set; without it, a 4xx/5xx is reported but exit stays 0.
### Exit code map
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded (and, without `--fail`, any HTTP status) |
| 1 | Usage error (no URLs, bad flags) |
| 2 | DNS resolution failure |
| 3 | Connection failure (refused / unreachable / reset) |
| 4 | Timeout (exceeded `--timeout`) |
| 5 | TLS handshake failure |
| 6 | HTTP error status ≥ 400 (only when `--fail` is set) |
When multiple URLs/samples fail with different causes, the process exits with the
**highest** code encountered (deterministic, easy to document).
## Files to modify
- `go/internal/probe/probe.go` — failure classification + timeout option
- `go/main.go` — flags, sampling loop, exit codes, text + JSON rendering
- `docs/usage/step-5-failure-handling.md` — new user doc
- `CHANGELOG.md`, `README.md` — bookkeeping
- `docs/plans/2026-07-01-00-38-error-handling.md` — copy of this plan
## Implementation
### 1. `probe.go` — classification + timeout
Add an options struct and a failure category to `Result`:
```go
type Options struct {
Timeout time.Duration // 0 = no timeout
}
// FailPhase classifies a network failure: "dns", "connect", "timeout",
// "tls", or "request". Empty when the request reached a response (even 4xx/5xx).
```
Add `FailPhase string` to `Result`. Change signature to
`Measure(url string, opts Options) Result`.
- Capture DNS resolution error: in the `DNSDone` hook, save `info.Err` into a
local `dnsErr` (the `httptrace.DNSDoneInfo` already carries it — reuse, don't
re-resolve).
- Apply timeout: if `opts.Timeout > 0`, wrap the context with
`context.WithTimeout` (covers connect through body read); `defer cancel()`.
- On `Do()` error (or body-read error), classify into `FailPhase`:
1. `dnsErr != nil` or `errors.As(err, *net.DNSError)``"dns"`
2. `errors.Is(err, context.DeadlineExceeded)` or a `net.Error` with
`Timeout()==true``"timeout"`
3. `tlsStart` set but `tlsDone` zero → `"tls"`
4. otherwise → `"connect"`
(request-construction error → `"request"`.)
- Keep populating whatever phases completed before the failure (the existing
zero-checks already do this) so partial timing is preserved.
### 2. `main.go` — flags, loop, exit codes, rendering
**Flags:** add `--timeout` (duration, default `10s`) and `--fail` (bool).
Pass `probe.Options{Timeout: *timeout}` into `Measure`.
**Per-URL collection** (replaces `collectSamples`): run all `*count` samples
without aborting. Split into:
- `succeeded []probe.Result` (Err == nil) → fed to `probe.Summarize`
- `failures` grouped by `FailPhase` with a count and a representative message
Track the worst exit code across all URLs. HTTP status ≥ 400 contributes code 6
only when `--fail` is set.
**Text rendering:**
- All samples succeeded → unchanged (`printResult` / `printAggregate`).
- Some failed (`-n`) → aggregate header gains `, X failed`, followed by a
`Failures:` summary line, e.g. `Failures: 2 × connect (connection refused)`.
- All failed → header `URL (FAILED, 0/N succeeded)` plus partial phases from
the last attempt (if any) and the `Failures:` summary.
- Single sample failure → `URL (FAILED)`, partial phases, then
`✗ <phase>: <message>`.
**JSON rendering:** extend `jsonEntry` with:
```go
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []jsonError `json:"errors,omitempty"` // {phase, count, message}
```
`phases` is emitted only when there is ≥1 successful sample; `status` is 0 when
no sample produced a response.
## Verification
```sh
cd go && go build ./... && go vet ./...
```
Manual cases (each should show partial timing where applicable + correct exit code):
| Case | Command | Expect |
|------|---------|--------|
| DNS failure | `./latprobe https://nonexistent.invalid; echo $?` | DNS error, exit 2 |
| Connection refused | `./latprobe http://localhost:1; echo $?` | connect error, exit 3 |
| Timeout | `./latprobe --timeout 1s https://example.com:81; echo $?` | timeout, exit 4 |
| HTTP error, default | `./latprobe https://httpbin.org/status/500; echo $?` | shows 500, exit 0 |
| HTTP error, --fail | `./latprobe --fail https://httpbin.org/status/404; echo $?` | shows 404, exit 6 |
| Mixed sampling | `./latprobe -n 5 https://example.com` (with transient failures) | aggregates successes, reports fail count |
| JSON failure | `./latprobe --json https://nonexistent.invalid \| jq .` | valid JSON with `errors` array |
| Success regression | `./latprobe -n 3 https://example.com https://www.google.com` | unchanged from Step 3 |
Confirm partial phases appear (e.g. a TLS failure still shows DNS + connect).
## Bookkeeping at execution time
1. Copy this plan to `docs/plans/2026-07-01-00-38-error-handling.md`.
2. Add `docs/usage/step-5-failure-handling.md`.
3. Append a Step 5 entry to `CHANGELOG.md`; add a Step 5 row to `README.md`.

View File

@@ -0,0 +1,125 @@
# Plan: Step 6 — Integration tests (Go)
## Context
The Go `latprobe` tool is feature-complete (Steps 05): per-phase latency,
multiple URLs, `-n` sampling, `--json`, and classified failure handling with
distinct exit codes. There are currently **no automated tests** — every check so
far has been manual.
We want integration tests that demonstrate the application's behaviour across a
**successful run and every failure mode**: HTTP success (200), HTTP error status
(404/500), DNS failure, connection refused, timeout, and TLS handshake failure —
asserting on both the rendered output and the exit code.
The user chose **both test layers**: fast in-process tests for breadth, plus a
few subprocess smoke tests that exercise the real compiled binary.
## Prerequisite refactor (makes the CLI testable)
`main()` currently uses the global `flag` package, prints directly to
`os.Stdout`/`os.Stderr`, and calls `os.Exit` — none of which is testable.
Extract the logic into a pure, injectable function in `go/main.go`:
```go
func run(args []string, stdout, stderr io.Writer) int { ... }
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
```
- Use a local `flag.NewFlagSet("latprobe", flag.ContinueOnError)` with
`fs.SetOutput(stderr)`; on parse error return `exitUsage`.
- Replace every `fmt.Print*` with `fmt.Fprint*(stdout/stderr, …)`.
- Thread an `io.Writer` through `printURL`, `printResult`, `printAggregate`,
`printFailureSummary`.
- Return the exit code instead of calling `os.Exit`.
This is a mechanical change; no behaviour changes.
## Small fix surfaced by the TLS test — `go/internal/probe/probe.go`
The current `classifyErr` detects TLS failure via "tlsStart set but tlsDone
zero". But Go's `httptrace` calls `TLSHandshakeDone` **with the error** on a
failed handshake, so `tlsDone` is set even on a cert error — meaning a TLS
failure is currently misclassified as `connect`.
Fix (mirrors the existing `dnsErr` capture): record the handshake error in the
`TLSHandshakeDone` hook into a local `tlsErr`, and in `classifyErr` return
`"tls"` when `tlsErr != nil`. Classification order: dns → timeout → tls →
connect (so a deadline during the handshake still classifies as `timeout`).
## Test approach
Stdlib only (`testing`, `net/http/httptest`, `os/exec`, `encoding/json`) — no
third-party deps, consistent with the project.
### How each case is triggered (deterministically)
| Case | Trigger |
|------|---------|
| Success 200 | `httptest.NewServer` returning 200 |
| HTTP 404 / 500 | `httptest.NewServer` returning the status |
| DNS failure | URL with reserved `.invalid` TLD (RFC 6761 — always NXDOMAIN) |
| Connection refused | `net.Listen` on `127.0.0.1:0`, capture addr, `Close()`, use that addr |
| Timeout | server handler blocks on `<-r.Context().Done()`; client `--timeout 200ms` (handler returns as soon as the client disconnects, so `Close()` doesn't hang) |
| TLS failure | `httptest.NewTLSServer` (self-signed cert) → default client rejects → cert error |
### Layer 1 — in-process (`go/run_test.go`, `package main`)
Table-driven tests calling `run(args, &stdoutBuf, &stderrBuf)` and asserting on
the returned exit code and output substrings. Cases:
- success 200 → exit 0; output contains `Total`, `DNS lookup`
- 500 without `--fail` → exit 0; output contains `(500)`
- 404 with `--fail` → exit 6; output contains `404 ✗`
- DNS failure → exit 2; output contains `✗ dns:`
- connection refused → exit 3; output contains `✗ connect:`
- timeout (`--timeout 200ms`) → exit 4; output contains `✗ timeout:`
- TLS failure → exit 5; output contains `✗ tls:`
- multiple URLs, mixed (200 + `.invalid`) → exit = highest (2)
- `-n 3` success → exit 0; output contains `3 samples`
- no args → exit 1; stderr contains usage
- `--json` success → valid JSON, `phases.total` present, `failed == 0`
- `--json` DNS failure → valid JSON, `errors[0].phase == "dns"`, `succeeded == 0`
JSON cases unmarshal `stdout` into the `[]jsonEntry` shape (or a mirror struct)
and assert on fields — verifying phase presence without brittle text matching.
### Layer 2 — subprocess smoke tests (`go/cli_test.go`, `package main`)
`TestMain` builds the binary once with `go build -o <tmp>/latprobe` and stores
the path; tests `exec.Command` it and read exit code via `*exec.ExitError`.
A small representative set (real `os.Exit` path, real binary):
- success against a local `httptest` server → exit 0, stdout has `Total`
- DNS failure (`https://*.invalid`) → exit 2
- `--json` DNS failure → stdout parses as JSON with an `errors` entry
## Files
- `go/main.go` — refactor to `run(...) int` (+ thread writer through printers)
- `go/internal/probe/probe.go` — capture `tlsErr`, fix `classifyErr`
- `go/run_test.go` — new, in-process integration matrix
- `go/cli_test.go` — new, `TestMain` + subprocess smoke tests
- `docs/usage/step-6-integration-tests.md` — how to run the tests
- `CHANGELOG.md`, `README.md` — bookkeeping
- `docs/plans/2026-07-01-00-49-integration-tests.md` — copy of this plan
## Verification
```sh
cd go
go build ./...
go vet ./...
go test ./... # all integration + subprocess tests pass
go test -v ./... # human-readable per-case results showing behaviour
```
Confirm the matrix covers exit codes 06 and that a deliberately broken
classification (e.g. revert the TLS fix) makes the TLS case fail — proving the
tests actually assert behaviour.
## Bookkeeping at execution time
1. Copy this plan to `docs/plans/2026-07-01-00-49-integration-tests.md`.
2. Add `docs/usage/step-6-integration-tests.md`.
3. Append a Step 6 entry to `CHANGELOG.md`; add a Step 6 row to `README.md`.

View File

@@ -0,0 +1,90 @@
# Plan: Root Makefile
## Context
The project has grown to a multi-step Go implementation with a test suite, and a
Python port is planned. Common workflows (build, test, vet, coverage) are
currently typed by hand with `cd go && go ...`. A root `Makefile` gives a single,
discoverable entry point for these tasks and a place to hang the future Python
targets.
Decisions confirmed with the user:
- Include **all** proposed targets: core set + `cover` + `tidy`/`install` + `lint`.
- **Namespaced** naming (`go-build`, `go-test`, …) with umbrella targets
(`build`, `test`, …) that delegate to the language-specific ones, so `py-*`
can slot in cleanly during the Python port.
Environment: GNU Make 3.81, Go 1.26.4, `golangci-lint` installed. Go commands use
`go -C go …` (Go 1.20+ directory flag) to avoid `cd`.
## File: `Makefile` (repo root)
Variables:
```make
GO_DIR := go
BINARY := latprobe
ARGS ?=
.DEFAULT_GOAL := help
```
### Umbrella targets (delegate now to Go; Python added later)
| Target | Delegates to | Notes |
|--------|--------------|-------|
| `help` | — | Default. Auto-generated from `##` comments. |
| `build` | `go-build` | `py-build` appended during Python port |
| `test` | `go-test` | `py-test` appended later |
| `check` | `go-check` | fmt + vet + test gate |
| `fmt` | `go-fmt` | |
| `vet` | `go-vet` | |
| `clean` | `go-clean` | |
| `all` | `check build` | |
A comment marks where `py-*` will be added (e.g. `test: go-test # + py-test later`).
### Go targets
| Target | Command |
|--------|---------|
| `go-build` | `go -C $(GO_DIR) build -o $(BINARY) .` |
| `go-run` | `go -C $(GO_DIR) run . $(ARGS)` |
| `go-test` | `go -C $(GO_DIR) test ./...` |
| `go-test-verbose` | `go -C $(GO_DIR) test -v ./...` |
| `go-fmt` | `gofmt -w $(GO_DIR)` |
| `go-vet` | `go -C $(GO_DIR) vet ./...` |
| `go-check` | depends on `go-fmt go-vet go-test` |
| `go-cover` | `go -C $(GO_DIR) test -coverprofile=coverage.out ./...` then `go -C $(GO_DIR) tool cover -html=coverage.out -o coverage.html` |
| `go-tidy` | `go -C $(GO_DIR) mod tidy` |
| `go-install` | `go -C $(GO_DIR) install .` |
| `go-lint` | guard for `golangci-lint` presence, then `cd $(GO_DIR) && golangci-lint run` |
| `go-clean` | `rm -f $(GO_DIR)/$(BINARY) $(GO_DIR)/coverage.out $(GO_DIR)/coverage.html` |
Details:
- `make run ARGS="https://example.com -n 3"` passes flags through.
- All targets listed in `.PHONY`.
- `help` recipe: `grep`/`awk` over `$(MAKEFILE_LIST)` printing `target ## description`
(works on GNU Make 3.81).
- `go-lint` guard:
```make
@command -v golangci-lint >/dev/null 2>&1 || { echo "golangci-lint not installed: https://golangci-lint.run"; exit 1; }
```
## Other changes
- `.gitignore`: add `go/coverage.out` and `go/coverage.html`.
- `docs/usage/makefile.md`: new user doc — target table + examples.
- `CHANGELOG.md`: timestamped entry.
- `README.md`: short "Development" note pointing at `make help`.
- `docs/plans/2026-07-01-01-05-makefile.md`: copy of this plan.
## Verification
```sh
make help # lists all targets with descriptions
make build # produces go/latprobe
make run ARGS="https://example.com"
make test # go test ./...
make go-test-verbose # per-test PASS/FAIL output
make check # fmt + vet + test
make go-cover # writes go/coverage.html
make go-lint # runs golangci-lint (installed)
make clean # removes binary + coverage artifacts
```
Confirm `make` with no args shows help, and that `clean` leaves the tree as
`git status` clean (no tracked files removed).

View File

@@ -0,0 +1,140 @@
# Plan: Concurrency (Go, Step 7)
## Context
Large probe runs are slow. The example matrices in `docs/custom-usage-examples.md`
are 15 URLs × 10 samples = 150 sequential HTTP requests, dominated by summed
network round-trips, so a single run takes minutes. Execution is currently
strictly sequential: `run()` loops over URLs and, for each, `runSamples()` loops
`-n` times calling `probe.Measure()`.
We want to cut wall-clock time by probing multiple **URLs** in parallel, while
keeping measurement honest. Concurrency and latency accuracy are in tension:
firing many requests at once makes them contend for the local NIC/CPU/resolver
and inflates timings. The decision (confirmed with the user) is to parallelize
**only across URLs** — the samples of a single URL stay sequential so each URL's
min/avg/max remains a clean, self-consistent measurement.
Confirmed decisions:
- **Granularity:** across URLs only; samples sequential per URL. Unit of work =
one URL with its full `-n` sample set.
- **Default concurrency:** auto when `-c` is not given → `min(numURLs, 8)`.
`-c 1` forces fully sequential (most accurate); `-c N` sets an explicit cap.
- **Output:** buffered and printed in original input order (deterministic;
identical layout to today, for both text and JSON).
## Files to modify
### `go/main.go` — core change
1. **New flag** (mirror the `-n`/`--count` pattern at lines 7879):
```go
conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)")
fs.IntVar(conc, "c", 0, "max URLs probed in parallel (shorthand)")
```
Add `-c, --concurrency` to `usageText` (around lines 2227).
2. **Resolve effective concurrency** after the empty-URL check (line 96):
```go
const defaultMaxConc = 8
workers := *conc
if workers <= 0 {
workers = min(len(urls), defaultMaxConc) // auto
}
workers = min(workers, len(urls)) // never exceed work units
if workers < 1 {
workers = 1
}
```
(Go 1.26 has builtin `min`.)
3. **Split `run()` into measure phase (concurrent) + render phase (sequential).**
Introduce a small result holder:
```go
type urlResult struct {
url string
succeeded, failed []probe.Result
}
```
Measure phase — bounded worker pool via a semaphore channel + `sync.WaitGroup`,
each goroutine writing only its own preallocated slot (no shared mutable state,
so no mutex / data race):
```go
results := make([]urlResult, len(urls))
sem := make(chan struct{}, workers)
var wg sync.WaitGroup
for i, rawURL := range urls {
wg.Add(1)
go func(i int, rawURL string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
s, f := runSamples(rawURL, *count, opts)
results[i] = urlResult{rawURL, s, f}
}(i, rawURL)
}
wg.Wait()
```
Render phase — the existing per-URL loop body (lines 102127), unchanged in
behaviour, now reading from `results[i]` instead of calling `runSamples`
inline. `worstCode` accumulation, JSON entry building, and ordered text
printing all stay identical. JSON encode block (129136) and `return worstCode`
unchanged.
`runSamples`, `printURL`, `buildJSONEntry`, and all output helpers stay as-is.
Concurrency safety: `probe.Measure` uses `http.DefaultClient`, which is safe for
concurrent use; distinct URLs hit distinct hosts so the shared transport pool is
not a correctness concern. Output order is deterministic because rendering reads
the indexed `results` slice after `wg.Wait()`.
### `go/run_test.go` — add coverage
- `TestRunConcurrentOrder`: spin up several `httptest` servers (reuse existing
`statusSrv` helper), pass them with `-c` larger than 1, and assert the printed
blocks appear in **input order** and the exit code matches the sequential run.
- Add a mixed success/failure case under high `-c` to confirm `worstCode`
aggregation is unaffected by parallelism.
### `Makefile` + `docs/usage/makefile.md`
- Add `go-test-race`: `go -C $(GO_DIR) test -race ./...` (with `##` help comment,
added to `.PHONY`). Document it in the Go targets table. Leave `check` as-is
(race run kept as an explicit opt-in target).
### Docs & conventions
- `docs/usage/step-7-concurrency.md` (new): explain the `-c`/`--concurrency`
flag, the auto default (`min(numURLs, 8)`), the across-URLs-only model, the
accuracy trade-off (use `-c 1` for the most precise numbers), and a worked
example with before/after timing.
- `README.md`: add `-c, --concurrency` to the Usage flags block; add **Step 7 —
Concurrency** row (✅) to the Go roadmap table.
- `CHANGELOG.md`: new top entry, `2026-07-01 01:23 — Concurrency (Go, Step 7)`.
- `docs/custom-usage-examples.md`: note near the top that runs now parallelize
across URLs by default and that `-c 1` restores serial timing; revise the
Example 2/3/9 runtime estimates to reflect the speedup.
- `docs/plans/2026-07-01-01-23-concurrency.md`: copy of this plan (per CLAUDE.md).
## Verification
```sh
make build
# Auto concurrency (defaults to min(numURLs,8)) — should be much faster than before:
time ./go/latprobe -n 5 https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au
# Forced serial for comparison / accuracy:
time ./go/latprobe -c 1 -n 5 https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au
# Output order is deterministic regardless of -c:
./go/latprobe -c 8 https://www.google.com https://www.bbc.co.uk https://www.abc.net.au
# JSON array order also matches input order:
./go/latprobe --json -c 8 https://www.google.com https://www.bbc.co.uk | jq '.[].url'
make test # full suite incl. new ordering tests
make go-test-race # data-race detector must report clean
make check # fmt + vet + test gate
```
Confirm: identical output (modulo timing numbers) between `-c 1` and default for
the same URL set; exit codes unchanged; `-race` clean.

View File

@@ -0,0 +1,47 @@
# Python Port — Step 1: `simple.py`
## Goal
The simplest possible latency-checking script: read a plain-text list of sites,
issue a GET to each, report whether it was reachable and how long it took.
No phase breakdown, no flags beyond the config-file path. ~50 lines.
## Input
`python simple.py [sites.txt]`
- Positional argument: path to the plain-text config (default: `sites.txt` in cwd).
- Config format: one URL per line; `#` introduces a comment; blank lines ignored.
Forward-compatible with trailing `key=value` tokens that future steps may add
(the parser strips everything after the first whitespace token when reading the URL).
## Output
One line per site, aligned in three columns:
```
OK 147.11 ms https://example.com
FAIL (connection refused) http://localhost:8080
FAIL (name or service not known) https://nonexistent.invalid
```
- `OK` / `FAIL` tag (7 chars padded), ms formatted as `%.2f ms`, then URL.
- All output goes to `stdout`.
- Exit `0` if every site returned a response (any HTTP status); `1` if any failed.
## Implementation
- `urllib.request.urlopen(url, timeout=10)` wrapped in a try/except.
- Timing: `time.perf_counter()` bracketed around `urlopen` + `resp.read()` (drain
body so the number is real wall-clock including transfer).
- Catch `urllib.error.URLError` and `Exception` for any network failure; extract
the reason string for the FAIL message.
- Stdlib only: `urllib.request`, `urllib.error`, `time`, `sys`.
## Files
- `python/simple.py` — the script
- `python/sites.txt` — example config (committed as a sample)
- `docs/usage/py-simple.md` — user-facing doc
- Makefile `py-simple-run` and `py-test` (empty stub) targets, wired into umbrella `test`
- CHANGELOG.md entry

View File

@@ -0,0 +1,68 @@
# Python Port — Step 2: `phases.py`
## Goal
Introduce the manual per-phase timing technique — the Python answer to Go's
`net/http/httptrace`. A single self-contained script that hand-drives a raw
socket for each URL and times each phase individually with `time.perf_counter()`.
## Why raw sockets
Python's `urllib` / `httpx` / `requests` give no per-phase callbacks, unlike
Go's `httptrace.ClientTrace`. The only way to time DNS, TCP connect, TLS
handshake, TTFB, and transfer independently is to drive the connection at the
socket level:
- `socket.getaddrinfo()` → DNS
- `sock.connect()` → TCP
- `ssl.SSLContext.wrap_socket()` → TLS (HTTPS only)
- `sock.sendall(request)` + `sock.recv()` → TTFB
- drain to EOF → Transfer
## Input
- Bare URL(s) as positional args: `python phases.py https://example.com`
- Or a plain-text config file: `python phases.py configs/all-ok.txt`
(detected by whether the first arg starts with `http://` / `https://`)
## Output
Mirrors Go's single-sample text layout for each URL:
```
https://example.com (200)
DNS lookup : 18.21 ms
TCP connect : 10.12 ms
TLS handshake : 36.11 ms
Server (TTFB) : 21.95 ms
Transfer : 0.18 ms
─────────────────────────────
Total : 88.00 ms
```
- TLS row omitted for `http://` URLs.
- On failure: header shows `(FAILED)`, partial phases shown, error at the end.
- Multiple URLs separated by a blank line.
## Error classification
Mirrors Go's priority order (dns → timeout → tls → connect):
- `socket.gaierror``"dns"`
- `socket.timeout` / `TimeoutError``"timeout"` (regardless of phase)
- `ssl.SSLError` or `OSError` during TLS wrap → `"tls"`
- `ConnectionRefusedError` / other `OSError` during connect → `"connect"`
- Error after first byte → `"transfer"`
Partial phases are preserved on failure (same invariant as Go).
## Known limitations (documented in usage doc)
- No redirect following (3xx responses are reported with their raw status code).
- `Connection: close` + read-to-EOF; no keep-alive, no HTTP/2.
- Timeout hard-coded at 10 s (no `--timeout` flag — that's in the full version).
- Bodies fully drained to get accurate Transfer timing.
## Files
- `python/phases.py` — the script
- `docs/usage/py-phases.md` — user-facing doc
- CHANGELOG.md entry

View File

@@ -0,0 +1,139 @@
# Plan: Full `latprobe` Python Package
**Timestamp:** 2026-07-01 12:23
**Status:** Complete
## Goal
Build `python/latprobe/` — a packaged Python port of the Go `latprobe` CLI that
mirrors its behavior: flags, sampling, cross-URL concurrency, JSON output, and
distinct exit codes.
## Package layout
```
python/
latprobe/
__init__.py — package marker
__main__.py — sys.exit(cli.run(sys.argv[1:], sys.stdout, sys.stderr))
probe.py — measure(url, opts) -> Result (raw socket timing)
aggregate.py — summarize(results) -> Aggregate (min/avg/max per phase)
cli.py — run(args, stdout, stderr) -> int (argparse + rendering)
duration.py — parse_duration("10s") -> float seconds
tests/
test_probe.py — unittest: success, DNS fail, connect refused, timeout, bad scheme
test_cli.py — unittest: drives cli.run() in-process, all branches + JSON
```
## Key design decisions
- **`probe.py`**: reuses the raw-socket technique from `phases.py` with an
`Options(timeout)` parameter. Partial phases preserved on failure.
- **`aggregate.py`**: `summarize(List[Result]) -> Aggregate` computes
`PhaseStats(min_ms, avg_ms, max_ms, present)` per phase. Uses only `succeeded`
results.
- **`duration.py`**: parses `"10s"`, `"500ms"`, `"2m"`, bare numbers → float seconds.
- **`cli.py`**: injectable `run(args, stdout, stderr) -> int` seam for testability.
- `_Parser` subclasses `argparse.ArgumentParser` with custom `print_help`,
`print_usage`, `error`, `exit` to redirect all output to injected streams
and raise `_ArgExit` instead of calling `sys.exit`.
- `ThreadPoolExecutor` for cross-URL concurrency; `executor.map` preserves
input order.
- Four text-rendering branches: single-success, multi-sample aggregate,
all-failed, mixed.
- Failure summary groups errors by `(phase, message)` preserving insertion
order; `N ×` prefix when grouped.
## CLI flags
| Flag | Default | Description |
|------|---------|-------------|
| `urls` (positional, `+`) | required | One or more URLs |
| `-n`/`--count` | 1 | Requests per URL |
| `-c`/`--concurrency` | 0 (auto) | Max parallel URLs; auto = `min(numURLs, 8)` |
| `--timeout` | `10s` | Per-request timeout (parsed via `duration.py`) |
| `--fail` | off | Exit non-zero on HTTP status >= 400 |
| `--json` | off | Output as JSON array instead of text |
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded (with `--fail`: no 4xx/5xx) |
| 1 | Usage / argument error |
| 2 | DNS failure |
| 3 | TCP connect failure |
| 4 | Timeout |
| 5 | TLS error |
| 6 | HTTP status >= 400 (`--fail` only) |
Worst (highest) code across all URLs wins.
## JSON schema
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 3,
"failed": 2,
"phases": {
"dns": {"min_ms": 1.23, "avg_ms": 1.45, "max_ms": 1.67},
"connect": {"min_ms": ...},
"tls": {"min_ms": ...},
"ttfb": {"min_ms": ...},
"transfer": {"min_ms": ...},
"total": {"min_ms": ...}
},
"errors": [
{"phase": "connect", "count": 2, "message": "..."}
]
}
]
```
- `phases` omitted when `succeeded == 0`
- Per-phase key omitted when no successful sample had that phase (e.g. `tls` for `http://`)
- `errors` omitted when `failed == 0`
## Text output format
Single sample:
```
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
```
Aggregate (-n 3):
```
https://example.com (200, 3 samples)
min avg max
DNS lookup : 2.36 ms 3.51 ms 5.00 ms
TCP connect : 10.25 ms 17.45 ms 31.61 ms
TLS handshake : 14.04 ms 41.60 ms 96.50 ms
Server (TTFB) : 63.30 ms 66.72 ms 69.78 ms
Transfer : 0.26 ms 0.38 ms 0.51 ms
─────────────────────────────────────────────────
Total : 101.33 ms 139.19 ms 211.99 ms
```
## Testing approach
- `test_probe.py`: real `http.server.HTTPServer` in a daemon thread; closed port
for connection-refused; `.invalid` TLD for DNS failure; black-hole socket (accepts
TCP, never sends data) to trigger TTFB timeout.
- `test_cli.py`: in-process `cli.run(args, io.StringIO(), io.StringIO())` — no
subprocess, fast. Covers: usage errors, single/aggregate text, failure exit codes,
worst-code accumulation, `--fail`, JSON schema, JSON ordering, JSON error grouping.
## Makefile changes
- `py-test`: added `PYTHONPATH=$(PY_DIR)` so tests can `import latprobe`; removed
`|| true` now that real tests exist.

View File

@@ -0,0 +1,505 @@
# Plan: `--verbose` / `-v` flag for `latprobe`
**Timestamp:** 2026-07-01 12:55
**Status:** Planned
## Goal
Add a `--verbose` / `-v` flag that surfaces diagnostic detail beyond timing:
which IP was used, what TLS version and cipher was negotiated, certificate
metadata (CN, expiry, issuer), and the most useful response headers. Useful
for debugging *why* a probe failed, not just *that* it did.
---
## What verbose mode shows
### Text output — successful HTTPS request
```
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
IP : 93.184.216.34
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=www.example.com valid until 2025-06-14 DigiCert Inc
Server : ECS (dcb/7F84)
Content-Type : text/html; charset=UTF-8
```
### Text output — TLS failure (with diagnostic cert pass)
```
https://expired.badssl.com/ (FAILED)
DNS lookup : 15.39 ms
TCP connect : 124.98 ms
TLS handshake : 305.30 ms
─────────────────────────────
Total : 473.17 ms
✗ tls: certificate verify failed: certificate has expired
IP : 104.154.89.105
Cert (unverified) : CN=*.badssl.com EXPIRED 2015-04-09 COMODO RSA Domain Validation
```
### Text output — DNS failure (minimal; nothing to show after DNS)
```
http://no.such.host.invalid (FAILED)
Total : 0.65 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
```
No verbose block — IP is unknown, no TLS, no headers.
### Text output — HTTP 4xx with redirect header
```
https://www.google.com/this-page-does-not-exist (404)
...
Total : 145.50 ms
IP : 142.251.36.4
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
Cert : CN=*.google.com valid until 2026-01-20 Google Trust Services
Server : gws
Content-Type : text/html; charset=UTF-8
```
### Text output — 3xx redirect (Location shown)
```
https://google.com (301)
...
IP : 142.251.36.4
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
Cert : CN=*.google.com valid until 2026-01-20 Google Trust Services
Location : https://www.google.com/
Server : gws
Content-Type : text/html; charset=UTF-8
```
### Aggregate verbose (multi-sample `-n N`)
```
https://example.com (200, 5 samples)
min avg max
...
Total : 97.10 ms 101.20 ms 109.80 ms
IP : 93.184.216.34
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=www.example.com valid until 2025-06-14 DigiCert Inc
Server : ECS (dcb/7F84)
Content-Type : text/html; charset=UTF-8
```
Verbose block taken from the **last successful sample**. If DNS resolves to
multiple IPs and different samples hit different addresses, they are listed as
`IP : 93.184.216.34, 93.184.216.35` (deduped, insertion-ordered).
---
## Verbose block format rules
- Label column: **14 chars**, left-padded with spaces, matching the phase labels.
- Separator: `" : "` (4 chars) — same as phase rows.
- Value: free-form string, no fixed width.
- Verbose block appears immediately after the last line of the standard block
(after `Total` or after the `✗ …` error line).
- Block is omitted entirely when there is nothing to show (e.g. pure DNS
failure where IP is unknown).
- No separator line before the verbose block — the visual break from `Total`
and `✗` is enough.
Label strings (14 chars each):
```
"IP " # resolved IP address
"TLS " # version + cipher + bits
"Cert " # CN, expiry, issuer (verified)
"Cert (unvrf.) " # same fields, cert not trusted (TLS failure)
"Location " # for 3xx responses
"Server " # Server response header
"Content-Type " # Content-Type response header
"X-Cache " # CDN cache status (if present)
"Via " # proxy chain (if present)
```
Additional headers shown only when present (see priority list in
`_VERBOSE_HEADERS` below).
---
## JSON extension
When `--verbose --json` are both set, each entry gets a top-level `"verbose"`
object (omitted when `--verbose` is absent):
```json
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": { ... },
"verbose": {
"ip": "93.184.216.34",
"tls_version": "TLSv1.3",
"tls_cipher": "TLS_AES_256_GCM_SHA384",
"tls_bits": 256,
"cert": {
"cn": "www.example.com",
"sans": ["www.example.com", "example.com"],
"expiry": "2025-06-14",
"issuer_cn": "DigiCert SHA2 Secure Server CA",
"verified": true
},
"headers": {
"Server": "ECS (dcb/7F84)",
"Content-Type": "text/html; charset=UTF-8",
"X-Cache": "HIT"
}
}
}
```
- `"verbose"` is omitted when `--verbose` is not set.
- `"cert"` is omitted for `http://` URLs (no TLS).
- For TLS failures: `"cert"` contains what the diagnostic pass found, with
`"verified": false`. If the diagnostic pass itself failed, `"cert"` is omitted.
- `"headers"` contains **all** parsed response headers (not just the priority
list used in text mode).
- When `succeeded == 0` the `"verbose"` object may still contain `"ip"` if DNS
resolved, but will lack `"tls_version"`, `"cert"`, and `"headers"`.
---
## Data model changes
### `probe.py` — new dataclasses
```python
@dataclass
class CertInfo:
cn: str = ""
sans: list[str] = field(default_factory=list)
expiry: str = "" # ISO date "YYYY-MM-DD"
issuer_cn: str = ""
verified: bool = False # True = TLS handshake passed verification
@dataclass
class VerboseDetail:
resolved_ip: str = ""
tls_version: str = ""
tls_cipher: str = ""
tls_bits: int = 0
cert: CertInfo | None = None
headers: dict[str, str] = field(default_factory=dict) # all parsed headers
```
### `probe.py` — `Options` change
```python
@dataclass
class Options:
timeout: float = 10.0
verbose: bool = False # NEW
```
### `probe.py` — `Result` change
```python
@dataclass
class Result:
...existing fields...
detail: VerboseDetail | None = None # NEW; None when opts.verbose=False
```
---
## Capture points in `probe.py`
### Resolved IP
After `socket.getaddrinfo` succeeds:
```python
if opts.verbose:
r.detail = VerboseDetail(resolved_ip=str(infos[0][4][0]))
```
`infos[0][4][0]` is the first resolved address string (works for both IPv4
and IPv6 since `[4]` is the full sockaddr tuple and `[0]` is the address).
### TLS version, cipher, and certificate
After `ctx.wrap_socket()` succeeds:
```python
if opts.verbose and r.detail:
r.detail.tls_version = sock.version() or ""
cipher_name, _, bits = sock.cipher()
r.detail.tls_cipher = cipher_name or ""
r.detail.tls_bits = bits or 0
r.detail.cert = _parse_cert(sock.getpeercert(), verified=True)
```
`sock.getpeercert()` returns a dict with `subject`, `issuer`, `notAfter`,
`subjectAltName` when called after a successful handshake.
### Response headers
The current code reads the first non-empty chunk then drains the body.
For verbose mode (and more correctly in general), the probe must accumulate
bytes until `\r\n\r\n` is found before recording `t_first_byte`, so that the
full header section is available.
Modified TTFB loop (replaces the current `while not first_chunk:` block):
```python
buf = b""
t_first_byte: float | None = None
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
raise OSError("server closed connection before headers complete")
if t_first_byte is None:
t_first_byte = time.perf_counter() # first byte — unchanged semantics
buf += chunk
t_first_byte = t_first_byte or time.perf_counter()
r.ttfb = _p(t_wrote, t_first_byte)
r.status_code = _parse_status(buf)
if opts.verbose and r.detail:
r.detail.headers = _parse_response_headers(buf)
```
TTFB semantics are **unchanged**`t_first_byte` is captured on the first
`recv()` call that returns data, not after all headers arrive.
The transfer drain loop needs to also drain `buf` bytes that follow
`\r\n\r\n` (the body prefix already read into the buffer).
### Certificate inspection on TLS failure
When `opts.verbose=True` and `r.fail_phase == "tls"`, call a helper that
opens a second, non-verifying connection purely to fetch the certificate:
```python
def _fetch_cert_unverified(
host: str, port: int, addr, family: int, timeout: float
) -> CertInfo | None:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = socket.socket(family, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect(addr)
ssl_sock = ctx.wrap_socket(sock, server_hostname=host)
peer = ssl_sock.getpeercert()
ssl_sock.close()
return _parse_cert(peer, verified=False) if peer else None
except OSError:
return None
finally:
sock.close()
```
This runs **after** `measure()` records timing, so it does not affect any
phase durations. It adds a short additional wall-clock delay (one more TLS
round trip) only in verbose mode on TLS failures — acceptable for a diagnostic
path.
### Certificate parsing helper
```python
from email.utils import parsedate # for "Jun 14 00:00:00 2025 GMT"
import datetime
def _parse_cert(peer: dict, verified: bool) -> CertInfo:
def _cn(rdns):
for rdn in rdns:
for k, v in rdn:
if k == "commonName":
return v
return ""
sans = [v for k, v in peer.get("subjectAltName", ()) if k == "DNS"]
expiry = ""
not_after = peer.get("notAfter", "")
if not_after:
t = parsedate(not_after) # returns time.struct_time or None
if t:
expiry = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d}"
return CertInfo(
cn = _cn(peer.get("subject", ())),
sans = sans,
expiry = expiry,
issuer_cn = _cn(peer.get("issuer", ())),
verified = verified,
)
```
### Response header parsing helper
```python
_VERBOSE_HEADERS = [
"Location", "Server", "Content-Type", "X-Cache",
"CF-Cache-Status", "Cache-Control", "Via",
"X-Powered-By", "Strict-Transport-Security",
]
def _parse_response_headers(buf: bytes) -> dict[str, str]:
"""Parse all headers from a raw HTTP response buffer (up to \\r\\n\\r\\n)."""
header_section = buf.split(b"\r\n\r\n", 1)[0]
lines = header_section.decode("latin-1", errors="replace").splitlines()
headers: dict[str, str] = {}
for line in lines[1:]: # skip status line
if ":" in line:
name, _, value = line.partition(":")
headers[name.strip()] = value.strip()
return headers
```
---
## `cli.py` changes
### New flag
```python
parser.add_argument(
"-v", "--verbose", action="store_true",
help="show resolved IP, TLS details, certificate, and response headers",
)
```
Passed into `Options(verbose=ns.verbose)`.
### Text rendering — verbose block
```python
def _print_verbose_block(detail: VerboseDetail, out: IO) -> None:
rows: list[tuple[str, str]] = []
if detail.resolved_ip:
rows.append(("IP ", detail.resolved_ip))
if detail.tls_version:
tls_val = detail.tls_version
if detail.tls_cipher:
tls_val += f" {detail.tls_cipher}"
if detail.tls_bits:
tls_val += f" {detail.tls_bits} bit"
rows.append(("TLS ", tls_val))
if detail.cert:
c = detail.cert
label = "Cert (unvrf.) " if not c.verified else "Cert "
parts = [f"CN={c.cn}"] if c.cn else []
if c.expiry:
tag = "EXPIRED" if _cert_expired(c.expiry) else "valid until"
parts.append(f"{tag} {c.expiry}")
if c.issuer_cn:
parts.append(c.issuer_cn)
rows.append((label, " ".join(parts)))
# Response headers — show priority list, in order, if present
for name in _VERBOSE_HEADERS:
val = detail.headers.get(name)
if val:
label = f"{name:<14}"
rows.append((label, val))
for label, value in rows:
out.write(f" {label} : {value}\n")
```
`_cert_expired(expiry: str) -> bool` compares the ISO date string against
today's date using `datetime.date.fromisoformat`.
Call site: `_print_verbose_block` is called from `_print_single`,
`_print_all_failed`, and `_print_aggregate` — after the last line written by
each function — but only when `result.detail` (or `agg`'s last-sample detail)
is not `None`.
For aggregate rendering: collect `detail` from the last successful result
before calling `summarize`. Pass it alongside `agg` to `_print_aggregate`.
### JSON extension
In `_build_json_entry`, when `detail` is not `None`:
```python
v: dict = {}
if detail.resolved_ip:
v["ip"] = detail.resolved_ip
if detail.tls_version:
v["tls_version"] = detail.tls_version
v["tls_cipher"] = detail.tls_cipher
v["tls_bits"] = detail.tls_bits
if detail.cert:
c = detail.cert
v["cert"] = {
"cn": c.cn,
"sans": c.sans,
"expiry": c.expiry,
"issuer_cn": c.issuer_cn,
"verified": c.verified,
}
if detail.headers:
v["headers"] = dict(detail.headers)
if v:
entry["verbose"] = v
```
---
## Files changed
| File | Change |
|------|--------|
| `python/latprobe/probe.py` | `CertInfo`, `VerboseDetail` dataclasses; `Options.verbose`; `Result.detail`; capture points for IP, TLS, cert, headers; `_fetch_cert_unverified`; `_parse_cert`; `_parse_response_headers`; modified TTFB loop |
| `python/latprobe/cli.py` | `-v`/`--verbose` flag; `_print_verbose_block`; verbose call sites in all 3 print functions; `_build_json_entry` extension; `_cert_expired` helper |
| `python/tests/test_probe.py` | Tests for verbose fields on success (IP, TLS, cert, headers), on TLS failure (unverified cert), and that non-verbose leaves `detail=None` |
| `python/tests/test_cli.py` | Tests for `--verbose` text layout (IP/TLS/cert/header rows), `--verbose --json` schema, verbose absent when flag not set |
| `python/tests/test_integration.py` | Tests for real site verbose output (expiry date format, header values, actual IPs), TLS failure cert inspection against badssl.com |
| `docs/usage/py-latprobe.md` | New `--verbose` section with example output |
---
## Implementation order
1. **`probe.py` — data model**: add `CertInfo`, `VerboseDetail`, `Options.verbose`,
`Result.detail`. No behaviour change yet — all `None` by default.
2. **`probe.py` — capture IP**: set `r.detail.resolved_ip` after `getaddrinfo`.
Easiest capture; good checkpoint.
3. **`probe.py` — modify TTFB loop**: accumulate to `\r\n\r\n`, update body drain.
This is the riskiest change (touches the hot path); verify existing tests
still pass before continuing.
4. **`probe.py` — parse headers**: add `_parse_response_headers`, `_VERBOSE_HEADERS`;
populate `r.detail.headers` when verbose.
5. **`probe.py` — TLS details on success**: add `_parse_cert`; populate
`tls_version`, `tls_cipher`, `tls_bits`, `cert` after `wrap_socket`.
6. **`probe.py` — TLS failure cert**: add `_fetch_cert_unverified`; call it at the
end of `measure` when `opts.verbose and r.fail_phase == "tls"`.
7. **`cli.py` — flag + text rendering**: add `-v`, `_print_verbose_block`,
`_cert_expired`; wire into all print functions.
8. **`cli.py` — JSON**: extend `_build_json_entry`.
9. **Tests**: hermetic tests for each capture point; CLI text/JSON tests;
integration tests against real sites.
10. **Docs**: update `docs/usage/py-latprobe.md`.
Steps 12 and 7 can be skipped ahead and demonstrated early (IP line shows up
immediately); steps 36 build the richer detail progressively.
---
## Known limitations / out of scope
- **Aggregate verbose from last sample only** — no per-sample IP list unless
sampling actually returned multiple distinct IPs (rare; only with round-robin
DNS between samples).
- **DNS timeout not controllable** — `getaddrinfo` does not accept a Python
timeout; the `--timeout` flag applies only from TCP connect onward. Already
documented in existing usage doc.
- **No redirect following** — 3xx responses show the `Location` header in the
verbose block but do not probe the target. Consistent with non-verbose
behaviour.
- **Diagnostic cert pass adds latency** — only on TLS failures in verbose mode;
documented inline in output (future: could suppress with a flag).
- **HTTP/2, HTTP/3 not supported** — raw socket drives HTTP/1.1 only; TLS
ALPN negotiation may result in some servers rejecting the connection. Out of
scope for this tool.

96
docs/usage/makefile.md Normal file
View File

@@ -0,0 +1,96 @@
# Makefile
The root `Makefile` provides a single entry point for all common development
tasks. Targets are namespaced by language (`go-*`, `py-*` when the Python port
lands) with short umbrella targets (`build`, `test`, …) that delegate to the
language-specific ones.
## Quick reference
```sh
make # same as: make help
make help # list all targets with descriptions
```
## Umbrella targets
| Target | Description |
|--------|-------------|
| `all` | Run `check` then `build` |
| `build` | Build the binary (→ `go-build`) |
| `test` | Run all tests (→ `go-test`) |
| `check` | Pre-commit gate: fmt + vet + test (→ `go-check`) |
| `fmt` | Format source (→ `go-fmt`) |
| `vet` | Static analysis (→ `go-vet`) |
| `clean` | Remove binary and coverage artifacts |
## Go targets
| Target | Description |
|--------|-------------|
| `go-build` | Compile `go/latprobe` |
| `go-run` | Build and run (pass flags via `ARGS=`) |
| `go-test` | `go test ./...` |
| `go-test-verbose` | `go test -v ./...` — per-case PASS/FAIL output |
| `go-test-race` | `go test -race ./...` — run tests with the data-race detector |
| `go-check` | `go-fmt` + `go-vet` + `go-test` |
| `go-cover` | Coverage report → `go/coverage.html` |
| `go-fmt` | `gofmt -w go/` |
| `go-vet` | `go vet ./...` |
| `go-tidy` | `go mod tidy` |
| `go-install` | Install binary to `$GOBIN` |
| `go-lint` | `golangci-lint run` (must be installed) |
| `go-clean` | Remove `go/latprobe`, `go/coverage.out`, `go/coverage.html` |
## Examples
```sh
# Build and run a quick probe
make go-run ARGS="https://example.com"
make go-run ARGS="-n 5 --json https://example.com https://www.google.com"
# Run the full test suite
make test
# Verbose output showing each test case
make go-test-verbose
# Run tests with the race detector (verify concurrency safety)
make go-test-race
# Pre-commit gate (format, vet, test all in one)
make check
# Generate and open coverage report
make go-cover
open go/coverage.html # macOS
# Lint
make go-lint
# Clean up artifacts
make clean
```
## Passing arguments to `go-run`
```sh
make go-run ARGS="<url> [url ...] [flags]"
# Examples
make go-run ARGS="https://example.com"
make go-run ARGS="--timeout 2s --fail https://example.com"
make go-run ARGS="--json -n 3 https://example.com https://www.google.com"
```
## Adding Python targets (when the port lands)
The umbrella targets are designed to accept Python targets alongside the Go ones.
The pattern will be:
```make
test: go-test py-test # py-test added here during the port
```
`go-lint` requires `golangci-lint` to be installed. If it's missing the target
prints an install link and exits 1.

294
docs/usage/py-latprobe.md Normal file
View 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 | 06 per failure class |
| Config file | yes | yes | no (URL args only) |

143
docs/usage/py-phases.md Normal file
View File

@@ -0,0 +1,143 @@
# `phases.py` — Per-Phase HTTP Latency Measurement
## What it does
Measures the latency of each phase of an HTTP request by hand-driving a raw
socket, timing each step individually with `time.perf_counter()`. This is the
Python answer to Go's `net/http/httptrace` — there is no equivalent callback
API in Python's stdlib, so we instrument at the socket level.
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 — server processing time |
| Transfer | First byte → EOF — body download time |
| Total | DNS start → body EOF — wall-clock end-to-end |
The TLS row is omitted automatically for `http://` URLs.
## Flags / arguments
```
python phases.py <url> [url ...]
python phases.py <config_file>
```
| Argument | Meaning |
|----------|---------|
| `<url> [url …]` | One or more URLs starting with `http://` or `https://` |
| `<config_file>` | Path to a plain-text URL list (same format as `simple.py`) |
Detection is automatic: if the first argument starts with `http://` or
`https://`, all arguments are treated as URLs; otherwise the single argument
is treated as a config file path.
**Exit codes:**
| Code | Meaning |
|------|---------|
| 0 | All URLs completed without a network error |
| 1 | One or more URLs failed, or a usage/config error |
HTTP error statuses (4xx, 5xx) do **not** set exit code 1 — the request
completed successfully at the network level. The status code is visible in
the output header.
## Example — single URL
```sh
python phases.py https://example.com
```
```
https://example.com (200)
DNS lookup : 21.74 ms
TCP connect : 10.79 ms
TLS handshake : 18.48 ms
Server (TTFB) : 73.72 ms
Transfer : 0.13 ms
─────────────────────────────
Total : 132.95 ms
```
## Example — HTTP URL (no TLS row)
```sh
python phases.py http://example.com
```
```
http://example.com (200)
DNS lookup : 3.37 ms
TCP connect : 13.51 ms
Server (TTFB) : 20.01 ms
Transfer : 2.85 ms
─────────────────────────────
Total : 39.77 ms
```
## Example — DNS failure (partial phases)
```sh
python phases.py https://no.such.host.invalid
```
```
https://no.such.host.invalid (FAILED)
─────────────────────────────
Total : 0.67 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
```
## Example — TLS failure (partial phases preserved)
```sh
python phases.py https://expired.badssl.com/
```
```
https://expired.badssl.com/ (FAILED)
DNS lookup : 28.32 ms
TCP connect : 129.01 ms
TLS handshake : 305.30 ms
─────────────────────────────
Total : 473.17 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired …
```
Note: DNS and TCP phases are populated even though the overall request failed.
This is the same behaviour as the Go tool — partial timing is preserved up to
the point of failure.
## Example — config file
```sh
python phases.py configs/all-ok.txt
```
Multiple URLs are separated by a blank line, matching Go's text output style.
## Using the error-path example configs
The `python/configs/` files from `simple.py` work identically with `phases.py`.
See [`python/configs/usage-phases.md`](../../python/configs/usage-phases.md) for
runnable shell commands and expected output for every config file, including a
side-by-side comparison of how `phases.py` and `simple.py` differ on HTTP 4xx
responses.
## Limitations
- **No redirect following.** 3xx responses are reported with their raw status
code; the redirect target is not probed. Use `latprobe/` (the full version)
or `simple.py` (which uses `urllib`, which follows redirects) if you need
the final destination's timing.
- **HTTP 1.1 + `Connection: close` only.** No keep-alive, no HTTP/2, no auth,
no custom headers beyond `Host` and `User-Agent`.
- **Timeout hard-coded at 10 s.** Use `latprobe/` for a `--timeout` flag.
- **No sampling.** Each URL is probed once. Use `latprobe/` for `-n` (min/avg/max).
- **Body is fully drained.** Transfer time includes reading the entire response
body, so it is real wall-clock transfer time.

79
docs/usage/py-simple.md Normal file
View File

@@ -0,0 +1,79 @@
# `simple.py` — Site Reachability Checker
## What it does
Reads a plain-text list of URLs, issues an HTTP GET to each one, and reports
whether the site responded and how long it took (full wall-clock time including
body download). This is the simplest possible latency check — one line of
output per site, nothing more.
## Flags / arguments
```
python simple.py [sites.txt]
```
| Argument | Default | Meaning |
|----------|---------|---------|
| `sites.txt` | `sites.txt` in the current directory | Path to the plain-text config file |
**Config file format:**
- One URL per line.
- Lines starting with `#` are comments and are ignored.
- Blank lines are ignored.
- Future `key=value` tokens after the URL (e.g. `https://x.com budget=200ms`)
are silently ignored, so the file format is forward-compatible.
**Exit codes:**
| Code | Meaning |
|------|---------|
| 0 | All sites responded |
| 1 | One or more sites failed, or a usage/config error |
## Example
**Config (`sites.txt`):**
```
https://example.com
https://www.google.com
# https://httpbin.org/get # uncomment to include
```
**Run:**
```sh
python simple.py sites.txt
```
**Expected output (latencies vary):**
```
OK 147.11 ms https://example.com
OK 83.42 ms https://www.google.com
```
**With an unreachable host:**
```
OK 147.11 ms https://example.com
FAIL (nodename nor servname provided, or not known) https://nonexistent.invalid
```
Exit code: `1`
## Example config files
`python/configs/` contains purpose-built config files targeting every distinct
failure class (DNS, connection-refused, timeout, TLS-cert errors, HTTP 4xx/5xx,
and a mixed scenario). Each file includes a comment explaining what it exercises.
See [`python/configs/usage-simple.md`](../../python/configs/usage-simple.md) for the runnable
shell commands and expected output for each file.
## Limitations
- Measures total wall-clock time (DNS + TCP + TLS + server + transfer) as a
single number. Use `phases.py` for a per-phase breakdown.
- Always uses GET; no auth, no custom headers, no redirect control.
- Timeout is hard-coded at 10 s. Use `phases.py` or `latprobe/` for a `--timeout`
flag.
- HTTP error statuses (4xx, 5xx) are reported as `FAIL``urllib.request.urlopen`
raises `HTTPError` for non-2xx responses, so a 404 is treated as a failure,
not a success.

View File

@@ -0,0 +1,148 @@
# Step 5 — Failure Handling
## What this step delivers
`latprobe` now handles all three real-world failure modes explicitly:
| Failure | Exit code | Behaviour |
|---------|-----------|-----------|
| DNS resolution failure | 2 | Partial timing (DNS duration) shown; classified as `dns` |
| Connection failure (refused / unreachable) | 3 | Partial timing shown; classified as `connect` |
| Timeout (exceeded `--timeout`) | 4 | Partial timing shown; classified as `timeout` |
| TLS handshake failure | 5 | Partial timing shown; classified as `tls` |
| HTTP status ≥ 400 (with `--fail`) | 6 | Full timing shown, status annotated with ✗ |
| HTTP status ≥ 400 (without `--fail`) | 0 | Full timing shown, status visible but exit is 0 |
With `-n` sampling, network failures do **not** abort the run — remaining samples continue and successful ones are aggregated normally. The failure count and cause are reported alongside the aggregate.
When multiple URLs or failure types occur, the process exits with the **highest** exit code encountered.
## New flags
| Flag | Default | Description |
|------|---------|-------------|
| `--timeout` | `10s` | Per-request timeout (e.g. `500ms`, `2s`, `30s`) |
| `--fail` | off | Exit with code 6 when any URL returns HTTP status ≥ 400 |
## Examples
### DNS failure
```sh
$ ./latprobe https://nonexistent.invalid; echo $?
https://nonexistent.invalid (FAILED)
✗ dns: dial tcp: lookup nonexistent.invalid: no such host
2
```
### Connection refused
```sh
$ ./latprobe http://localhost:1; echo $?
http://localhost:1 (FAILED)
✗ connect: dial tcp [::1]:1: connect: connection refused
3
```
### Timeout
```sh
$ ./latprobe --timeout 1s https://example.com:81; echo $?
https://example.com:81 (FAILED)
✗ timeout: context deadline exceeded
4
```
### HTTP error — default (exit 0)
```sh
$ ./latprobe https://httpbin.org/status/500; echo $?
https://httpbin.org/status/500 (500)
DNS lookup : 27.75 ms
TCP connect : 114.47 ms
TLS handshake : 245.59 ms
Server (TTFB) : 113.06 ms
Transfer : 0.21 ms
─────────────────────────────
Total : 502.05 ms
0
```
### HTTP error — with `--fail` (exit 6)
```sh
$ ./latprobe --fail https://httpbin.org/status/404; echo $?
https://httpbin.org/status/404 (404)
DNS lookup : 3.35 ms
...
Total : 476.01 ms
6
```
### Mixed sampling (some network failures)
```sh
$ ./latprobe -n 5 https://flaky-host.example.com; echo $?
https://flaky-host.example.com (200, 3 samples, 2 failed)
min avg max
DNS lookup : 5.00 ms 5.10 ms 5.20 ms
...
─────────────────────────────────────────────────
Total : 80.00 ms 85.00 ms 92.00 ms
2 × connect: connection refused
3
```
### JSON output with failure
```sh
$ ./latprobe --json https://nonexistent.invalid | jq .
[
{
"url": "https://nonexistent.invalid",
"status": 0,
"succeeded": 0,
"failed": 1,
"errors": [
{
"phase": "dns",
"count": 1,
"message": "dial tcp: lookup nonexistent.invalid: no such host"
}
]
}
]
```
## JSON schema changes
`jsonEntry` now includes:
```json
{
"url": "https://example.com",
"status": 200,
"succeeded": 3,
"failed": 2,
"phases": { ... },
"errors": [
{ "phase": "connect", "count": 2, "message": "connection refused" }
]
}
```
- `phases` is omitted when `succeeded == 0`
- `errors` is omitted when `failed == 0`
- `status` is 0 when no sample reached a response
## Exit code reference
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded |
| 1 | Usage error (no URLs, bad flags) |
| 2 | DNS resolution failure |
| 3 | Connection failure |
| 4 | Timeout |
| 5 | TLS handshake failure |
| 6 | HTTP status ≥ 400 (only with `--fail`) |

View File

@@ -0,0 +1,90 @@
# Step 6 — Integration Tests
## What this step delivers
A full integration-test suite covering every success and failure mode of the
`latprobe` CLI. Tests are written using the Go standard library only (`testing`,
`net/http/httptest`, `os/exec`, `encoding/json`) — no third-party dependencies.
Two test layers:
| Layer | File | How it runs |
|-------|------|-------------|
| In-process | `go/run_test.go` | Calls `run(args, stdout, stderr)` directly; fast, covers the full matrix |
| Subprocess | `go/cli_test.go` | Builds the real binary once, `exec.Command`s it; exercises the true `os.Exit` path |
## Running the tests
```sh
cd go
# Run all tests (quiet)
go test ./...
# Run with per-case output
go test -v ./...
# Run only in-process tests
go test -v -run TestRunMatrix .
go test -v -run TestJSON .
# Run only subprocess smoke tests
go test -v -run TestCLI .
```
## Test matrix
### In-process (`run_test.go`)
| Test case | Exit code | Assertion |
|-----------|-----------|-----------|
| Success 200 | 0 | stdout contains `(200)`, `TCP connect`, `Total` |
| HTTP 500, no `--fail` | 0 | stdout contains `(500)`, `Total` |
| HTTP 404, `--fail` | 6 | stdout contains `404 ✗` |
| DNS failure (`.invalid` TLD) | 2 | stdout contains `✗ dns:` |
| Connection refused (listen-then-close) | 3 | stdout contains `✗ connect:` |
| Timeout (`--timeout 200ms`, blocking handler) | 4 | stdout contains `✗ timeout:` |
| TLS failure (self-signed cert) | 5 | stdout contains `✗ tls:` |
| Multiple URLs (200 + `.invalid`) | 2 (highest) | stdout contains both `(200)` and `✗ dns:` |
| Sampling `-n 3`, all success | 0 | stdout contains `3 samples`, `min`, `avg`, `max` |
| No args | 1 | stderr contains `Usage:` |
| `-h` | 0 | stderr contains `Usage:` |
| `--json` success | 0 | valid JSON, `phases.total` present, `failed == 0` |
| `--json` DNS failure | 2 | valid JSON, `errors[0].phase == "dns"`, `succeeded == 0` |
| `--json` `-n 3` success | 0 | `succeeded == 3`, `total.min_ms > 0`, `max_ms >= min_ms` |
### Subprocess smoke tests (`cli_test.go`)
`TestMain` builds the binary with `go build -o <tmp>/latprobe .` once before
any test runs. The binary is deleted on test completion.
| Test | Checks |
|------|--------|
| `TestCLISuccess` | Local server, exit 0, stdout has `(200)` and `Total` |
| `TestCLIDNSFailure` | `.invalid` host, real binary exits 2 |
| `TestCLIJSONDNSFailure` | `.invalid` host, JSON output, `errors[0].phase == "dns"` |
## Notes
- The `http: TLS handshake error` log line printed during the TLS test is the
**server-side** log of the client correctly rejecting the self-signed cert.
It is expected and harmless.
- `httptest.NewServer` binds to `127.0.0.1`; Go resolves loopback addresses
without a DNS query, so the DNS row does not appear in localhost test output.
Tests use `TCP connect` as the success-path phase assertion instead.
- The test suite requires no network access for any case except DNS failure,
which uses the reserved `.invalid` TLD (RFC 6761 — always NXDOMAIN).
## Code changes included in this step
Beyond the tests, two code changes were made:
1. **`main.go` refactor** — extracted `run(args []string, stdout, stderr io.Writer) int`
so the CLI is testable in-process. `main()` is now a one-liner:
`os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))`. No behaviour change.
2. **`probe.go` TLS classification fix** — Go's `httptrace` calls
`TLSHandshakeDone` with the error on a failed handshake, so `tlsDone` was
set even on cert rejection. The previous classifier ("tlsStart set, tlsDone
zero") never matched. Fixed by capturing `tlsErr` from the hook and checking
it in `classifyErr`.

View File

@@ -0,0 +1,76 @@
# Step 7 — Concurrency
`latprobe` now probes multiple URLs in parallel, cutting wall-clock time for
large runs without sacrificing measurement accuracy.
## How it works
| Axis | Behaviour |
|------|-----------|
| Across URLs | **Parallel** — up to `-c` workers run simultaneously |
| Samples of one URL (`-n`) | **Sequential** — always serial within a URL |
Keeping samples sequential ensures that min/avg/max statistics for a single URL
reflect genuine latency variability, not artificial load created by firing
multiple requests at the same server at once.
Output is always printed in **input order**, regardless of which URL finishes
first.
## Flag
```
-c, --concurrency int Max URLs probed in parallel (0 = auto, default)
```
| Value | Behaviour |
|-------|-----------|
| `0` (default) | Auto: `min(numURLs, 8)` — scales with workload, caps at 8 |
| `1` | Fully serial — identical to the old behaviour; best for precision |
| `N > 1` | Explicit worker cap |
## When to use serial mode
For the most accurate latency numbers — especially when comparing sites —
use `-c 1`. Concurrent probes share your local NIC, DNS resolver, and CPU,
which can inflate timings on slower machines or fast batch runs.
```sh
# High fidelity: one URL at a time
./go/latprobe -c 1 -n 10 https://www.google.com https://www.bbc.co.uk
# Speed: all four probed in parallel (auto default would do this anyway)
./go/latprobe -c 4 -n 10 https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.abc.net.au
```
## Example: speedup in practice
Five sites, 5 samples each — sequential vs parallel:
```sh
# Serial (-c 1): ~wall time ≈ sum of all RTTs × 5
time ./go/latprobe -c 1 -n 5 \
https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au
# real ~1m15s (5 sites × ~15s sequential)
# Parallel (default): ~wall time ≈ slowest single URL × 5
time ./go/latprobe -n 5 \
https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au
# real ~18s (all 5 measured at once, gated by the slowest)
```
## Exit codes
Exit codes work exactly as before: when multiple failure types occur the
**highest** code wins. Concurrency does not change this — exit codes are
accumulated after all workers complete.
## Race safety
The implementation uses a semaphore channel (`chan struct{}`) and
`sync.WaitGroup` with each goroutine writing only its own indexed result slot,
so there is no shared mutable state. `http.DefaultClient` is safe for concurrent
use. Verified clean with `go test -race` (`make go-test-race`).

105
go/cli_test.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// binaryPath is set once by TestMain and used by all CLI smoke tests.
var binaryPath string
// TestMain builds the real binary once, runs all tests, then cleans up.
func TestMain(m *testing.M) {
tmp, err := os.MkdirTemp("", "latprobe-cli-*")
if err != nil {
panic("TestMain: MkdirTemp: " + err.Error())
}
binaryPath = filepath.Join(tmp, "latprobe")
out, err := exec.Command("go", "build", "-o", binaryPath, ".").CombinedOutput()
if err != nil {
panic("TestMain: go build failed:\n" + string(out))
}
code := m.Run()
os.RemoveAll(tmp)
os.Exit(code)
}
// cli runs the compiled binary and returns stdout, stderr, and the exit code.
func cli(args ...string) (stdout, stderr string, code int) {
cmd := exec.Command(binaryPath, args...)
var outBuf, errBuf strings.Builder
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
if err != nil {
if ex, ok := err.(*exec.ExitError); ok {
return outBuf.String(), errBuf.String(), ex.ExitCode()
}
}
return outBuf.String(), errBuf.String(), 0
}
// ── smoke tests ───────────────────────────────────────────────────────────────
// TestCLISuccess verifies the happy path against a real local server:
// all phases shown, status 200, exit 0.
func TestCLISuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
stdout, stderr, code := cli(srv.URL)
if code != 0 {
t.Fatalf("exit code = %d, want 0\nstdout: %s\nstderr: %s", code, stdout, stderr)
}
// httptest uses 127.0.0.1 — Go skips DNS for loopback, so no DNS row.
for _, want := range []string{"(200)", "TCP connect", "Total"} {
if !strings.Contains(stdout, want) {
t.Errorf("stdout missing %q\nstdout: %s", want, stdout)
}
}
}
// TestCLIDNSFailure verifies that an NXDOMAIN resolves to exit 2 in the
// real binary (exercises the actual os.Exit path).
func TestCLIDNSFailure(t *testing.T) {
_, _, code := cli("https://no.such.host.for.cli.smoke.invalid")
if code != exitDNS {
t.Errorf("exit code = %d, want %d (dns)", code, exitDNS)
}
}
// TestCLIJSONDNSFailure checks that --json + a DNS failure produces valid JSON
// with a correctly classified error entry — exercising the full output pipeline.
func TestCLIJSONDNSFailure(t *testing.T) {
stdout, _, code := cli("--json", "https://no.such.host.json.cli.invalid")
if code != exitDNS {
t.Fatalf("exit code = %d, want %d\nstdout: %s", code, exitDNS, stdout)
}
var results []struct {
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []struct {
Phase string `json:"phase"`
} `json:"errors"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) == 0 || len(results[0].Errors) == 0 {
t.Fatalf("expected non-empty errors in JSON\nstdout: %s", stdout)
}
if results[0].Errors[0].Phase != "dns" {
t.Errorf("error phase = %q, want \"dns\"", results[0].Errors[0].Phase)
}
}

View File

@@ -4,16 +4,24 @@ package probe
import (
"context"
"crypto/tls"
"errors"
"io"
"net"
"net/http"
"net/http/httptrace"
"time"
)
// Options configures a single Measure call.
type Options struct {
Timeout time.Duration // 0 = no timeout
}
// Phase holds the measured duration of a single request phase.
type Phase struct {
Duration time.Duration
// Present is false when the phase was skipped (e.g. no TLS for http://).
// Present is false when the phase was skipped (e.g. no TLS for http://)
// or did not complete before a failure.
Present bool
}
@@ -27,15 +35,20 @@ type Result struct {
Transfer Phase // body read: GotFirstResponseByte → body closed
Total Phase
// StatusCode is the HTTP response status code (0 on error).
// StatusCode is the HTTP response status code (0 on network error).
StatusCode int
// FailPhase is the phase where the request broke: "dns", "connect",
// "timeout", "tls", "transfer", or "request" (bad URL). Empty on success.
FailPhase string
// Err is non-nil if the request failed.
Err error
}
// Measure performs an HTTP GET to url and returns a Result with all phases
// populated via net/http/httptrace.
func Measure(url string) Result {
// Measure performs an HTTP GET to url and returns a Result with all completed
// phases populated. Partial phases are preserved when the request fails.
func Measure(url string, opts Options) Result {
r := Result{URL: url}
var (
@@ -47,11 +60,16 @@ func Measure(url string) Result {
tlsDone time.Time
wroteRequest time.Time
firstByte time.Time
dnsErr error
tlsErr error
)
trace := &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(info httptrace.DNSDoneInfo) {
dnsDone = time.Now()
dnsErr = info.Err
},
ConnectStart: func(_, _ string) {
if connectStart.IsZero() {
connectStart = time.Now()
@@ -59,23 +77,38 @@ func Measure(url string) Result {
},
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
tlsDone = time.Now()
tlsErr = err
},
WroteRequest: func(_ httptrace.WroteRequestInfo) { wroteRequest = time.Now() },
GotFirstResponseByte: func() { firstByte = time.Now() },
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
r.Err = err
r.FailPhase = "request"
return r
}
start := time.Now()
resp, err := http.DefaultClient.Do(req)
if err != nil {
end := time.Now()
r.Err = err
r.Total = Phase{Duration: time.Since(start), Present: true}
r.FailPhase = classifyErr(err, dnsErr, tlsErr, tlsStart)
r.Total = Phase{Duration: end.Sub(start), Present: true}
r.DNS = makePhase(dnsStart, dnsDone)
r.Connect = makePhase(connectStart, connectDone)
r.TLS = makePhase(tlsStart, tlsDone)
return r
}
defer resp.Body.Close()
@@ -86,25 +119,52 @@ func Measure(url string) Result {
r.StatusCode = resp.StatusCode
if err != nil {
r.Err = err
r.FailPhase = "transfer"
}
r.Total = Phase{Duration: end.Sub(start), Present: true}
if !dnsStart.IsZero() && !dnsDone.IsZero() {
r.DNS = Phase{Duration: dnsDone.Sub(dnsStart), Present: true}
}
if !connectStart.IsZero() && !connectDone.IsZero() {
r.Connect = Phase{Duration: connectDone.Sub(connectStart), Present: true}
}
if !tlsStart.IsZero() && !tlsDone.IsZero() {
r.TLS = Phase{Duration: tlsDone.Sub(tlsStart), Present: true}
}
if !wroteRequest.IsZero() && !firstByte.IsZero() {
r.TTFB = Phase{Duration: firstByte.Sub(wroteRequest), Present: true}
}
r.DNS = makePhase(dnsStart, dnsDone)
r.Connect = makePhase(connectStart, connectDone)
r.TLS = makePhase(tlsStart, tlsDone)
r.TTFB = makePhase(wroteRequest, firstByte)
if !firstByte.IsZero() {
r.Transfer = Phase{Duration: end.Sub(firstByte), Present: true}
}
return r
}
func makePhase(start, end time.Time) Phase {
if start.IsZero() || end.IsZero() {
return Phase{}
}
return Phase{Duration: end.Sub(start), Present: true}
}
// classifyErr maps a Do() error to the phase that caused it.
// Order: dns → timeout → tls → connect.
// Timeout during TLS still classifies as timeout, not tls.
func classifyErr(err, dnsErr, tlsErr error, tlsStart time.Time) string {
if dnsErr != nil {
return "dns"
}
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
return "dns"
}
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "timeout"
}
if tlsErr != nil {
return "tls"
}
// TLS started but handshake was interrupted (e.g. context cancelled mid-handshake)
if !tlsStart.IsZero() {
return "tls"
}
return "connect"
}

View File

@@ -2,10 +2,14 @@ package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/url"
"os"
"strings"
"sync"
"time"
"latprobe/internal/probe"
@@ -17,110 +21,295 @@ Usage:
latprobe [flags] <url> [url ...]
Flags:
-n, --count int Number of requests per URL (default 1)
--json Output results as JSON instead of text
-h, --help Show this help
-n, --count int Number of requests per URL (default 1)
-c, --concurrency int Max URLs probed in parallel, 0 = auto (default min(numURLs,8))
--timeout duration Request timeout, e.g. 10s, 500ms (default 10s)
--fail Exit non-zero on HTTP status >= 400 (exit code 6)
--json Output results as JSON instead of text
-h, --help Show this help
Exit codes:
0 All probes succeeded
1 Usage error
2 DNS resolution failure
3 Connection failure
4 Timeout
5 TLS handshake failure
6 HTTP status >= 400 (only with --fail)
Examples:
latprobe https://example.com
latprobe -n 5 https://example.com https://www.google.com
latprobe --timeout 2s https://slow-host.example.com
latprobe --fail https://example.com
latprobe --json https://example.com | jq .
`
// exit codes
const (
exitOK = 0
exitUsage = 1
exitDNS = 2
exitConnect = 3
exitTimeout = 4
exitTLS = 5
exitHTTP = 6
)
func failPhaseCode(fp string) int {
switch fp {
case "dns":
return exitDNS
case "timeout":
return exitTimeout
case "tls":
return exitTLS
default:
return exitConnect
}
}
func main() {
count := flag.Int("count", 1, "number of requests per URL")
flag.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
jsonOut := flag.Bool("json", false, "output results as JSON instead of text")
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
flag.Usage = func() { fmt.Fprint(os.Stderr, usageText) }
flag.Parse()
func run(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("latprobe", flag.ContinueOnError)
fs.SetOutput(stderr)
urls := flag.Args()
if len(urls) == 0 {
fmt.Fprint(os.Stderr, usageText)
os.Exit(1)
count := fs.Int("count", 1, "number of requests per URL")
fs.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)")
fs.IntVar(conc, "c", 0, "max URLs probed in parallel (shorthand)")
timeout := fs.Duration("timeout", 10*time.Second, "request timeout per sample")
fail := fs.Bool("fail", false, "exit non-zero on HTTP status >= 400")
jsonOut := fs.Bool("json", false, "output results as JSON instead of text")
fs.Usage = func() { fmt.Fprint(stderr, usageText) }
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return exitOK
}
return exitUsage
}
failed := false
urls := fs.Args()
if len(urls) == 0 {
fmt.Fprint(stderr, usageText)
return exitUsage
}
opts := probe.Options{Timeout: *timeout}
// Resolve effective worker count.
const defaultMaxConc = 8
workers := *conc
if workers <= 0 {
workers = min(len(urls), defaultMaxConc) // auto
}
workers = min(workers, len(urls)) // never more goroutines than work units
workers = max(workers, 1)
// ── Measure phase (concurrent) ────────────────────────────────────────────
// Each goroutine writes only its own indexed slot — no shared mutable state.
type urlResult struct {
succeeded, failed []probe.Result
}
results := make([]urlResult, len(urls))
sem := make(chan struct{}, workers)
var wg sync.WaitGroup
for i, rawURL := range urls {
wg.Add(1)
go func(i int, rawURL string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
s, f := runSamples(rawURL, *count, opts)
results[i] = urlResult{s, f}
}(i, rawURL)
}
wg.Wait()
// ── Render phase (sequential, input order) ────────────────────────────────
worstCode := exitOK
var jsonEntries []jsonEntry
for i, url := range urls {
results := collectSamples(url, *count, &failed)
if len(results) == 0 {
continue
for i, rawURL := range urls {
succeeded := results[i].succeeded
failed := results[i].failed
for _, r := range failed {
if c := failPhaseCode(r.FailPhase); c > worstCode {
worstCode = c
}
}
if *fail {
for _, r := range succeeded {
if r.StatusCode >= 400 && exitHTTP > worstCode {
worstCode = exitHTTP
}
}
}
if *jsonOut {
jsonEntries = append(jsonEntries, toJSONEntry(probe.Summarize(results)))
jsonEntries = append(jsonEntries, buildJSONEntry(rawURL, succeeded, failed))
continue
}
if i > 0 {
fmt.Println()
}
if *count == 1 {
printResult(results[0])
} else {
printAggregate(probe.Summarize(results))
fmt.Fprintln(stdout)
}
printURL(stdout, rawURL, succeeded, failed, *count, *fail)
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(jsonEntries); err != nil {
fmt.Fprintf(os.Stderr, "json encode: %v\n", err)
os.Exit(1)
fmt.Fprintf(stderr, "json encode: %v\n", err)
return exitConnect
}
}
if failed {
os.Exit(1)
}
return worstCode
}
func collectSamples(url string, count int, failed *bool) []probe.Result {
results := make([]probe.Result, 0, count)
for i := range count {
r := probe.Measure(url)
// ── sampling ──────────────────────────────────────────────────────────────────
func runSamples(rawURL string, count int, opts probe.Options) (succeeded, failed []probe.Result) {
for range count {
r := probe.Measure(rawURL, opts)
if r.Err != nil {
fmt.Fprintf(os.Stderr, "error %s (sample %d/%d): %v\n", url, i+1, count, r.Err)
*failed = true
return nil
failed = append(failed, r)
} else {
succeeded = append(succeeded, r)
}
results = append(results, r)
}
return results
return
}
func unwrapMsg(err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return urlErr.Err.Error()
}
return err.Error()
}
// ── text output ───────────────────────────────────────────────────────────────
func printResult(r probe.Result) {
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
for _, ph := range singlePhases(r) {
if ph.p.Present {
fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
func printURL(w io.Writer, rawURL string, succeeded, failed []probe.Result, total int, fail bool) {
nOK := len(succeeded)
nFail := len(failed)
switch {
case nFail == 0 && total == 1:
printResult(w, succeeded[0], fail)
case nFail == 0:
printAggregate(w, probe.Summarize(succeeded), nil, fail)
case nOK == 0:
// All samples failed — print header then partial timing from last failure.
header := rawURL + " (FAILED"
if total > 1 {
header += fmt.Sprintf(", 0/%d succeeded", total)
}
fmt.Fprintln(w, header+")")
last := failed[len(failed)-1]
anyPhase := false
for _, ph := range singlePhaseList(last) {
if ph.p.Present {
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
anyPhase = true
}
}
if last.Total.Present {
if anyPhase {
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
}
fmt.Fprintf(w, " %s : %8.2f ms\n", "Total ", ms(last.Total.Duration))
}
printFailureSummary(w, failed)
default:
// Mixed: some succeeded, some failed.
printAggregate(w, probe.Summarize(succeeded), failed, fail)
}
fmt.Println(" " + strings.Repeat("─", 29))
fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
}
func printAggregate(a probe.Aggregate) {
fmt.Printf("%s (%d, %d samples)\n", a.URL, a.StatusCode, a.Count)
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
for _, ph := range aggPhases(a) {
func printResult(w io.Writer, r probe.Result, fail bool) {
status := fmt.Sprintf("%d", r.StatusCode)
if fail && r.StatusCode >= 400 {
status += " ✗"
}
fmt.Fprintf(w, "%s (%s)\n", r.URL, status)
for _, ph := range singlePhaseList(r) {
if ph.p.Present {
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
}
}
fmt.Println(" " + strings.Repeat("─", 49))
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
if r.Total.Present {
fmt.Fprintf(w, " %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
}
if r.Err != nil {
fmt.Fprintf(w, " ✗ %s: %s\n", r.FailPhase, unwrapMsg(r.Err))
}
}
func singlePhases(r probe.Result) []struct {
func printAggregate(w io.Writer, a probe.Aggregate, failed []probe.Result, fail bool) {
status := fmt.Sprintf("%d", a.StatusCode)
if fail && a.StatusCode >= 400 {
status += " ✗"
}
header := fmt.Sprintf("%s (%s, %d samples", a.URL, status, a.Count)
if len(failed) > 0 {
header += fmt.Sprintf(", %d failed", len(failed))
}
fmt.Fprintln(w, header+")")
if a.Total.Present {
fmt.Fprintf(w, " %-14s %9s %9s %9s\n", "", "min", "avg", "max")
for _, ph := range aggPhaseList(a) {
if ph.p.Present {
fmt.Fprintf(w, " %s : %6.2f ms %6.2f ms %6.2f ms\n",
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
}
}
fmt.Fprintln(w, " "+strings.Repeat("─", 49))
fmt.Fprintf(w, " %s : %6.2f ms %6.2f ms %6.2f ms\n",
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
}
printFailureSummary(w, failed)
}
func printFailureSummary(w io.Writer, failed []probe.Result) {
if len(failed) == 0 {
return
}
type key struct{ phase, msg string }
counts := map[key]int{}
var order []key
for _, r := range failed {
k := key{r.FailPhase, unwrapMsg(r.Err)}
if counts[k] == 0 {
order = append(order, k)
}
counts[k]++
}
for _, k := range order {
n := counts[k]
if n == 1 {
fmt.Fprintf(w, " ✗ %s: %s\n", k.phase, k.msg)
} else {
fmt.Fprintf(w, " ✗ %d × %s: %s\n", n, k.phase, k.msg)
}
}
}
func singlePhaseList(r probe.Result) []struct {
label string
p probe.Phase
} {
@@ -136,7 +325,7 @@ func singlePhases(r probe.Result) []struct {
}
}
func aggPhases(a probe.Aggregate) []struct {
func aggPhaseList(a probe.Aggregate) []struct {
label string
p probe.PhaseStats
} {
@@ -164,34 +353,58 @@ type jsonPhase struct {
MaxMS float64 `json:"max_ms"`
}
type jsonEntry struct {
URL string `json:"url"`
Status int `json:"status"`
Samples int `json:"samples"`
Phases map[string]jsonPhase `json:"phases"`
type jsonError struct {
Phase string `json:"phase"`
Count int `json:"count"`
Message string `json:"message"`
}
func toJSONEntry(a probe.Aggregate) jsonEntry {
type jsonEntry struct {
URL string `json:"url"`
Status int `json:"status"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Phases map[string]jsonPhase `json:"phases,omitempty"`
Errors []jsonError `json:"errors,omitempty"`
}
func buildJSONEntry(rawURL string, succeeded, failed []probe.Result) jsonEntry {
e := jsonEntry{
URL: a.URL,
Status: a.StatusCode,
Samples: a.Count,
Phases: make(map[string]jsonPhase),
URL: rawURL,
Succeeded: len(succeeded),
Failed: len(failed),
}
add := func(name string, s probe.PhaseStats) {
if s.Present {
e.Phases[name] = jsonPhase{
MinMS: ms(s.Min),
AvgMS: ms(s.Avg),
MaxMS: ms(s.Max),
if len(succeeded) > 0 {
a := probe.Summarize(succeeded)
e.Status = a.StatusCode
e.Phases = make(map[string]jsonPhase)
add := func(name string, s probe.PhaseStats) {
if s.Present {
e.Phases[name] = jsonPhase{MinMS: ms(s.Min), AvgMS: ms(s.Avg), MaxMS: ms(s.Max)}
}
}
add("dns", a.DNS)
add("connect", a.Connect)
add("tls", a.TLS)
add("ttfb", a.TTFB)
add("transfer", a.Transfer)
add("total", a.Total)
}
add("dns", a.DNS)
add("connect", a.Connect)
add("tls", a.TLS)
add("ttfb", a.TTFB)
add("transfer", a.Transfer)
add("total", a.Total)
type key struct{ phase, msg string }
counts := map[key]int{}
var order []key
for _, r := range failed {
k := key{r.FailPhase, unwrapMsg(r.Err)}
if counts[k] == 0 {
order = append(order, k)
}
counts[k]++
}
for _, k := range order {
e.Errors = append(e.Errors, jsonError{Phase: k.phase, Count: counts[k], Message: k.msg})
}
return e
}

371
go/run_test.go Normal file
View File

@@ -0,0 +1,371 @@
package main
import (
"bytes"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ── helpers ───────────────────────────────────────────────────────────────────
// statusSrv starts a server that always replies with the given HTTP status.
func statusSrv(code int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
}))
}
// blockingSrv starts a server whose handler blocks until the client disconnects.
// Using r.Context().Done() means the handler exits cleanly when the client drops,
// so srv.Close() never hangs.
func blockingSrv() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
}
// refusedURL returns an http:// URL on a port where nothing is listening.
// It binds a listener to get a free port, closes it immediately, then hands
// back that address — so any connect attempt is immediately refused.
func refusedURL() string {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic("refusedURL: " + err.Error())
}
addr := l.Addr().String()
l.Close()
return "http://" + addr
}
// invoke calls run() and returns stdout, stderr, and the exit code.
func invoke(args ...string) (stdout, stderr string, code int) {
var outBuf, errBuf bytes.Buffer
code = run(args, &outBuf, &errBuf)
return outBuf.String(), errBuf.String(), code
}
// ── matrix ────────────────────────────────────────────────────────────────────
func TestRunMatrix(t *testing.T) {
ok200 := statusSrv(200)
defer ok200.Close()
ok500 := statusSrv(500)
defer ok500.Close()
ok404 := statusSrv(404)
defer ok404.Close()
tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
defer tlsSrv.Close()
hangSrv := blockingSrv()
defer hangSrv.Close()
refused := refusedURL()
cases := []struct {
name string
args []string
wantCode int
wantOut []string // substrings required in stdout
wantErr []string // substrings required in stderr
}{
{
// httptest uses 127.0.0.1 — Go skips DNS for loopback, so no DNS row.
name: "success 200",
args: []string{ok200.URL},
wantCode: exitOK,
wantOut: []string{"(200)", "TCP connect", "Total"},
},
{
name: "HTTP 500 without --fail",
args: []string{ok500.URL},
wantCode: exitOK,
wantOut: []string{"(500)", "Total"},
},
{
name: "HTTP 404 with --fail",
args: []string{"--fail", ok404.URL},
wantCode: exitHTTP,
wantOut: []string{"404 ✗"},
},
{
name: "DNS failure",
args: []string{"https://this.will.never.resolve.invalid"},
wantCode: exitDNS,
wantOut: []string{"✗ dns:"},
},
{
name: "connection refused",
args: []string{refused},
wantCode: exitConnect,
wantOut: []string{"✗ connect:"},
},
{
name: "timeout",
args: []string{"--timeout", "200ms", hangSrv.URL},
wantCode: exitTimeout,
wantOut: []string{"✗ timeout:"},
},
{
name: "TLS failure (self-signed cert rejected by default client)",
args: []string{tlsSrv.URL},
wantCode: exitTLS,
wantOut: []string{"✗ tls:"},
},
{
name: "multiple URLs — highest exit code wins",
args: []string{ok200.URL, "https://no.such.host.for.test.invalid"},
wantCode: exitDNS, // 2 > 0
wantOut: []string{"(200)", "✗ dns:"},
},
{
name: "sampling -n 3 all success",
args: []string{"-n", "3", ok200.URL},
wantCode: exitOK,
wantOut: []string{"3 samples", "min", "avg", "max"},
},
{
name: "no args → usage",
args: []string{},
wantCode: exitUsage,
wantErr: []string{"Usage:"},
},
{
name: "-h → usage exit 0",
args: []string{"-h"},
wantCode: exitOK,
wantErr: []string{"Usage:"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stdout, stderr, code := invoke(tc.args...)
if code != tc.wantCode {
t.Errorf("exit code = %d, want %d\nstdout:\n%s\nstderr:\n%s",
code, tc.wantCode, stdout, stderr)
}
for _, s := range tc.wantOut {
if !strings.Contains(stdout, s) {
t.Errorf("stdout missing %q\nstdout:\n%s", s, stdout)
}
}
for _, s := range tc.wantErr {
if !strings.Contains(stderr, s) {
t.Errorf("stderr missing %q\nstderr:\n%s", s, stderr)
}
}
})
}
}
// ── JSON-specific assertions ──────────────────────────────────────────────────
func TestJSONSuccess(t *testing.T) {
srv := statusSrv(200)
defer srv.Close()
stdout, _, code := invoke("--json", srv.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout: %s", code, stdout)
}
var results []struct {
URL string `json:"url"`
Status int `json:"status"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Phases map[string]any `json:"phases"`
Errors []any `json:"errors"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.Status != 200 {
t.Errorf("status = %d, want 200", r.Status)
}
if r.Succeeded != 1 {
t.Errorf("succeeded = %d, want 1", r.Succeeded)
}
if r.Failed != 0 {
t.Errorf("failed = %d, want 0", r.Failed)
}
if r.Phases["total"] == nil {
t.Error("phases.total missing")
}
if len(r.Errors) > 0 {
t.Errorf("unexpected errors: %v", r.Errors)
}
}
func TestJSONDNSFailure(t *testing.T) {
stdout, _, code := invoke("--json", "https://no.such.host.json.test.invalid")
if code != exitDNS {
t.Fatalf("exit code = %d, want %d\nstdout: %s", code, exitDNS, stdout)
}
var results []struct {
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []struct {
Phase string `json:"phase"`
Count int `json:"count"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.Succeeded != 0 {
t.Errorf("succeeded = %d, want 0", r.Succeeded)
}
if r.Failed != 1 {
t.Errorf("failed = %d, want 1", r.Failed)
}
if len(r.Errors) == 0 {
t.Fatal("errors array is empty")
}
if r.Errors[0].Phase != "dns" {
t.Errorf("error phase = %q, want \"dns\"", r.Errors[0].Phase)
}
if r.Errors[0].Count != 1 {
t.Errorf("error count = %d, want 1", r.Errors[0].Count)
}
}
// ── concurrency tests ─────────────────────────────────────────────────────────
// TestRunConcurrentOrder verifies that with -c > 1 the URL blocks are printed
// in the original input order and exit codes are accumulated correctly.
func TestRunConcurrentOrder(t *testing.T) {
a := statusSrv(200)
defer a.Close()
b := statusSrv(200)
defer b.Close()
c := statusSrv(200)
defer c.Close()
// Run with explicit high concurrency — all three URLs measured in parallel.
stdout, _, code := invoke("-c", "3", a.URL, b.URL, c.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout:\n%s", code, stdout)
}
// Each URL block must appear and in the correct order.
posA := strings.Index(stdout, a.URL)
posB := strings.Index(stdout, b.URL)
posC := strings.Index(stdout, c.URL)
if posA < 0 || posB < 0 || posC < 0 {
t.Fatalf("one or more URLs missing from stdout:\n%s", stdout)
}
if !(posA < posB && posB < posC) {
t.Errorf("URLs out of order: posA=%d posB=%d posC=%d\nstdout:\n%s",
posA, posB, posC, stdout)
}
}
// TestConcurrentWorstCode confirms that worstCode aggregation is correct when
// both successes and failures run concurrently.
func TestConcurrentWorstCode(t *testing.T) {
good := statusSrv(200)
defer good.Close()
// DNS failure mixed with a successful URL — highest code (2) must win.
stdout, _, code := invoke("-c", "2",
good.URL,
"https://totally.bogus.domain.for.concurrency.test.invalid",
)
if code != exitDNS {
t.Errorf("exit code = %d, want %d (DNS)\nstdout:\n%s", code, exitDNS, stdout)
}
// Both URLs must appear in output (good one before the failing one).
if !strings.Contains(stdout, good.URL) {
t.Errorf("stdout missing good URL\nstdout:\n%s", stdout)
}
if !strings.Contains(stdout, "✗ dns:") {
t.Errorf("stdout missing DNS failure marker\nstdout:\n%s", stdout)
}
}
// TestConcurrentJSONOrder verifies that JSON entries preserve input order under
// parallel execution.
func TestConcurrentJSONOrder(t *testing.T) {
a := statusSrv(200)
defer a.Close()
b := statusSrv(200)
defer b.Close()
stdout, _, code := invoke("--json", "-c", "2", a.URL, b.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout:\n%s", code, stdout)
}
var results []struct {
URL string `json:"url"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].URL != a.URL {
t.Errorf("results[0].url = %q, want %q", results[0].URL, a.URL)
}
if results[1].URL != b.URL {
t.Errorf("results[1].url = %q, want %q", results[1].URL, b.URL)
}
}
func TestJSONSampling(t *testing.T) {
srv := statusSrv(200)
defer srv.Close()
stdout, _, code := invoke("--json", "-n", "3", srv.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout: %s", code, stdout)
}
var results []struct {
Succeeded int `json:"succeeded"`
Phases map[string]struct {
MinMS float64 `json:"min_ms"`
AvgMS float64 `json:"avg_ms"`
MaxMS float64 `json:"max_ms"`
} `json:"phases"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
r := results[0]
if r.Succeeded != 3 {
t.Errorf("succeeded = %d, want 3", r.Succeeded)
}
total, ok := r.Phases["total"]
if !ok {
t.Fatal("phases.total missing")
}
if total.MinMS <= 0 {
t.Errorf("total.min_ms = %f, want > 0", total.MinMS)
}
if total.MaxMS < total.MinMS {
t.Errorf("total.max_ms (%f) < total.min_ms (%f)", total.MaxMS, total.MinMS)
}
}

View File

@@ -0,0 +1,7 @@
# All sites are expected to respond successfully.
# Run: python3.14 python/simple.py python/configs/all-ok.txt
# Expected exit code: 0
https://example.com
https://www.google.com
https://www.iana.org

View File

@@ -0,0 +1,7 @@
# Connection-refused errors — loopback addresses with no server on those ports.
# The OS rejects the TCP SYN immediately, so these fail in milliseconds.
# Run: python3.14 python/simple.py python/configs/connection-refused.txt
# Expected exit code: 1
http://127.0.0.1:9999
http://127.0.0.1:19999

View File

@@ -0,0 +1,9 @@
# DNS resolution failures — hostnames that cannot be resolved.
# .invalid is an IANA-reserved TLD guaranteed never to resolve (RFC 2606).
# Run: python3.14 python/simple.py python/configs/dns-failure.txt
# Expected exit code: 1
https://this-host-does-not-exist.invalid
http://no.such.host.invalid
# Also exercises the blank-line and comment-line parser paths.

View File

@@ -0,0 +1,21 @@
# HTTP error status codes — urllib.request.urlopen() raises HTTPError for
# 4xx/5xx responses, so simple.py reports these as FAIL even though the server
# responded. The URLs below are real paths that reliably return 404 on stable
# public servers.
#
# Note: a genuine 500 from a well-known server is hard to provoke on demand.
# These 404s are sufficient to demonstrate that HTTP errors are treated as FAIL.
#
# Requires internet access.
#
# Run: python3.14 python/simple.py python/configs/http-errors.txt
# Expected exit code: 1
# 404 from Google
https://www.google.com/this-page-does-not-exist-at-all-1234567890
# 404 from GitHub
https://github.com/this-repo-does-not-exist-abcxyz123/no-way
# 404 from IANA
https://www.iana.org/this-page-does-not-exist-either

21
python/configs/mixed.txt Normal file
View File

@@ -0,0 +1,21 @@
# Mixed — one entry from each error class alongside a successful site.
# Shows that OK and FAIL lines can interleave in the same run.
# Timeout is omitted here so the run completes in a few seconds.
#
# Run: python3.14 python/simple.py python/configs/mixed.txt
# Expected exit code: 1 (any FAIL drives exit to 1)
# Success
https://example.com
# DNS failure
https://no.such.host.invalid
# Connection refused (loopback, no server)
http://127.0.0.1:9999
# TLS certificate error
https://self-signed.badssl.com/
# HTTP error (server responded with 404)
https://github.com/this-repo-does-not-exist-abcxyz123/no-way

View File

@@ -0,0 +1,14 @@
# Timeout — non-routable IP addresses that accept no TCP traffic.
# The kernel sends a SYN but never gets a reply; simple.py waits the full
# 10-second hard-coded timeout per host before printing FAIL.
#
# WARNING: this config takes ~20 seconds to complete (10 s per host).
#
# 10.255.255.1 and 192.0.2.1 (RFC 5737 TEST-NET-1, documentation-only range)
# are guaranteed to be unreachable on any normal network.
#
# Run: python3.14 python/simple.py python/configs/timeout.txt
# Expected exit code: 1
http://10.255.255.1/
http://192.0.2.1/

View File

@@ -0,0 +1,21 @@
# TLS certificate errors — badssl.com provides endpoints with intentionally
# broken certificates. Python's ssl module rejects them by default.
#
# Note: badssl.com may intermittently reset the connection instead of
# completing the TLS handshake. The error message will then read
# "[Errno 54] Connection reset by peer" rather than CERTIFICATE_VERIFY_FAILED,
# but simple.py still correctly reports FAIL in either case.
#
# Requires internet access.
#
# Run: python3.14 python/simple.py python/configs/tls-errors.txt
# Expected exit code: 1
# Certificate has expired
https://expired.badssl.com/
# Certificate is self-signed (not trusted by the system CA store)
https://self-signed.badssl.com/
# Certificate chain is incomplete (intermediate CA missing)
https://incomplete-chain.badssl.com/

View File

@@ -0,0 +1,312 @@
# `latprobe` — Runnable Usage Reference
All commands run from the repository root. Timings will differ on your
machine and network; the output structure is stable.
---
## Basic — single URL
```sh
make py-run ARGS="https://example.com"
# or directly:
python3.14 -m latprobe 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
```
---
## Verbose mode — IP, TLS, certificate, headers
```sh
python3.14 -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
```
The verbose block shows:
- **IP** — first resolved address (useful when DNS round-robins across IPs)
- **TLS** — protocol version, cipher suite, and key bits
- **Cert** — common name, expiry date (prefixed `EXPIRED` if past), and issuer
- Response headers from the priority list: `Location`, `Server`, `Content-Type`,
`X-Cache`, `CF-Cache-Status`, `Cache-Control`, `Via`, `X-Powered-By`
---
## Verbose — plain HTTP (no TLS block)
```sh
python3.14 -m latprobe --verbose http://example.com
```
```
http://example.com (301)
DNS lookup : 18.22 ms
TCP connect : 10.01 ms
Server (TTFB) : 65.40 ms
Transfer : 0.08 ms
─────────────────────────────
Total : 96.10 ms
IP : 104.20.23.154
Location : https://www.example.com/
Server : cloudflare
Content-Type : text/html
```
No `TLS` or `Cert` rows for `http://` URLs.
---
## Verbose — TLS failure (certificate expired)
```sh
python3.14 -m latprobe --verbose https://expired.badssl.com/
echo "exit: $?"
```
```
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 (_ssl.c:1082)
IP : 104.154.89.105
exit: 5
```
The IP is shown even on TLS failure (DNS and TCP both succeeded). The error
message identifies the cause; certificate details are unavailable because
Python's stdlib does not expose the rejected cert.
---
## Verbose — DNS failure (no IP to show)
```sh
python3.14 -m latprobe --verbose http://no.such.host.invalid
echo "exit: $?"
```
```
http://no.such.host.invalid (FAILED)
Total : 2.65 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
exit: 2
```
The verbose block is empty (IP never resolved), so it is suppressed entirely.
---
## Sampling (`-n`) — min / avg / max table
```sh
python3.14 -m latprobe -n 3 https://example.com
```
```
https://example.com (200, 3 samples)
min avg max
DNS lookup : 1.15 ms 2.09 ms 3.45 ms
TCP connect : 8.76 ms 9.50 ms 10.21 ms
TLS handshake : 14.48 ms 16.63 ms 20.56 ms
Server (TTFB) : 65.44 ms 68.22 ms 70.58 ms
Transfer : 0.15 ms 0.28 ms 0.42 ms
─────────────────────────────────────────────────
Total : 101.64 ms 106.31 ms 115.47 ms
```
With `--verbose`, the verbose block is appended below the table using the last
successful sample's detail:
```sh
python3.14 -m latprobe --verbose -n 3 https://example.com
```
```
https://example.com (200, 3 samples)
min avg max
...
Total : 101.64 ms 106.31 ms 115.47 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
```
---
## Multiple URLs (probed in parallel)
```sh
python3.14 -m latprobe https://example.com https://www.iana.org
```
Output for each URL is separated by a blank line. Exit code = worst across all.
---
## `--fail` flag — exit non-zero on HTTP 4xx/5xx
```sh
python3.14 -m latprobe --fail https://www.google.com/this-page-does-not-exist
echo "exit: $?"
```
```
https://www.google.com/this-page-does-not-exist (404 ✗)
...
Total : 145.50 ms
exit: 6
```
Without `--fail`, HTTP 4xx/5xx responses are shown normally and the exit code
is `0`.
---
## JSON output
```sh
python3.14 -m latprobe --json https://example.com | python3.14 -m json.tool
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": {
"dns": {"min_ms": 16.47, "avg_ms": 16.47, "max_ms": 16.47},
"connect": {"min_ms": 9.76, "avg_ms": 9.76, "max_ms": 9.76},
"tls": {"min_ms": 13.09, "avg_ms": 13.09, "max_ms": 13.09},
"ttfb": {"min_ms": 60.31, "avg_ms": 60.31, "max_ms": 60.31},
"transfer": {"min_ms": 0.20, "avg_ms": 0.20, "max_ms": 0.20},
"total": {"min_ms": 112.76, "avg_ms": 112.76, "max_ms": 112.76}
}
}
]
```
`phases` is omitted when all samples failed (only `errors` is present).
`tls` key is omitted for `http://` URLs.
---
## JSON + verbose
```sh
python3.14 -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",
"cf-cache-status": "HIT"
}
}
}
]
```
`"verbose"` is omitted when `--verbose` is not set.
`"cert"` is omitted for `http://` URLs and when TLS fails.
`"headers"` contains **all** parsed response headers (the text block shows
only the priority list).
---
## Timeout
```sh
python3.14 -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
```
`--timeout` accepts `ms`, `s`, `m` suffixes or bare seconds.
---
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded (or HTTP 4xx without `--fail`) |
| 1 | Usage / argument error |
| 2 | DNS failure |
| 3 | TCP connect failure |
| 4 | Timeout |
| 5 | TLS error |
| 6 | HTTP status ≥ 400 with `--fail` |
The **highest** exit code across all URLs is returned as the process exit.
---
## Makefile shortcuts
```sh
make py-run ARGS="--verbose https://example.com" # run latprobe
make py-test # hermetic tests only
make py-test-integration # live internet tests (~30 s)
make py-check # alias for py-test
```

View File

@@ -0,0 +1,347 @@
# `phases.py` — Example Config Files
Each `.txt` file in this directory targets a distinct error path so you can
observe `phases.py`'s per-phase behaviour for every failure class. Run them
from the `python/` directory.
A key difference from `simple.py`: on failure, `phases.py` shows the phases
that *did* complete before the error — giving you partial timing up to the
point of failure.
---
## Direct URL (no config file)
`phases.py` also accepts bare URLs as positional arguments:
```sh
python phases.py https://example.com
```
Expected output:
```
https://example.com (200)
DNS lookup : 14.19 ms
TCP connect : 9.60 ms
TLS handshake : 16.44 ms
Server (TTFB) : 70.29 ms
Transfer : 0.13 ms
─────────────────────────────
Total : 118.14 ms
```
For `http://` URLs the TLS row is omitted:
```sh
python phases.py http://example.com
```
```
http://example.com (200)
DNS lookup : 13.61 ms
TCP connect : 10.56 ms
Server (TTFB) : 14.51 ms
Transfer : 0.08 ms
─────────────────────────────
Total : 38.91 ms
```
---
## `all-ok.txt` — all sites respond successfully
```sh
python phases.py configs/all-ok.txt
echo "exit: $?"
```
Expected output (latencies vary; multiple results separated by a blank line):
```
https://example.com (200)
DNS lookup : 3.82 ms
TCP connect : 10.04 ms
TLS handshake : 17.12 ms
Server (TTFB) : 28.59 ms
Transfer : 0.09 ms
─────────────────────────────
Total : 66.16 ms
https://www.google.com (200)
DNS lookup : 14.31 ms
TCP connect : 8.47 ms
TLS handshake : 24.61 ms
Server (TTFB) : 95.24 ms
Transfer : 33.96 ms
─────────────────────────────
Total : 182.67 ms
https://www.iana.org (200)
DNS lookup : 23.19 ms
TCP connect : 9.77 ms
TLS handshake : 14.51 ms
Server (TTFB) : 68.53 ms
Transfer : 0.20 ms
─────────────────────────────
Total : 125.93 ms
exit: 0
```
---
## `dns-failure.txt` — DNS resolution fails
DNS fails immediately — no TCP or TLS phases run. Only `Total` is shown
alongside the error (DNS lookup itself is not shown since it never completed).
```sh
python phases.py configs/dns-failure.txt
echo "exit: $?"
```
Expected output:
```
https://this-host-does-not-exist.invalid (FAILED)
─────────────────────────────
Total : 2.67 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://no.such.host.invalid (FAILED)
─────────────────────────────
Total : 0.65 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
exit: 1
```
---
## `connection-refused.txt` — TCP connection refused
DNS succeeds (loopback resolves instantly), TCP connect fails immediately.
Both DNS and TCP phases are shown as partial timing.
```sh
python phases.py configs/connection-refused.txt
echo "exit: $?"
```
Expected output:
```
http://127.0.0.1:9999 (FAILED)
DNS lookup : 1.03 ms
TCP connect : 0.82 ms
─────────────────────────────
Total : 1.88 ms
✗ connect: [Errno 61] Connection refused
http://127.0.0.1:19999 (FAILED)
DNS lookup : 0.01 ms
TCP connect : 0.40 ms
─────────────────────────────
Total : 0.41 ms
✗ connect: [Errno 61] Connection refused
exit: 1
```
---
## `timeout.txt` — connect hangs until timeout
DNS resolves, TCP SYN is sent but never gets a reply. `phases.py` waits the
full **10-second** timeout per host before printing FAIL.
> ⚠️ This run takes approximately **20 seconds** to complete.
```sh
python phases.py configs/timeout.txt
echo "exit: $?"
```
Expected output (after ~20 s):
```
http://10.255.255.1/ (FAILED)
DNS lookup : x.xx ms
TCP connect : 10000.xx ms
─────────────────────────────
Total : 10000.xx ms
✗ timeout: timed out
http://192.0.2.1/ (FAILED)
DNS lookup : x.xx ms
TCP connect : 10000.xx ms
─────────────────────────────
Total : 10000.xx ms
✗ timeout: timed out
exit: 1
```
---
## `tls-errors.txt` — TLS certificate errors
DNS and TCP complete; TLS handshake is attempted and fails. All three phases
are shown with real timings even though the request fails.
> **Note:** badssl.com occasionally resets the connection mid-handshake. The
> error may read `[Errno 54] Connection reset by peer` instead of
> `CERTIFICATE_VERIFY_FAILED` — both are classified as `✗ tls`.
```sh
python phases.py configs/tls-errors.txt
echo "exit: $?"
```
Expected output:
```
https://expired.badssl.com/ (FAILED)
DNS lookup : 15.39 ms
TCP connect : 124.98 ms
TLS handshake : 326.52 ms
─────────────────────────────
Total : 479.50 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired …
https://self-signed.badssl.com/ (FAILED)
DNS lookup : 2.06 ms
TCP connect : 127.74 ms
TLS handshake : 963.72 ms
─────────────────────────────
Total : 1103.21 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate …
https://incomplete-chain.badssl.com/ (FAILED)
DNS lookup : 2.11 ms
TCP connect : 2128.40 ms
TLS handshake : 381.42 ms
─────────────────────────────
Total : 2532.73 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer …
exit: 1
```
---
## `http-errors.txt` — HTTP 4xx / 5xx status codes
Unlike `simple.py` (which treats 4xx as FAIL), `phases.py` completes all
phases and shows the raw HTTP status code. The request succeeded at the network
level — you get full phase breakdown plus the 404.
```sh
python phases.py configs/http-errors.txt
echo "exit: $?"
```
Expected output:
```
https://www.google.com/this-page-does-not-exist-at-all-1234567890 (404)
DNS lookup : 4.12 ms
TCP connect : 10.56 ms
TLS handshake : 25.03 ms
Server (TTFB) : 124.26 ms
Transfer : 0.19 ms
─────────────────────────────
Total : 172.60 ms
https://github.com/this-repo-does-not-exist-abcxyz123/no-way (404)
DNS lookup : 16.56 ms
TCP connect : 22.86 ms
TLS handshake : 24.47 ms
Server (TTFB) : 275.52 ms
Transfer : 90.08 ms
─────────────────────────────
Total : 441.17 ms
https://www.iana.org/this-page-does-not-exist-either (404)
DNS lookup : 2.48 ms
TCP connect : 10.03 ms
TLS handshake : 16.10 ms
Server (TTFB) : 75.14 ms
Transfer : 0.46 ms
─────────────────────────────
Total : 115.90 ms
exit: 0
```
> **Compare with `simple.py`:** the same URLs return `FAIL (HTTP Error 404: Not Found)`
> in `simple.py` (because `urlopen` raises for 4xx) but `(404)` with a full
> phase breakdown in `phases.py` (because the request completed at the network level).
> Exit code is `0` here vs `1` in `simple.py`.
---
## `mixed.txt` — one of each (no timeout)
```sh
python phases.py configs/mixed.txt
echo "exit: $?"
```
Expected output:
```
https://example.com (200)
DNS lookup : 4.88 ms
TCP connect : 11.79 ms
TLS handshake : 17.17 ms
Server (TTFB) : 67.79 ms
Transfer : 0.09 ms
─────────────────────────────
Total : 108.72 ms
https://no.such.host.invalid (FAILED)
─────────────────────────────
Total : 1.46 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://127.0.0.1:9999 (FAILED)
DNS lookup : 0.01 ms
TCP connect : 0.82 ms
─────────────────────────────
Total : 0.85 ms
✗ connect: [Errno 61] Connection refused
https://self-signed.badssl.com/ (FAILED)
DNS lookup : 1.54 ms
TCP connect : 1129.98 ms
TLS handshake : 296.93 ms
─────────────────────────────
Total : 1438.84 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate …
https://github.com/this-repo-does-not-exist-abcxyz123/no-way (404)
DNS lookup : 13.48 ms
TCP connect : 22.98 ms
TLS handshake : 24.58 ms
Server (TTFB) : 324.54 ms
Transfer : 95.05 ms
─────────────────────────────
Total : 495.53 ms
exit: 1
```
Note how the GitHub 404 shows all five phases (network succeeded) while the
DNS, TCP, and TLS failures each show only the phases that ran.
---
## Run all configs
```sh
# Quick sweep — skips the slow timeout config
for f in configs/*.txt; do
[[ "$f" == *timeout* ]] && continue
echo
echo "=== $f ==="
python phases.py "$f"
echo "exit: $?"
done
```
```sh
# Full sweep — includes timeout (~20 s extra)
for f in configs/*.txt; do
echo
echo "=== $f ==="
python phases.py "$f"
echo "exit: $?"
done
```

View File

@@ -0,0 +1,185 @@
# `simple.py` — Example Config Files
Each `.txt` file in this directory targets a distinct error path so you can
observe `simple.py`'s behaviour for every failure class. Run them from the
`python/` directory.
---
## `all-ok.txt` — all sites respond successfully
```sh
python simple.py configs/all-ok.txt
echo "exit: $?"
```
Expected output:
```
OK xxx.xx ms https://example.com
OK xxx.xx ms https://www.google.com
OK xxx.xx ms https://www.iana.org
exit: 0
```
---
## `dns-failure.txt` — DNS resolution fails
Hostnames use the `.invalid` TLD (RFC 2606), which is guaranteed never to
resolve. Fails in milliseconds.
```sh
python simple.py configs/dns-failure.txt
echo "exit: $?"
```
Expected output:
```
FAIL ([Errno 8] nodename nor servname provided, or not known) https://this-host-does-not-exist.invalid
FAIL ([Errno 8] nodename nor servname provided, or not known) http://no.such.host.invalid
exit: 1
```
---
## `connection-refused.txt` — TCP connection refused
Loopback addresses with no server listening. The OS rejects the SYN immediately
(sub-millisecond failure).
```sh
python simple.py configs/connection-refused.txt
echo "exit: $?"
```
Expected output:
```
FAIL ([Errno 61] Connection refused) http://127.0.0.1:9999
FAIL ([Errno 61] Connection refused) http://127.0.0.1:19999
exit: 1
```
---
## `timeout.txt` — connect hangs until timeout
Non-routable IPs (private/documentation ranges) silently drop TCP SYNs.
`simple.py` waits the full **10-second** timeout per host.
> ⚠️ This run takes approximately **20 seconds** to complete.
```sh
python simple.py configs/timeout.txt
echo "exit: $?"
```
Expected output (after ~20 s):
```
FAIL (timed out) http://10.255.255.1/
FAIL (timed out) http://192.0.2.1/
exit: 1
```
---
## `tls-errors.txt` — TLS certificate errors
Uses [badssl.com](https://badssl.com) endpoints with intentionally broken
certificates. Requires internet access; badssl.com must be reachable.
```sh
python simple.py configs/tls-errors.txt
echo "exit: $?"
```
Expected output:
```
FAIL ([SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired …) https://expired.badssl.com/
FAIL ([SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate …) https://self-signed.badssl.com/
FAIL ([SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer …) https://incomplete-chain.badssl.com/
exit: 1
```
> **Note:** badssl.com occasionally resets connections mid-handshake. When that
> happens, the error reads `[Errno 54] Connection reset by peer` instead of
> `CERTIFICATE_VERIFY_FAILED`. The key behaviour is the same — every entry is
> reported as `FAIL` and the script exits 1. This demonstrates that `simple.py`
> handles both SSL-level and network-level TLS failures uniformly.
---
## `http-errors.txt` — HTTP 4xx / 5xx status codes
`urllib.request.urlopen()` raises `HTTPError` for 4xx/5xx, so `simple.py`
reports these as `FAIL` even though the server responded. The URLs are real
paths that reliably return 404 on stable public servers. Requires internet
access.
```sh
python simple.py configs/http-errors.txt
echo "exit: $?"
```
Expected output:
```
FAIL (HTTP Error 404: Not Found) https://www.google.com/this-page-does-not-exist-at-all-1234567890
FAIL (HTTP Error 404: Not Found) https://github.com/this-repo-does-not-exist-abcxyz123/no-way
FAIL (HTTP Error 404: Not Found) https://www.iana.org/this-page-does-not-exist-either
exit: 1
```
---
## `mixed.txt` — one of each (no timeout)
One successful site followed by one entry from each fast error class (DNS,
connection-refused, TLS, HTTP). Timeout is excluded so the run completes
quickly.
```sh
python simple.py configs/mixed.txt
echo "exit: $?"
```
Expected output:
```
OK xxx.xx ms https://example.com
FAIL ([Errno 8] nodename nor servname …) https://no.such.host.invalid
FAIL ([Errno 61] Connection refused) http://127.0.0.1:9999
FAIL ([SSL: CERTIFICATE_VERIFY_FAILED] …) https://self-signed.badssl.com/
FAIL (HTTP Error 404: Not Found) https://github.com/this-repo-does-not-exist-abcxyz123/no-way
exit: 1
```
> **Note:** the TLS entry may occasionally show `[Errno 54] Connection reset by peer`
> instead of `CERTIFICATE_VERIFY_FAILED` — badssl.com sometimes drops the connection
> before the handshake completes. Both are reported as `FAIL`; run `tls-errors.txt`
> in isolation for consistent cert-verification messages.
---
## Run all configs
Iterate over every config file (skip `timeout.txt` for a quick sweep, or omit
the exclusion to run all):
```sh
# Quick sweep — skips the slow timeout config
for f in configs/*.txt; do
[[ "$f" == *timeout* ]] && continue
echo
echo "=== $f ==="
python simple.py "$f"
echo "exit: $?"
done
```
```sh
# Full sweep — includes timeout (~20 s extra)
for f in configs/*.txt; do
echo
echo "=== $f ==="
python simple.py "$f"
echo "exit: $?"
done
```

View File

@@ -0,0 +1 @@
"""latprobe — measure per-phase HTTP request latency."""

View File

@@ -0,0 +1,5 @@
import sys
from .cli import run
sys.exit(run(sys.argv[1:], sys.stdout, sys.stderr))

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass, field
from .probe import Result
@dataclass
class PhaseStats:
min_ms: float = 0.0
avg_ms: float = 0.0
max_ms: float = 0.0
present: bool = False
@dataclass
class Aggregate:
url: str = ""
count: int = 0
status_code: int = 0
dns: PhaseStats = field(default_factory=PhaseStats)
connect: PhaseStats = field(default_factory=PhaseStats)
tls: PhaseStats = field(default_factory=PhaseStats)
ttfb: PhaseStats = field(default_factory=PhaseStats)
transfer: PhaseStats = field(default_factory=PhaseStats)
total: PhaseStats = field(default_factory=PhaseStats)
def summarize(results: list[Result]) -> Aggregate:
"""Compute per-phase min/avg/max over a list of succeeded Results."""
if not results:
return Aggregate()
a = Aggregate(url=results[0].url, count=len(results))
a.status_code = results[-1].status_code
def _stats(attr: str) -> PhaseStats:
vals = [getattr(r, attr).ms for r in results if getattr(r, attr).present]
if not vals:
return PhaseStats()
return PhaseStats(
min_ms=min(vals),
avg_ms=sum(vals) / len(vals),
max_ms=max(vals),
present=True,
)
a.dns = _stats("dns")
a.connect = _stats("connect")
a.tls = _stats("tls")
a.ttfb = _stats("ttfb")
a.transfer = _stats("transfer")
a.total = _stats("total")
return a

441
python/latprobe/cli.py Normal file
View File

@@ -0,0 +1,441 @@
from __future__ import annotations
import argparse
import concurrent.futures
import datetime
import json
import sys
from typing import IO
from .aggregate import Aggregate, PhaseStats, summarize
from .duration import parse_duration
from .probe import Options, Result, VerboseDetail, measure
# ── exit codes (mirrors Go) ───────────────────────────────────────────────────
EXIT_OK = 0
EXIT_USAGE = 1
EXIT_DNS = 2
EXIT_CONNECT = 3
EXIT_TIMEOUT = 4
EXIT_TLS = 5
EXIT_HTTP = 6
_PHASE_EXIT: dict[str, int] = {
"dns": EXIT_DNS,
"timeout": EXIT_TIMEOUT,
"tls": EXIT_TLS,
}
def _phase_code(fail_phase: str) -> int:
return _PHASE_EXIT.get(fail_phase, EXIT_CONNECT)
# ── display constants ─────────────────────────────────────────────────────────
_SINGLE_SEP = " " + "" * 29
_AGG_SEP = " " + "" * 49
_PHASE_LABELS = [
("dns", "DNS lookup "),
("connect", "TCP connect "),
("tls", "TLS handshake "),
("ttfb", "Server (TTFB) "),
("transfer", "Transfer "),
]
# Response headers shown in text verbose block, in priority order.
# In JSON verbose mode all parsed headers are included.
_VERBOSE_HEADERS = [
"Location",
"Server",
"Content-Type",
"X-Cache",
"CF-Cache-Status",
"Cache-Control",
"Via",
"X-Powered-By",
]
# ── argparse with injectable streams ─────────────────────────────────────────
class _ArgExit(Exception):
def __init__(self, code: int) -> None:
self.code = code
class _Parser(argparse.ArgumentParser):
"""ArgumentParser that writes to injected streams and raises instead of exiting."""
def __init__(self, *args, out: IO, err: IO, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._out = out
self._err = err
def _print_message(self, message: str, file=None) -> None:
if message:
(file if file is not None else self._out).write(message)
def print_help(self, file=None) -> None:
self._print_message(self.format_help(), self._out)
def print_usage(self, file=None) -> None:
self._print_message(self.format_usage(), self._err)
def error(self, message: str) -> None:
self.print_usage()
self._err.write(f"{self.prog}: error: {message}\n")
raise _ArgExit(EXIT_USAGE)
def exit(self, status: int = 0, message: str | None = None) -> None:
if message:
self._err.write(message)
raise _ArgExit(int(status) if status else EXIT_OK)
# ── sampling ──────────────────────────────────────────────────────────────────
def _run_samples(
url: str, count: int, opts: Options
) -> tuple[list[Result], list[Result]]:
succeeded, failed = [], []
for _ in range(count):
r = measure(url, opts)
(failed if r.err else succeeded).append(r)
return succeeded, failed
# ── verbose rendering ─────────────────────────────────────────────────────────
def _cert_expired(expiry: str) -> bool:
try:
return datetime.date.fromisoformat(expiry) < datetime.date.today()
except (ValueError, TypeError):
return False
def _print_verbose_block(detail: VerboseDetail, out: IO) -> None:
"""Write the verbose detail block (IP, TLS, cert, headers) after timing rows."""
def _row(label: str, value: str) -> None:
out.write(f" {label:<14} : {value}\n")
if detail.resolved_ip:
_row("IP", detail.resolved_ip)
if detail.tls_version:
parts = [detail.tls_version]
if detail.tls_cipher:
parts.append(detail.tls_cipher)
if detail.tls_bits:
parts.append(f"{detail.tls_bits} bit")
_row("TLS", " ".join(parts))
if detail.cert:
c = detail.cert
label = "Cert (unvrf.)" if not c.verified else "Cert"
cert_parts = []
if c.cn:
cert_parts.append(f"CN={c.cn}")
if c.expiry:
tag = "EXPIRED" if _cert_expired(c.expiry) else "valid until"
cert_parts.append(f"{tag} {c.expiry}")
if c.issuer_cn:
cert_parts.append(c.issuer_cn)
_row(label, " ".join(cert_parts))
for name in _VERBOSE_HEADERS:
value = detail.headers.get(name)
if value:
_row(name, value)
# ── text rendering ────────────────────────────────────────────────────────────
def _status_str(status_code: int, fail_flag: bool) -> str:
s = str(status_code)
if fail_flag and status_code >= 400:
s += ""
return s
def _print_single(r: Result, fail_flag: bool, out: IO) -> None:
status = _status_str(r.status_code, fail_flag)
out.write(f"{r.url} ({status})\n")
for attr, label in _PHASE_LABELS:
ph = getattr(r, attr)
if ph.present:
out.write(f" {label} : {ph.ms:8.2f} ms\n")
out.write(_SINGLE_SEP + "\n")
if r.total.present:
out.write(f" {'Total '} : {r.total.ms:8.2f} ms\n")
if r.err is not None:
out.write(f"{r.fail_phase}: {r.err}\n")
if r.detail is not None:
_print_verbose_block(r.detail, out)
def _print_aggregate(
agg: Aggregate,
failed: list[Result],
fail_flag: bool,
out: IO,
detail: VerboseDetail | None = None,
) -> None:
status = _status_str(agg.status_code, fail_flag)
header = f"{agg.url} ({status}, {agg.count} samples"
if failed:
header += f", {len(failed)} failed"
out.write(header + ")\n")
if agg.total.present:
out.write(f" {'':14} {'min':>9} {'avg':>9} {'max':>9}\n")
for attr, label in _PHASE_LABELS:
ps: PhaseStats = getattr(agg, attr)
if ps.present:
out.write(
f" {label} : {ps.min_ms:6.2f} ms"
f" {ps.avg_ms:6.2f} ms"
f" {ps.max_ms:6.2f} ms\n"
)
out.write(_AGG_SEP + "\n")
out.write(
f" {'Total '} : {agg.total.min_ms:6.2f} ms"
f" {agg.total.avg_ms:6.2f} ms"
f" {agg.total.max_ms:6.2f} ms\n"
)
_print_failure_summary(failed, out)
if detail is not None:
_print_verbose_block(detail, out)
def _print_all_failed(
url: str, failed: list[Result], total_count: int, out: IO
) -> None:
header = f"{url} (FAILED"
if total_count > 1:
header += f", 0/{total_count} succeeded"
out.write(header + ")\n")
last = failed[-1]
any_phase = False
for attr, label in _PHASE_LABELS:
ph = getattr(last, attr)
if ph.present:
out.write(f" {label} : {ph.ms:8.2f} ms\n")
any_phase = True
if last.total.present:
if any_phase:
out.write(_SINGLE_SEP + "\n")
out.write(f" {'Total '} : {last.total.ms:8.2f} ms\n")
_print_failure_summary(failed, out)
if last.detail is not None:
_print_verbose_block(last.detail, out)
def _print_failure_summary(failed: list[Result], out: IO) -> None:
if not failed:
return
counts: dict[tuple[str, str], int] = {}
order: list[tuple[str, str]] = []
for r in failed:
key = (r.fail_phase, str(r.err))
if key not in counts:
order.append(key)
counts[key] = 0
counts[key] += 1
for phase, msg in order:
n = counts[(phase, msg)]
if n == 1:
out.write(f"{phase}: {msg}\n")
else:
out.write(f"{n} × {phase}: {msg}\n")
def _print_url(
url: str,
succeeded: list[Result],
failed: list[Result],
total_count: int,
fail_flag: bool,
out: IO,
) -> None:
n_ok = len(succeeded)
n_fail = len(failed)
last_detail = succeeded[-1].detail if succeeded else None
if n_fail == 0 and total_count == 1:
_print_single(succeeded[0], fail_flag, out)
elif n_fail == 0:
_print_aggregate(summarize(succeeded), [], fail_flag, out, last_detail)
elif n_ok == 0:
_print_all_failed(url, failed, total_count, out)
else:
_print_aggregate(summarize(succeeded), failed, fail_flag, out, last_detail)
# ── JSON rendering ────────────────────────────────────────────────────────────
def _build_json_entry(
url: str,
succeeded: list[Result],
failed: list[Result],
detail: VerboseDetail | None = None,
) -> dict:
entry: dict = {
"url": url,
"status": 0,
"succeeded": len(succeeded),
"failed": len(failed),
}
if succeeded:
agg = summarize(succeeded)
entry["status"] = agg.status_code
phases: dict = {}
for attr in ("dns", "connect", "tls", "ttfb", "transfer", "total"):
ps: PhaseStats = getattr(agg, attr)
if ps.present:
phases[attr] = {
"min_ms": ps.min_ms,
"avg_ms": ps.avg_ms,
"max_ms": ps.max_ms,
}
if phases:
entry["phases"] = phases
if failed:
counts: dict[tuple[str, str], int] = {}
order: list[tuple[str, str]] = []
for r in failed:
key = (r.fail_phase, str(r.err))
if key not in counts:
order.append(key)
counts[key] = 0
counts[key] += 1
entry["errors"] = [
{"phase": ph, "count": counts[(ph, msg)], "message": msg}
for ph, msg in order
]
if detail is not None:
v: dict = {}
if detail.resolved_ip:
v["ip"] = detail.resolved_ip
if detail.tls_version:
v["tls_version"] = detail.tls_version
v["tls_cipher"] = detail.tls_cipher
v["tls_bits"] = detail.tls_bits
if detail.cert:
c = detail.cert
v["cert"] = {
"cn": c.cn,
"sans": c.sans,
"expiry": c.expiry,
"issuer_cn": c.issuer_cn,
"verified": c.verified,
}
if detail.headers:
v["headers"] = dict(detail.headers)
if v:
entry["verbose"] = v
return entry
# ── entry point ───────────────────────────────────────────────────────────────
def run(args: list[str], stdout: IO, stderr: IO) -> int:
parser = _Parser(
prog="latprobe",
description="measure per-phase HTTP request latency",
out=stdout,
err=stderr,
)
parser.add_argument("urls", nargs="+", metavar="url")
parser.add_argument(
"-n", "--count", type=int, default=1, metavar="N",
help="number of requests per URL (default: 1)",
)
parser.add_argument(
"-c", "--concurrency", type=int, default=0, metavar="N",
help="max parallel URLs, 0=auto (default: 0)",
)
parser.add_argument(
"--timeout", default="10s", metavar="DURATION",
help="per-request timeout, e.g. 10s, 500ms (default: 10s)",
)
parser.add_argument(
"--fail", action="store_true",
help="exit non-zero on HTTP status >= 400",
)
parser.add_argument(
"--json", action="store_true", dest="json_out",
help="output results as JSON instead of text",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="show resolved IP, TLS version/cipher, certificate, and response headers",
)
try:
ns = parser.parse_args(args)
except _ArgExit as exc:
return exc.code
try:
timeout_secs = parse_duration(ns.timeout)
except ValueError:
stderr.write(f"latprobe: error: invalid timeout: {ns.timeout!r}\n")
return EXIT_USAGE
opts = Options(timeout=timeout_secs, verbose=ns.verbose)
urls = ns.urls
count = ns.count
workers = ns.concurrency
if workers <= 0:
workers = min(len(urls), 8)
workers = max(1, min(workers, len(urls)))
def _probe(url: str) -> tuple[list[Result], list[Result]]:
return _run_samples(url, count, opts)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
all_results = list(ex.map(_probe, urls))
worst = EXIT_OK
json_items = []
for i, (url, (succeeded, failed)) in enumerate(zip(urls, all_results)):
for r in failed:
c = _phase_code(r.fail_phase)
if c > worst:
worst = c
if ns.fail:
for r in succeeded:
if r.status_code >= 400:
worst = max(worst, EXIT_HTTP)
last_detail = succeeded[-1].detail if succeeded else (
failed[-1].detail if failed else None
)
if ns.json_out:
json_items.append(_build_json_entry(url, succeeded, failed, last_detail))
continue
if i > 0:
stdout.write("\n")
_print_url(url, succeeded, failed, count, ns.fail, stdout)
if ns.json_out:
stdout.write(json.dumps(json_items, indent=2) + "\n")
return worst

View File

@@ -0,0 +1,14 @@
def parse_duration(s: str) -> float:
"""Parse a duration string to seconds.
Suffixes: ms (milliseconds), s (seconds), m (minutes).
A bare number is treated as seconds.
"""
s = s.strip()
if s.endswith("ms"):
return float(s[:-2]) / 1000
if s.endswith("s"):
return float(s[:-1])
if s.endswith("m"):
return float(s[:-1]) * 60
return float(s)

306
python/latprobe/probe.py Normal file
View File

@@ -0,0 +1,306 @@
from __future__ import annotations
import datetime
import socket
import ssl
import time
import urllib.parse
from dataclasses import dataclass, field
# ── verbose detail dataclasses ────────────────────────────────────────────────
@dataclass
class CertInfo:
cn: str = ""
sans: list[str] = field(default_factory=list)
expiry: str = "" # ISO date "YYYY-MM-DD"
issuer_cn: str = ""
verified: bool = False # True = TLS handshake passed verification
@dataclass
class VerboseDetail:
resolved_ip: str = ""
tls_version: str = ""
tls_cipher: str = ""
tls_bits: int = 0
cert: CertInfo | None = None
headers: dict[str, str] = field(default_factory=dict) # all parsed response headers
# ── core dataclasses ──────────────────────────────────────────────────────────
@dataclass
class Options:
timeout: float = 10.0
verbose: bool = False
@dataclass
class Phase:
ms: float = 0.0
present: bool = False
@dataclass
class Result:
url: str
dns: Phase = field(default_factory=Phase)
connect: Phase = field(default_factory=Phase)
tls: Phase = field(default_factory=Phase)
ttfb: Phase = field(default_factory=Phase)
transfer: Phase = field(default_factory=Phase)
total: Phase = field(default_factory=Phase)
status_code: int = 0
fail_phase: str = ""
err: Exception | None = None
detail: VerboseDetail | None = None # populated only when opts.verbose=True
# ── internal helpers ──────────────────────────────────────────────────────────
def _p(start: float, end: float) -> Phase:
return Phase(ms=(end - start) * 1000, present=True)
def _parse_status(data: bytes) -> int:
eol = data.find(b"\r\n")
if eol == -1:
eol = data.find(b"\n")
if eol == -1:
return 0
parts = data[:eol].decode("latin-1", errors="replace").split(None, 2)
if len(parts) >= 2:
try:
return int(parts[1])
except ValueError:
pass
return 0
def _parse_cert_date(s: str) -> str:
"""Convert SSL cert date 'Jun 14 00:00:00 2025 GMT''2025-06-14'."""
if not s:
return ""
s = " ".join(s.split()) # collapse double-spaces ("May 5 ..." → "May 5 ...")
for fmt in ("%b %d %H:%M:%S %Y %Z", "%b %d %H:%M:%S %Y"):
try:
return datetime.datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return ""
def _parse_cert(peer: dict, *, verified: bool) -> CertInfo:
"""Build CertInfo from the dict returned by SSLSocket.getpeercert()."""
def _attr(rdns, key: str) -> str:
for rdn in rdns:
for k, v in rdn:
if k == key:
return v
return ""
issuer = peer.get("issuer", ())
# Prefer organizationName for issuer (more human-readable than CA CN)
issuer_cn = _attr(issuer, "organizationName") or _attr(issuer, "commonName")
return CertInfo(
cn=_attr(peer.get("subject", ()), "commonName"),
sans=[v for k, v in peer.get("subjectAltName", ()) if k == "DNS"],
expiry=_parse_cert_date(peer.get("notAfter", "")),
issuer_cn=issuer_cn,
verified=verified,
)
def _parse_response_headers(buf: bytes) -> dict[str, str]:
"""Parse all HTTP response headers from a raw buffer (up to \\r\\n\\r\\n)."""
header_section = buf.split(b"\r\n\r\n", 1)[0]
lines = header_section.decode("latin-1", errors="replace").splitlines()
headers: dict[str, str] = {}
for line in lines[1:]: # skip status line
if ":" in line:
name, _, value = line.partition(":")
headers[name.strip()] = value.strip()
return headers
# ── measure ───────────────────────────────────────────────────────────────────
def measure(raw_url: str, opts: Options | None = None) -> Result:
"""Probe raw_url and return a Result with per-phase timings.
Partial phases are preserved when the request fails mid-flight.
Redirects are not followed; bodies are drained so Transfer timing is real.
When opts.verbose=True, Result.detail is populated with resolved IP,
TLS metadata, certificate info, and response headers.
"""
if opts is None:
opts = Options()
r = Result(url=raw_url)
if opts.verbose:
r.detail = VerboseDetail()
parsed = urllib.parse.urlparse(raw_url)
scheme = parsed.scheme.lower()
if scheme not in ("http", "https"):
r.fail_phase = "request"
r.err = ValueError(f"unsupported scheme: {scheme!r}")
return r
host = parsed.hostname or ""
port = parsed.port or (443 if scheme == "https" else 80)
path = parsed.path or "/"
if parsed.query:
path = path + "?" + parsed.query
use_tls = scheme == "https"
t_start = time.perf_counter()
# ── DNS ──────────────────────────────────────────────────────────────────
t0 = time.perf_counter()
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
r.fail_phase = "dns"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
r.dns = _p(t0, time.perf_counter())
if r.detail is not None:
r.detail.resolved_ip = str(infos[0][4][0])
# ── TCP connect ───────────────────────────────────────────────────────────
addr = infos[0][4]
family = infos[0][0]
sock = socket.socket(family, socket.SOCK_STREAM)
sock.settimeout(opts.timeout)
t0 = time.perf_counter()
try:
sock.connect(addr)
except socket.timeout as exc:
sock.close()
r.connect = _p(t0, time.perf_counter())
r.fail_phase = "timeout"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
except OSError as exc:
sock.close()
r.connect = _p(t0, time.perf_counter())
r.fail_phase = "connect"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
r.connect = _p(t0, time.perf_counter())
# ── TLS handshake (HTTPS only) ────────────────────────────────────────────
if use_tls:
ctx = ssl.create_default_context()
t0 = time.perf_counter()
try:
sock = ctx.wrap_socket(sock, server_hostname=host)
except socket.timeout as exc:
r.tls = _p(t0, time.perf_counter())
r.fail_phase = "timeout"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
except (ssl.SSLError, OSError) as exc:
r.tls = _p(t0, time.perf_counter())
r.fail_phase = "tls"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
r.tls = _p(t0, time.perf_counter())
if r.detail is not None:
r.detail.tls_version = sock.version() or ""
cipher_name, _, bits = sock.cipher()
r.detail.tls_cipher = cipher_name or ""
r.detail.tls_bits = bits or 0
peer = sock.getpeercert()
if peer:
r.detail.cert = _parse_cert(peer, verified=True)
# ── Send request ──────────────────────────────────────────────────────────
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Connection: close\r\n"
f"User-Agent: latprobe/1.0\r\n"
f"\r\n"
).encode()
try:
sock.sendall(request)
except OSError as exc:
sock.close()
r.fail_phase = "request"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
t_wrote = time.perf_counter()
# ── TTFB — accumulate until end of headers (\r\n\r\n) ────────────────────
#
# t_first_byte is stamped on the first recv() that returns data (unchanged
# semantics vs the old single-recv approach). We keep reading until the
# full header section is in buf so that response headers can be parsed when
# opts.verbose=True.
try:
buf = b""
t_first_byte: float | None = None
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
raise OSError("server closed connection before headers complete")
if t_first_byte is None:
t_first_byte = time.perf_counter()
buf += chunk
except socket.timeout as exc:
sock.close()
r.fail_phase = "timeout"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
except OSError as exc:
sock.close()
r.fail_phase = "transfer"
r.err = exc
r.total = _p(t_start, time.perf_counter())
return r
t_first_byte = t_first_byte or time.perf_counter()
r.ttfb = _p(t_wrote, t_first_byte)
r.status_code = _parse_status(buf)
if r.detail is not None:
r.detail.headers = _parse_response_headers(buf)
# ── Transfer — drain remaining body ───────────────────────────────────────
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
except OSError:
pass
finally:
sock.close()
t_end = time.perf_counter()
r.transfer = _p(t_first_byte, t_end)
r.total = _p(t_start, t_end)
return r

286
python/phases.py Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""phases.py — measure per-phase HTTP latency using raw sockets.
Phases: DNS lookup, TCP connect, TLS handshake (HTTPS only),
Server/TTFB (sent → first byte), Transfer (first byte → EOF), Total.
Usage:
python phases.py <url> [url ...] # one or more URLs
python phases.py <config_file> # plain-text list of URLs
Config file: one URL per line; '#' lines and blank lines ignored.
Exits 0 if all URLs complete without network error, 1 if any failed.
"""
import socket
import ssl
import sys
import time
import urllib.parse
from dataclasses import dataclass, field
_TIMEOUT = 10.0
_SEP = "" * 29
# (dataclass field name, 14-char display label)
_PHASE_LABELS = [
("dns", "DNS lookup "),
("connect", "TCP connect "),
("tls", "TLS handshake "),
("ttfb", "Server (TTFB) "),
("transfer", "Transfer "),
]
@dataclass
class Phase:
ms: float = 0.0
present: bool = False
@dataclass
class Result:
url: str
dns: Phase = field(default_factory=Phase)
connect: Phase = field(default_factory=Phase)
tls: Phase = field(default_factory=Phase)
ttfb: Phase = field(default_factory=Phase)
transfer: Phase = field(default_factory=Phase)
total: Phase = field(default_factory=Phase)
status_code: int = 0
fail_phase: str = ""
err: Exception | None = None
def _phase(start: float, end: float) -> Phase:
return Phase(ms=(end - start) * 1000, present=True)
def _parse_status(data: bytes) -> int:
"""Extract HTTP status code from the first recv() chunk."""
eol = data.find(b"\r\n")
if eol == -1:
eol = data.find(b"\n")
if eol == -1:
return 0
parts = data[:eol].decode("latin-1", errors="replace").split(None, 2)
if len(parts) >= 2:
try:
return int(parts[1])
except ValueError:
pass
return 0
def measure(raw_url: str) -> Result:
"""Probe raw_url and return a Result with per-phase timings.
Partial phases are preserved when the request fails mid-flight.
Method is always GET; bodies are fully drained so Transfer timing is real.
Redirects are NOT followed — the raw HTTP response status is reported.
"""
r = Result(url=raw_url)
parsed = urllib.parse.urlparse(raw_url)
scheme = parsed.scheme.lower()
if scheme not in ("http", "https"):
r.fail_phase = "request"
r.err = ValueError(f"unsupported scheme: {scheme!r}")
return r
host = parsed.hostname or ""
port = parsed.port or (443 if scheme == "https" else 80)
path = parsed.path or "/"
if parsed.query:
path = path + "?" + parsed.query
use_tls = scheme == "https"
t_start = time.perf_counter()
# ── DNS ──────────────────────────────────────────────────────────────────
t0 = time.perf_counter()
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
r.fail_phase = "dns"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
r.dns = _phase(t0, time.perf_counter())
# ── TCP connect ───────────────────────────────────────────────────────────
addr = infos[0][4]
family = infos[0][0]
sock = socket.socket(family, socket.SOCK_STREAM)
sock.settimeout(_TIMEOUT)
t0 = time.perf_counter()
try:
sock.connect(addr)
except socket.timeout as exc:
sock.close()
r.connect = _phase(t0, time.perf_counter())
r.fail_phase = "timeout"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
except OSError as exc:
sock.close()
r.connect = _phase(t0, time.perf_counter())
r.fail_phase = "connect"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
r.connect = _phase(t0, time.perf_counter())
# ── TLS handshake (HTTPS only) ────────────────────────────────────────────
if use_tls:
ctx = ssl.create_default_context()
t0 = time.perf_counter()
try:
# wrap_socket blocks until the handshake completes (do_handshake_on_connect=True)
sock = ctx.wrap_socket(sock, server_hostname=host)
except socket.timeout as exc:
# Timeout during handshake classifies as timeout, not tls (mirrors Go)
r.tls = _phase(t0, time.perf_counter())
r.fail_phase = "timeout"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
except (ssl.SSLError, OSError) as exc:
r.tls = _phase(t0, time.perf_counter())
r.fail_phase = "tls"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
r.tls = _phase(t0, time.perf_counter())
# ── Send request ──────────────────────────────────────────────────────────
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Connection: close\r\n"
f"User-Agent: latprobe-phases/1.0\r\n"
f"\r\n"
).encode()
try:
sock.sendall(request)
except OSError as exc:
sock.close()
r.fail_phase = "request"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
t_wrote = time.perf_counter()
# ── TTFB — sent → first response byte ────────────────────────────────────
try:
first_chunk = b""
while not first_chunk:
chunk = sock.recv(4096)
if not chunk:
raise OSError("server closed connection before sending a response")
first_chunk = chunk
except socket.timeout as exc:
sock.close()
r.fail_phase = "timeout"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
except OSError as exc:
sock.close()
r.fail_phase = "transfer"
r.err = exc
r.total = _phase(t_start, time.perf_counter())
return r
t_first_byte = time.perf_counter()
r.ttfb = _phase(t_wrote, t_first_byte)
r.status_code = _parse_status(first_chunk)
# ── Transfer — drain remaining body ───────────────────────────────────────
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
except OSError:
pass # connection reset during body drain is acceptable; timing is captured
finally:
sock.close()
t_end = time.perf_counter()
r.transfer = _phase(t_first_byte, t_end)
r.total = _phase(t_start, t_end)
return r
def print_result(r: Result) -> None:
if r.err is not None and r.status_code == 0:
print(f"{r.url} (FAILED)")
else:
print(f"{r.url} ({r.status_code})")
for attr, label in _PHASE_LABELS:
phase: Phase = getattr(r, attr)
if phase.present:
print(f" {label} : {phase.ms:8.2f} ms")
print(f" {_SEP}")
if r.total.present:
print(f" {'Total '} : {r.total.ms:8.2f} ms")
if r.err is not None:
print(f"{r.fail_phase}: {r.err}")
def load_config(path: str) -> list[str]:
urls = []
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
urls.append(line.split()[0])
return urls
def main(argv: list[str]) -> int:
if not argv:
print(__doc__, file=sys.stderr)
return 1
if argv[0].startswith(("http://", "https://")):
urls = argv
else:
try:
urls = load_config(argv[0])
except FileNotFoundError:
print(f"error: file not found: {argv[0]}", file=sys.stderr)
return 1
except OSError as exc:
print(f"error: cannot read config: {exc}", file=sys.stderr)
return 1
if not urls:
print("error: no URLs found", file=sys.stderr)
return 1
any_failed = False
for i, url in enumerate(urls):
if i > 0:
print()
r = measure(url)
print_result(r)
if r.err is not None:
any_failed = True
return 1 if any_failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

88
python/simple.py Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""simple.py — check whether a list of sites is reachable and how fast they respond.
Usage:
python simple.py [sites.txt]
The config file is a plain-text list of URLs, one per line.
Lines starting with '#' and blank lines are ignored.
Exits 0 if all sites responded, 1 if any failed.
"""
import sys
import time
import urllib.error
import urllib.request
_TIMEOUT = 10 # seconds
def load_sites(path: str) -> list[str]:
"""Return URLs from a plain-text config file (one per line, # comments)."""
urls = []
with open(path) as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
# Take only the first token so future 'url key=value' annotations
# (e.g. budget=200ms) don't break the URL.
urls.append(line.split()[0])
return urls
def check_site(url: str) -> tuple[bool, float, str]:
"""Probe url and return (ok, elapsed_ms, error_message).
The body is fully read so the elapsed time includes transfer, not just TTFB.
"""
t0 = time.perf_counter()
try:
with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp:
resp.read()
elapsed = (time.perf_counter() - t0) * 1000
return True, elapsed, ""
except urllib.error.HTTPError as exc:
elapsed = (time.perf_counter() - t0) * 1000
# Show the full "HTTP Error CODE: REASON" so the status code is visible.
return False, elapsed, str(exc)
except urllib.error.URLError as exc:
elapsed = (time.perf_counter() - t0) * 1000
# URLError.reason is either a string or another exception.
reason = str(exc.reason) if exc.reason else str(exc)
return False, elapsed, reason
except Exception as exc: # noqa: BLE001 — catch-all for unexpected errors
elapsed = (time.perf_counter() - t0) * 1000
return False, elapsed, str(exc)
def main(argv: list[str]) -> int:
config = argv[0] if argv else "sites.txt"
try:
urls = load_sites(config)
except FileNotFoundError:
print(f"error: config file not found: {config}", file=sys.stderr)
return 1
except OSError as exc:
print(f"error: cannot read config file: {exc}", file=sys.stderr)
return 1
if not urls:
print("error: no URLs found in config file", file=sys.stderr)
return 1
any_failed = False
for url in urls:
ok, elapsed_ms, err = check_site(url)
if ok:
print(f"OK {elapsed_ms:8.2f} ms {url}")
else:
any_failed = True
print(f"FAIL ({err}) {url}")
return 1 if any_failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

6
python/sites.txt Normal file
View File

@@ -0,0 +1,6 @@
# Example sites config for simple.py and phases.py.
# One URL per line; blank lines and lines starting with '#' are ignored.
https://example.com
https://www.google.com
# https://httpbin.org/get # uncomment to include

0
python/tests/__init__.py Normal file
View File

386
python/tests/test_cli.py Normal file
View File

@@ -0,0 +1,386 @@
import http.server
import io
import json
import socket
import threading
import unittest
from latprobe.cli import (
EXIT_CONNECT,
EXIT_DNS,
EXIT_HTTP,
EXIT_OK,
EXIT_TIMEOUT,
EXIT_USAGE,
run,
)
class _OKHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"hello latprobe")
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(404)
self.end_headers()
self.wfile.write(b"not found")
def log_message(self, *args):
pass
def _start_server(handler_class):
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
return server, server.server_address[1]
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _black_hole_port() -> int:
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(10)
port = srv.getsockname()[1]
conns: list = []
def _serve():
while True:
try:
conn, _ = srv.accept()
conns.append(conn)
except OSError:
break
threading.Thread(target=_serve, daemon=True).start()
return port
def _invoke(args: list[str]) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
code = run(args, out, err)
return code, out.getvalue(), err.getvalue()
class TestCLIUsageErrors(unittest.TestCase):
def test_no_args_returns_usage(self):
code, out, err = _invoke([])
self.assertEqual(code, EXIT_USAGE)
def test_help_returns_ok(self):
code, out, err = _invoke(["-h"])
self.assertEqual(code, EXIT_OK)
self.assertIn("latprobe", out)
def test_invalid_timeout_returns_usage(self):
code, out, err = _invoke(["--timeout", "bad", "http://example.com"])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("timeout", err)
class TestCLISuccess(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def _url(self, port=None):
return f"http://127.0.0.1:{port or self.ok_port}"
def test_single_url_success(self):
code, out, err = _invoke([self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("Total", out)
self.assertIn("DNS lookup", out)
def test_output_has_separator(self):
code, out, _ = _invoke([self._url()])
self.assertIn("", out)
def test_count_shows_aggregate(self):
code, out, _ = _invoke(["-n", "3", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("3 samples", out)
self.assertIn("min", out)
self.assertIn("avg", out)
self.assertIn("max", out)
def test_multiple_urls_output_separated_by_blank_line(self):
url = self._url()
code, out, _ = _invoke([url, url])
self.assertEqual(code, EXIT_OK)
self.assertIn("\n\n", out)
def test_fail_flag_ok_on_200(self):
code, out, _ = _invoke(["--fail", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertNotIn("", out)
def test_fail_flag_on_404(self):
code, out, _ = _invoke(["--fail", self._url(self.nf_port)])
self.assertEqual(code, EXIT_HTTP)
self.assertIn("", out)
class TestCLIFailures(unittest.TestCase):
def test_dns_failure(self):
code, out, _ = _invoke(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
self.assertIn("FAILED", out)
self.assertIn("", out)
def test_connection_refused(self):
port = _free_port()
code, out, _ = _invoke([f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("FAILED", out)
def test_timeout(self):
port = _black_hole_port()
code, _, _ = _invoke([f"http://127.0.0.1:{port}", "--timeout", "200ms"])
self.assertEqual(code, EXIT_TIMEOUT)
def test_worst_code_across_urls(self):
server, port = _start_server(
type("_H", (http.server.BaseHTTPRequestHandler,), {
"do_GET": lambda self: (self.send_response(200), self.end_headers(), self.wfile.write(b"ok")),
"log_message": lambda *a: None,
})
)
try:
code, _, _ = _invoke([
f"http://127.0.0.1:{port}", # exit 0
"http://no.such.host.invalid", # exit 2
])
self.assertEqual(code, EXIT_DNS)
finally:
server.shutdown()
def test_all_failed_single_header(self):
code, out, _ = _invoke(["http://no.such.host.invalid"])
self.assertIn("(FAILED)", out)
self.assertNotIn("0/1 succeeded", out)
def test_all_failed_multi_header(self):
code, out, _ = _invoke([
"-n", "3",
"http://no.such.host.invalid",
])
self.assertIn("0/3 succeeded", out)
def test_mixed_aggregate_shows_samples(self):
port = _free_port()
server, ok_port = _start_server(_OKHandler)
try:
code, out, _ = _invoke([
"-n", "1",
f"http://127.0.0.1:{ok_port}",
f"http://127.0.0.1:{port}",
])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("200", out)
finally:
server.shutdown()
class TestCLIJSON(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def _url(self, port=None):
return f"http://127.0.0.1:{port or self.ok_port}"
def test_json_success_schema(self):
code, out, _ = _invoke(["--json", self._url()])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertIsInstance(data, list)
self.assertEqual(len(data), 1)
entry = data[0]
self.assertEqual(entry["url"], self._url())
self.assertEqual(entry["status"], 200)
self.assertEqual(entry["succeeded"], 1)
self.assertEqual(entry["failed"], 0)
self.assertIn("phases", entry)
self.assertIn("total", entry["phases"])
self.assertNotIn("errors", entry)
def test_json_phase_fields(self):
code, out, _ = _invoke(["--json", self._url()])
data = json.loads(out)
total = data[0]["phases"]["total"]
for key in ("min_ms", "avg_ms", "max_ms"):
self.assertIn(key, total)
self.assertGreater(total[key], 0)
def test_json_dns_failure_schema(self):
code, out, _ = _invoke(["--json", "http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
data = json.loads(out)
entry = data[0]
self.assertEqual(entry["succeeded"], 0)
self.assertEqual(entry["failed"], 1)
self.assertNotIn("phases", entry)
self.assertIn("errors", entry)
self.assertEqual(entry["errors"][0]["phase"], "dns")
def test_json_sampling(self):
code, out, _ = _invoke(["--json", "-n", "3", self._url()])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertEqual(data[0]["succeeded"], 3)
def test_json_multiple_urls_ordered(self):
code, out, _ = _invoke([
"--json",
self._url(),
"http://no.such.host.invalid",
])
data = json.loads(out)
self.assertEqual(len(data), 2)
self.assertEqual(data[0]["status"], 200)
self.assertEqual(data[1]["succeeded"], 0)
def test_json_fail_flag_exit_code(self):
code, out, _ = _invoke(["--json", "--fail", self._url(self.nf_port)])
self.assertEqual(code, EXIT_HTTP)
data = json.loads(out)
self.assertEqual(data[0]["status"], 404)
def test_json_error_grouping(self):
code, out, _ = _invoke([
"--json", "-n", "2",
"http://no.such.host.invalid",
])
data = json.loads(out)
self.assertEqual(data[0]["errors"][0]["count"], 2)
class TestCLIVerbose(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def _url(self, port=None):
return f"http://127.0.0.1:{port or self.ok_port}"
def test_verbose_shows_ip(self):
code, out, _ = _invoke(["--verbose", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("IP", out)
self.assertIn("127.0.0.1", out)
def test_short_flag_v(self):
code, out, _ = _invoke(["-v", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("127.0.0.1", out)
def test_verbose_no_tls_for_http(self):
code, out, _ = _invoke(["--verbose", self._url()])
self.assertNotIn("TLS", out)
self.assertNotIn("Cert", out)
def test_verbose_shows_headers(self):
code, out, _ = _invoke(["--verbose", self._url()])
# BaseHTTPServer sends Server and Content-Type
self.assertIn("Server", out)
def test_verbose_absent_without_flag(self):
code, out, _ = _invoke([self._url()])
# Without --verbose, there is no IP label row (the URL contains the
# IP but is not followed by a " IP" verbose block line)
self.assertNotIn("\n IP ", out)
def test_verbose_aggregate_shows_ip(self):
code, out, _ = _invoke(["-v", "-n", "2", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("127.0.0.1", out)
self.assertIn("2 samples", out)
def test_verbose_on_connect_fail_shows_ip(self):
port = _free_port()
code, out, _ = _invoke(["--verbose", f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("FAILED", out)
self.assertIn("127.0.0.1", out)
def test_verbose_dns_fail_no_ip(self):
code, out, _ = _invoke(["--verbose", "http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
# IP is unknown for DNS failures — verbose block is empty so no IP row
self.assertNotIn("IP", out)
def test_verbose_json_includes_ip(self):
code, out, _ = _invoke(["--verbose", "--json", self._url()])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertIn("verbose", data[0])
self.assertEqual(data[0]["verbose"]["ip"], "127.0.0.1")
def test_verbose_json_includes_headers(self):
code, out, _ = _invoke(["--verbose", "--json", self._url()])
data = json.loads(out)
self.assertIn("headers", data[0]["verbose"])
self.assertIsInstance(data[0]["verbose"]["headers"], dict)
def test_no_verbose_key_in_json_without_flag(self):
code, out, _ = _invoke(["--json", self._url()])
data = json.loads(out)
self.assertNotIn("verbose", data[0])
def test_verbose_json_no_tls_for_http(self):
code, out, _ = _invoke(["--verbose", "--json", self._url()])
data = json.loads(out)
self.assertNotIn("tls_version", data[0]["verbose"])
self.assertNotIn("cert", data[0]["verbose"])
def test_verbose_json_connect_fail_has_ip(self):
port = _free_port()
code, out, _ = _invoke(["--verbose", "--json", f"http://127.0.0.1:{port}"])
data = json.loads(out)
self.assertIn("verbose", data[0])
self.assertEqual(data[0]["verbose"]["ip"], "127.0.0.1")
def test_verbose_json_dns_fail_no_verbose_object(self):
code, out, _ = _invoke(["--verbose", "--json", "http://no.such.host.invalid"])
data = json.loads(out)
# DNS failure: detail has no IP, so the verbose dict is empty → omitted
self.assertNotIn("verbose", data[0])

View File

@@ -0,0 +1,430 @@
"""Integration tests against live internet services.
Run with:
make py-test-integration # from repo root
PYTHONPATH=python python3.14 python/tests/test_integration.py -v
These tests require network access. The full suite takes roughly 2040 s
because TLS handshakes to badssl.com are slow and the timeout test waits
2 s for a non-routable IP to time out.
badssl.com tests are marked with a note — that host occasionally resets
connections mid-handshake, so the failure may show up as 'connect' rather
than 'tls'. Both are accepted.
"""
import io
import json
import socket
import sys
import unittest
from latprobe.cli import EXIT_DNS, EXIT_HTTP, EXIT_OK, EXIT_TIMEOUT, EXIT_TLS, run
from latprobe.probe import Options, VerboseDetail, measure
# ── connectivity guard ────────────────────────────────────────────────────────
def _online() -> bool:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect(("8.8.8.8", 53))
s.close()
return True
except OSError:
return False
_NEEDS_NET = unittest.skipUnless(_online(), "no internet connectivity")
# ── helpers ───────────────────────────────────────────────────────────────────
def _run(args: list[str]) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
code = run(args, out, err)
return code, out.getvalue(), err.getvalue()
# ── success ───────────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestSuccess(unittest.TestCase):
def test_https_all_phases_present(self):
r = measure("https://example.com")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 200)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.tls.present)
self.assertTrue(r.ttfb.present)
self.assertTrue(r.transfer.present)
self.assertTrue(r.total.present)
def test_http_no_tls_phase(self):
r = measure("http://example.com")
self.assertIsNone(r.err, r.err)
self.assertFalse(r.tls.present)
def test_iana_org(self):
r = measure("https://www.iana.org")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 200)
def test_google_com(self):
r = measure("https://www.google.com")
self.assertIsNone(r.err, r.err)
self.assertIn(r.status_code, (200, 301, 302))
def test_timings_are_positive(self):
r = measure("https://example.com")
for attr in ("dns", "connect", "tls", "ttfb", "transfer", "total"):
ph = getattr(r, attr)
if ph.present:
self.assertGreater(ph.ms, 0, f"{attr}.ms should be > 0")
def test_total_covers_all_present_phases(self):
r = measure("https://example.com")
phase_sum = sum(
getattr(r, a).ms
for a in ("dns", "connect", "tls", "ttfb", "transfer")
if getattr(r, a).present
)
self.assertGreaterEqual(r.total.ms, phase_sum * 0.9)
# ── DNS failure ───────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestDNSFailure(unittest.TestCase):
def test_invalid_tld_phase(self):
r = measure("http://no.such.host.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertIsNotNone(r.err)
self.assertFalse(r.dns.present)
self.assertFalse(r.connect.present)
self.assertTrue(r.total.present)
def test_invalid_tld_https(self):
r = measure("https://this-host-does-not-exist.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertFalse(r.tls.present)
def test_cli_exit_code(self):
code, out, _ = _run(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
self.assertIn("FAILED", out)
self.assertIn("dns", out)
def test_cli_json_errors_field(self):
code, out, _ = _run(["--json", "http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
data = json.loads(out)
self.assertEqual(data[0]["succeeded"], 0)
self.assertEqual(data[0]["failed"], 1)
self.assertNotIn("phases", data[0])
self.assertEqual(data[0]["errors"][0]["phase"], "dns")
# ── TLS errors (badssl.com) ───────────────────────────────────────────────────
@_NEEDS_NET
class TestTLSErrors(unittest.TestCase):
"""badssl.com occasionally resets mid-handshake; 'connect' is also accepted."""
def _assert_tls_or_connect_fail(self, url: str) -> None:
r = measure(url)
self.assertIsNotNone(r.err, f"{url} — expected failure")
self.assertIn(r.fail_phase, ("tls", "connect"),
f"unexpected phase for {url}: {r.fail_phase}")
self.assertTrue(r.dns.present, "dns should have completed")
def test_expired_cert(self):
self._assert_tls_or_connect_fail("https://expired.badssl.com/")
def test_self_signed_cert(self):
self._assert_tls_or_connect_fail("https://self-signed.badssl.com/")
def test_incomplete_chain(self):
self._assert_tls_or_connect_fail("https://incomplete-chain.badssl.com/")
def test_cli_exit_code_tls(self):
code, out, _ = _run(["https://expired.badssl.com/"])
# badssl may reset connection → connect (3) or tls (5); both > 0
self.assertGreater(code, 0)
self.assertIn("FAILED", out)
def test_partial_phases_dns_and_connect_present(self):
r = measure("https://expired.badssl.com/")
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
# tls phase is recorded even when it fails
self.assertTrue(r.tls.present)
# ── HTTP 4xx / 5xx ───────────────────────────────────────────────────────────
@_NEEDS_NET
class TestHTTP4xx(unittest.TestCase):
def test_google_404_probe_succeeds_at_network_level(self):
r = measure("https://www.google.com/this-page-does-not-exist-at-all-1234567890")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 404)
self.assertTrue(r.total.present)
def test_github_404_all_phases_present(self):
r = measure("https://github.com/this-repo-does-not-exist-abcxyz123/no-way")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 404)
for attr in ("dns", "connect", "tls", "ttfb", "transfer"):
self.assertTrue(getattr(r, attr).present, f"{attr} should be present")
def test_cli_exit_ok_without_fail_flag(self):
code, out, _ = _run(
["https://www.google.com/this-page-does-not-exist-at-all-1234567890"]
)
self.assertEqual(code, EXIT_OK)
self.assertIn("404", out)
self.assertNotIn("", out)
def test_cli_exit_http_with_fail_flag(self):
code, out, _ = _run([
"--fail",
"https://www.google.com/this-page-does-not-exist-at-all-1234567890",
])
self.assertEqual(code, EXIT_HTTP)
self.assertIn("", out)
def test_cli_json_404_status(self):
code, out, _ = _run([
"--json",
"https://www.google.com/this-page-does-not-exist-at-all-1234567890",
])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
self.assertEqual(data[0]["status"], 404)
self.assertEqual(data[0]["succeeded"], 1)
self.assertNotIn("errors", data[0])
def test_iana_404(self):
r = measure("https://www.iana.org/this-page-does-not-exist-either")
self.assertIsNone(r.err, r.err)
self.assertEqual(r.status_code, 404)
# ── timeout ───────────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestTimeout(unittest.TestCase):
"""Uses a non-routable IP (RFC 5737).
On networks that silently drop packets the connect phase waits the full
timeout → fail_phase='timeout'. On networks that return ICMP unreachable
immediately the connect fails with an OS error → fail_phase='connect'.
Both outcomes are accepted; the important assertion is that the probe fails
and the exit code is ≥ EXIT_CONNECT.
"""
_URL = "http://10.255.255.1/"
_OPTS = Options(timeout=2.0)
_FAIL_PHASES = ("timeout", "connect")
def test_probe_fail_phase(self):
r = measure(self._URL, self._OPTS)
self.assertIn(r.fail_phase, self._FAIL_PHASES)
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present) # IP literal — no real DNS lookup
self.assertTrue(r.connect.present)
self.assertTrue(r.total.present)
def test_cli_exit_code(self):
from latprobe.cli import EXIT_CONNECT
code, out, _ = _run(["--timeout", "2s", self._URL])
self.assertIn(code, (EXIT_CONNECT, EXIT_TIMEOUT))
self.assertIn("FAILED", out)
def test_cli_json_error_phase(self):
from latprobe.cli import EXIT_CONNECT
code, out, _ = _run(["--json", "--timeout", "2s", self._URL])
self.assertIn(code, (EXIT_CONNECT, EXIT_TIMEOUT))
data = json.loads(out)
self.assertIn(data[0]["errors"][0]["phase"], self._FAIL_PHASES)
# ── end-to-end multi-URL ─────────────────────────────────────────────────────
@_NEEDS_NET
class TestEndToEnd(unittest.TestCase):
def test_multi_url_worst_code_is_dns(self):
"""success (0) + dns failure (2) → worst exit = 2."""
code, out, _ = _run([
"https://example.com",
"http://no.such.host.invalid",
])
self.assertEqual(code, EXIT_DNS)
self.assertIn("200", out)
self.assertIn("FAILED", out)
def test_multi_url_output_ordered(self):
"""URLs appear in input order regardless of which resolves faster."""
code, out, _ = _run([
"https://www.iana.org",
"https://example.com",
])
self.assertEqual(code, EXIT_OK)
iana_pos = out.find("iana.org")
example_pos = out.find("example.com")
self.assertLess(iana_pos, example_pos)
def test_sampling_json_schema(self):
code, out, _ = _run(["--json", "-n", "3", "https://example.com"])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
entry = data[0]
self.assertEqual(entry["succeeded"], 3)
self.assertIn("tls", entry["phases"])
for phase, stats in entry["phases"].items():
self.assertLessEqual(stats["min_ms"], stats["avg_ms"])
self.assertLessEqual(stats["avg_ms"], stats["max_ms"])
def test_sampling_aggregate_text(self):
code, out, _ = _run(["-n", "3", "https://example.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("3 samples", out)
self.assertIn("min", out)
self.assertIn("max", out)
def test_concurrency_flag(self):
code, out, _ = _run([
"-c", "2",
"https://example.com",
"https://www.iana.org",
])
self.assertEqual(code, EXIT_OK)
self.assertIn("example.com", out)
self.assertIn("iana.org", out)
def test_mixed_fail_flag_exit_code(self):
"""200 (ok) + 404 with --fail → worst = 6."""
code, out, _ = _run([
"--fail",
"https://example.com",
"https://www.google.com/this-page-does-not-exist-at-all-1234567890",
])
self.assertEqual(code, EXIT_HTTP)
# ── verbose mode ─────────────────────────────────────────────────────────────
@_NEEDS_NET
class TestVerboseIntegration(unittest.TestCase):
def test_https_verbose_has_ip(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIsNotNone(r.detail)
self.assertRegex(r.detail.resolved_ip, r"^\d+\.\d+\.\d+\.\d+$",
"expected IPv4 address")
def test_https_verbose_has_tls_version(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIn("TLS", r.detail.tls_version,
f"expected TLSv1.x, got: {r.detail.tls_version!r}")
def test_https_verbose_has_cipher(self):
r = measure("https://example.com", Options(verbose=True))
self.assertNotEqual(r.detail.tls_cipher, "")
def test_https_verbose_cert_cn(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIsNotNone(r.detail.cert)
self.assertIn("example.com", r.detail.cert.cn)
def test_https_verbose_cert_expiry_format(self):
r = measure("https://example.com", Options(verbose=True))
expiry = r.detail.cert.expiry
self.assertRegex(expiry, r"^\d{4}-\d{2}-\d{2}$",
f"expected YYYY-MM-DD, got: {expiry!r}")
def test_https_verbose_cert_verified(self):
r = measure("https://example.com", Options(verbose=True))
self.assertTrue(r.detail.cert.verified)
def test_https_verbose_cert_issuer_set(self):
r = measure("https://example.com", Options(verbose=True))
self.assertNotEqual(r.detail.cert.issuer_cn, "")
def test_https_verbose_headers_present(self):
r = measure("https://example.com", Options(verbose=True))
self.assertGreater(len(r.detail.headers), 0)
# example.com always sends Content-Type
self.assertIn("Content-Type", r.detail.headers)
def test_http_verbose_no_tls(self):
r = measure("http://example.com", Options(verbose=True))
self.assertEqual(r.detail.tls_version, "")
self.assertIsNone(r.detail.cert)
def test_redirect_verbose_shows_location(self):
r = measure("http://example.com", Options(verbose=True))
# example.com HTTP redirects to HTTPS — Location header should be present
if r.status_code in (301, 302, 307, 308):
self.assertIn("Location", r.detail.headers)
def test_dns_fail_verbose_no_ip(self):
r = measure("http://no.such.host.invalid", Options(verbose=True))
self.assertIsNotNone(r.detail)
self.assertEqual(r.detail.resolved_ip, "")
self.assertEqual(r.detail.headers, {})
def test_cli_verbose_text_shows_ip(self):
code, out, _ = _run(["--verbose", "https://example.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("IP", out)
self.assertIn("TLS", out)
self.assertIn("Cert", out)
self.assertIn("Content-Type", out)
def test_cli_verbose_json_schema(self):
code, out, _ = _run(["--verbose", "--json", "https://example.com"])
self.assertEqual(code, EXIT_OK)
data = json.loads(out)
v = data[0]["verbose"]
self.assertIn("ip", v)
self.assertIn("tls_version", v)
self.assertIn("tls_cipher", v)
self.assertIn("tls_bits", v)
self.assertIn("cert", v)
self.assertIn("headers", v)
def test_cli_verbose_json_cert_fields(self):
code, out, _ = _run(["--verbose", "--json", "https://example.com"])
data = json.loads(out)
cert = data[0]["verbose"]["cert"]
self.assertIn("cn", cert)
self.assertIn("expiry", cert)
self.assertIn("issuer_cn", cert)
self.assertTrue(cert["verified"])
def test_cli_verbose_tls_fail_shows_ip(self):
code, out, _ = _run(["--verbose", "https://expired.badssl.com/"])
self.assertGreater(code, 0)
# IP should be shown even when TLS fails (DNS + TCP succeeded)
self.assertIn("IP", out)
if __name__ == "__main__":
unittest.main(verbosity=2)

208
python/tests/test_probe.py Normal file
View File

@@ -0,0 +1,208 @@
import http.server
import socket
import threading
import time
import unittest
from latprobe.probe import Options, Phase, Result, VerboseDetail, measure
class _OKHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"hello latprobe")
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(404)
self.end_headers()
self.wfile.write(b"not found")
def log_message(self, *args):
pass
def _start_server(handler_class):
server = http.server.HTTPServer(("127.0.0.1", 0), handler_class)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
return server, server.server_address[1]
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _black_hole_port() -> int:
"""Bind a port that accepts TCP but never sends any data (triggers TTFB timeout)."""
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(10)
port = srv.getsockname()[1]
conns: list = []
def _serve():
while True:
try:
conn, _ = srv.accept()
conns.append(conn)
except OSError:
break
threading.Thread(target=_serve, daemon=True).start()
return port
class TestMeasureSuccess(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
cls.nf_server, cls.nf_port = _start_server(_NotFoundHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
def test_all_phases_present_on_success(self):
r = measure(f"http://127.0.0.1:{self.ok_port}")
self.assertIsNone(r.err)
self.assertEqual(r.fail_phase, "")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertFalse(r.tls.present) # http — no TLS
self.assertTrue(r.ttfb.present)
self.assertTrue(r.transfer.present)
self.assertTrue(r.total.present)
def test_total_gte_sum_of_phases(self):
r = measure(f"http://127.0.0.1:{self.ok_port}")
phase_sum = r.dns.ms + r.connect.ms + r.ttfb.ms + r.transfer.ms
self.assertGreaterEqual(r.total.ms, phase_sum * 0.9)
def test_status_code_404(self):
r = measure(f"http://127.0.0.1:{self.nf_port}")
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 404)
self.assertTrue(r.total.present)
def test_options_default_timeout(self):
opts = Options()
r = measure(f"http://127.0.0.1:{self.ok_port}", opts)
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 200)
class TestMeasureFailures(unittest.TestCase):
def test_dns_failure(self):
r = measure("http://no.such.host.invalid")
self.assertEqual(r.fail_phase, "dns")
self.assertIsNotNone(r.err)
self.assertFalse(r.dns.present)
self.assertFalse(r.connect.present)
self.assertTrue(r.total.present)
def test_connection_refused(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}")
self.assertEqual(r.fail_phase, "connect")
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertFalse(r.ttfb.present)
self.assertTrue(r.total.present)
def test_ttfb_timeout(self):
port = _black_hole_port()
r = measure(f"http://127.0.0.1:{port}", Options(timeout=0.2))
self.assertEqual(r.fail_phase, "timeout")
self.assertIsNotNone(r.err)
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.total.present)
def test_bad_scheme(self):
r = measure("ftp://example.com")
self.assertEqual(r.fail_phase, "request")
self.assertIsNotNone(r.err)
self.assertFalse(r.total.present)
def test_partial_phases_preserved_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}")
self.assertTrue(r.dns.present, "dns should be recorded before connect")
self.assertTrue(r.connect.present, "connect duration recorded even on refusal")
self.assertGreater(r.dns.ms, 0)
self.assertGreater(r.connect.ms, 0)
class TestVerboseDetail(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ok_server, cls.ok_port = _start_server(_OKHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
def _url(self):
return f"http://127.0.0.1:{self.ok_port}"
def test_detail_none_without_verbose(self):
r = measure(self._url())
self.assertIsNone(r.detail)
def test_detail_present_with_verbose(self):
r = measure(self._url(), Options(verbose=True))
self.assertIsInstance(r.detail, VerboseDetail)
def test_resolved_ip_set_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.resolved_ip, "127.0.0.1")
def test_no_tls_fields_for_http(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.tls_version, "")
self.assertEqual(r.detail.tls_cipher, "")
self.assertEqual(r.detail.tls_bits, 0)
self.assertIsNone(r.detail.cert)
def test_headers_populated_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertIsInstance(r.detail.headers, dict)
# BaseHTTPServer always sends Content-Type for 200 responses
self.assertTrue(len(r.detail.headers) > 0)
def test_ip_set_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}", Options(verbose=True))
self.assertEqual(r.fail_phase, "connect")
self.assertEqual(r.detail.resolved_ip, "127.0.0.1")
self.assertEqual(r.detail.headers, {})
def test_detail_none_on_dns_failure_without_verbose(self):
r = measure("http://no.such.host.invalid")
self.assertIsNone(r.detail)
def test_detail_empty_ip_on_dns_failure(self):
r = measure("http://no.such.host.invalid", Options(verbose=True))
self.assertIsNotNone(r.detail)
self.assertEqual(r.detail.resolved_ip, "")
def test_headers_not_populated_on_connect_fail(self):
port = _free_port()
r = measure(f"http://127.0.0.1:{port}", Options(verbose=True))
self.assertEqual(r.detail.headers, {})