Compare commits
10 Commits
3affc05996
...
24ea9c9e71
| Author | SHA1 | Date | |
|---|---|---|---|
| 24ea9c9e71 | |||
| 16fff2f964 | |||
| f609d8124f | |||
| 6db2abb713 | |||
| 49838e9e4c | |||
| 9a69ba946f | |||
| 45583ac2be | |||
| 0cf5b54070 | |||
| a9534ec2c1 | |||
| c323d879d0 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Go binaries
|
# Go binaries and artifacts
|
||||||
go/latprobe
|
go/latprobe
|
||||||
python/latprobe
|
go/coverage.out
|
||||||
|
go/coverage.html
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
135
CHANGELOG.md
135
CHANGELOG.md
@@ -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 0–6, 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 (2–5)
|
||||||
|
- 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)
|
## 2026-07-01 00:08 — JSON output flag (Go, Step 4)
|
||||||
|
|
||||||
- `--json` flag emits a JSON array with one entry per URL
|
- `--json` flag emits a JSON array with one entry per URL
|
||||||
|
|||||||
128
Makefile
Normal file
128
Makefile
Normal 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
|
||||||
32
README.md
32
README.md
@@ -28,12 +28,16 @@ Both implementations produce identical CLI behaviour and output formats.
|
|||||||
latprobe [flags] <url> [url ...]
|
latprobe [flags] <url> [url ...]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-n, --count int Number of requests per URL (default 1)
|
-n, --count int Number of requests per URL (default 1)
|
||||||
--json Output results as JSON instead of text
|
-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:
|
Examples:
|
||||||
latprobe https://example.com
|
latprobe https://example.com
|
||||||
latprobe -n 5 https://example.com https://www.google.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 .
|
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 |
|
| 2 | Per-phase breakdown — DNS, TCP, TLS, TTFB, transfer (`net/http/httptrace`) | ✅ Done |
|
||||||
| 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ✅ Done |
|
| 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ✅ Done |
|
||||||
| 4 | `--json` output flag | ✅ 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
|
### 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
|
## Development Environment
|
||||||
|
|
||||||
- Go 1.26.4 / darwin arm64
|
- Go 1.26.4 / darwin arm64
|
||||||
|
|||||||
333
docs/custom-usage-examples.md
Normal file
333
docs/custom-usage-examples.md
Normal 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: ~5–8 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: ~20–30 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: ~20–30 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: ~20–30 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: ~20–30 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: ~35–40 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: ~20–30 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: ~20–30 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: ~35–45 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: ~4–6 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: ~18–22 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.
|
||||||
139
docs/plans/2026-07-01-00-38-error-handling.md
Normal file
139
docs/plans/2026-07-01-00-38-error-handling.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Plan: Step 5 — Failure handling (Go)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go implementation of `latprobe` (Steps 0–4) 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`.
|
||||||
125
docs/plans/2026-07-01-00-49-integration-tests.md
Normal file
125
docs/plans/2026-07-01-00-49-integration-tests.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Plan: Step 6 — Integration tests (Go)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go `latprobe` tool is feature-complete (Steps 0–5): 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 0–6 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`.
|
||||||
90
docs/plans/2026-07-01-01-05-makefile.md
Normal file
90
docs/plans/2026-07-01-01-05-makefile.md
Normal 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).
|
||||||
140
docs/plans/2026-07-01-01-23-concurrency.md
Normal file
140
docs/plans/2026-07-01-01-23-concurrency.md
Normal 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 78–79):
|
||||||
|
```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 22–27).
|
||||||
|
|
||||||
|
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 102–127), 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 (129–136) 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.
|
||||||
47
docs/plans/2026-07-01-10-39-py-simple.md
Normal file
47
docs/plans/2026-07-01-10-39-py-simple.md
Normal 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
|
||||||
68
docs/plans/2026-07-01-12-04-py-phases.md
Normal file
68
docs/plans/2026-07-01-12-04-py-phases.md
Normal 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
|
||||||
139
docs/plans/2026-07-01-12-23-py-latprobe.md
Normal file
139
docs/plans/2026-07-01-12-23-py-latprobe.md
Normal 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.
|
||||||
505
docs/plans/2026-07-01-12-55-py-latprobe-verbose.md
Normal file
505
docs/plans/2026-07-01-12-55-py-latprobe-verbose.md
Normal 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 1–2 and 7 can be skipped ahead and demonstrated early (IP line shows up
|
||||||
|
immediately); steps 3–6 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
96
docs/usage/makefile.md
Normal 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
294
docs/usage/py-latprobe.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# `latprobe/` — Full Python Port
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A packaged Python CLI that mirrors the Go `latprobe` tool: per-phase HTTP
|
||||||
|
latency measurement (DNS, TCP connect, TLS, TTFB, Transfer, Total) with
|
||||||
|
configurable sampling, cross-URL concurrency, JSON output, and distinct exit
|
||||||
|
codes per failure class.
|
||||||
|
|
||||||
|
Run it from the `python/` directory with `python -m latprobe`.
|
||||||
|
|
||||||
|
Phases measured:
|
||||||
|
|
||||||
|
| Phase | What is timed |
|
||||||
|
|-------|---------------|
|
||||||
|
| DNS lookup | `socket.getaddrinfo()` — hostname resolution |
|
||||||
|
| TCP connect | `sock.connect()` — SYN to connection established |
|
||||||
|
| TLS handshake | `ssl.wrap_socket()` — full handshake (HTTPS only) |
|
||||||
|
| Server (TTFB) | `sendall()` return → first `recv()` byte |
|
||||||
|
| Transfer | First byte → EOF — body download time |
|
||||||
|
| Total | DNS start → body EOF — wall-clock end-to-end |
|
||||||
|
|
||||||
|
## Flags / arguments
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m latprobe [flags] <url> [url ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `url …` (positional) | required | One or more URLs to probe |
|
||||||
|
| `-n N`, `--count N` | `1` | Number of requests per URL |
|
||||||
|
| `-c N`, `--concurrency N` | `0` (auto) | Max parallel URLs; `0` = `min(len(urls), 8)` |
|
||||||
|
| `--timeout DURATION` | `10s` | Per-request timeout; supports `ms`, `s`, `m`, or bare seconds |
|
||||||
|
| `--fail` | off | Exit non-zero when any HTTP status ≥ 400 |
|
||||||
|
| `--json` | off | Output as JSON array instead of text |
|
||||||
|
| `-v`, `--verbose` | off | Show resolved IP, TLS version/cipher, certificate details, and response headers |
|
||||||
|
| `-h`, `--help` | — | Show help and exit 0 |
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | All probes succeeded |
|
||||||
|
| 1 | Usage / argument error |
|
||||||
|
| 2 | DNS failure |
|
||||||
|
| 3 | TCP connect failure |
|
||||||
|
| 4 | Timeout |
|
||||||
|
| 5 | TLS error |
|
||||||
|
| 6 | HTTP status ≥ 400 (`--fail` only) |
|
||||||
|
|
||||||
|
The highest exit code across all URLs is used as the process exit.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Single URL
|
||||||
|
```sh
|
||||||
|
python -m latprobe https://example.com
|
||||||
|
```
|
||||||
|
```
|
||||||
|
https://example.com (200)
|
||||||
|
DNS lookup : 18.87 ms
|
||||||
|
TCP connect : 9.76 ms
|
||||||
|
TLS handshake : 14.71 ms
|
||||||
|
Server (TTFB) : 67.07 ms
|
||||||
|
Transfer : 0.14 ms
|
||||||
|
─────────────────────────────
|
||||||
|
Total : 118.39 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sampling (`-n`) — shows min/avg/max table
|
||||||
|
```sh
|
||||||
|
python -m latprobe -n 5 https://example.com
|
||||||
|
```
|
||||||
|
```
|
||||||
|
https://example.com (200, 5 samples)
|
||||||
|
min avg max
|
||||||
|
DNS lookup : 1.80 ms 3.14 ms 5.00 ms
|
||||||
|
TCP connect : 9.50 ms 10.25 ms 11.40 ms
|
||||||
|
TLS handshake : 13.80 ms 15.20 ms 18.90 ms
|
||||||
|
Server (TTFB) : 62.00 ms 66.50 ms 71.30 ms
|
||||||
|
Transfer : 0.10 ms 0.25 ms 0.40 ms
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
Total : 97.10 ms 101.20 ms 109.80 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple URLs (probed in parallel)
|
||||||
|
```sh
|
||||||
|
python -m latprobe https://example.com https://www.google.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Output for each URL is separated by a blank line. Exit code = worst across all.
|
||||||
|
|
||||||
|
### JSON output
|
||||||
|
```sh
|
||||||
|
python -m latprobe --json -n 3 https://example.com
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"url": "https://example.com",
|
||||||
|
"status": 200,
|
||||||
|
"succeeded": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"phases": {
|
||||||
|
"dns": {"min_ms": 1.80, "avg_ms": 3.14, "max_ms": 5.00},
|
||||||
|
"connect": {"min_ms": 9.50, "avg_ms": 10.25, "max_ms": 11.40},
|
||||||
|
"tls": {"min_ms": 13.80, "avg_ms": 15.20, "max_ms": 18.90},
|
||||||
|
"ttfb": {"min_ms": 62.00, "avg_ms": 66.50, "max_ms": 71.30},
|
||||||
|
"transfer": {"min_ms": 0.10, "avg_ms": 0.25, "max_ms": 0.40},
|
||||||
|
"total": {"min_ms": 97.10, "avg_ms": 101.20, "max_ms": 109.80}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`phases` is omitted when all samples failed. `tls` key is omitted for `http://`
|
||||||
|
URLs. `errors` is included (and `phases` omitted) when some samples fail.
|
||||||
|
|
||||||
|
### Verbose mode (`-v` / `--verbose`)
|
||||||
|
|
||||||
|
Shows the resolved IP address, TLS version/cipher/bits, certificate details
|
||||||
|
(CN, expiry, issuer), and useful response headers. Appended to the standard
|
||||||
|
timing block.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m latprobe --verbose https://example.com
|
||||||
|
```
|
||||||
|
```
|
||||||
|
https://example.com (200)
|
||||||
|
DNS lookup : 16.47 ms
|
||||||
|
TCP connect : 9.76 ms
|
||||||
|
TLS handshake : 13.09 ms
|
||||||
|
Server (TTFB) : 60.31 ms
|
||||||
|
Transfer : 0.20 ms
|
||||||
|
─────────────────────────────
|
||||||
|
Total : 112.76 ms
|
||||||
|
IP : 104.20.23.154
|
||||||
|
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
|
||||||
|
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
|
||||||
|
Server : cloudflare
|
||||||
|
Content-Type : text/html
|
||||||
|
```
|
||||||
|
|
||||||
|
For **TLS failures**, the verbose block still shows the IP (DNS + TCP
|
||||||
|
succeeded) so you can tell which server you actually reached:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m latprobe --verbose https://expired.badssl.com/
|
||||||
|
```
|
||||||
|
```
|
||||||
|
https://expired.badssl.com/ (FAILED)
|
||||||
|
DNS lookup : 30.98 ms
|
||||||
|
TCP connect : 126.58 ms
|
||||||
|
TLS handshake : 293.58 ms
|
||||||
|
─────────────────────────────
|
||||||
|
Total : 463.37 ms
|
||||||
|
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
|
||||||
|
IP : 104.154.89.105
|
||||||
|
```
|
||||||
|
|
||||||
|
For **DNS failures**, the verbose block is empty (IP unknown), so it is not
|
||||||
|
printed.
|
||||||
|
|
||||||
|
Combined with `--json`, verbose detail appears in a `"verbose"` object:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m latprobe --verbose --json https://example.com
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"url": "https://example.com",
|
||||||
|
"status": 200,
|
||||||
|
"succeeded": 1,
|
||||||
|
"failed": 0,
|
||||||
|
"phases": { ... },
|
||||||
|
"verbose": {
|
||||||
|
"ip": "104.20.23.154",
|
||||||
|
"tls_version": "TLSv1.3",
|
||||||
|
"tls_cipher": "TLS_AES_256_GCM_SHA384",
|
||||||
|
"tls_bits": 256,
|
||||||
|
"cert": {
|
||||||
|
"cn": "example.com",
|
||||||
|
"sans": ["example.com", "*.example.com"],
|
||||||
|
"expiry": "2026-08-29",
|
||||||
|
"issuer_cn": "SSL Corporation",
|
||||||
|
"verified": true
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
"Server": "cloudflare",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `"verbose"` key is omitted when `--verbose` is not set. The `"cert"` key
|
||||||
|
is omitted for `http://` URLs and when TLS fails (Python's stdlib does not
|
||||||
|
expose parsed cert data from a failed handshake). `"headers"` contains **all**
|
||||||
|
parsed response headers (the text block shows a curated priority list).
|
||||||
|
|
||||||
|
### DNS failure
|
||||||
|
```sh
|
||||||
|
python -m latprobe http://no.such.host.invalid
|
||||||
|
echo "exit: $?"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
http://no.such.host.invalid (FAILED)
|
||||||
|
Total : 0.65 ms
|
||||||
|
✗ dns: [Errno 8] nodename nor servname provided, or not known
|
||||||
|
exit: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--fail` flag (exit non-zero on HTTP 4xx/5xx)
|
||||||
|
```sh
|
||||||
|
python -m latprobe --fail https://www.google.com/this-page-does-not-exist-at-all
|
||||||
|
echo "exit: $?"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
https://www.google.com/this-page-does-not-exist-at-all (404 ✗)
|
||||||
|
DNS lookup : 3.10 ms
|
||||||
|
TCP connect : 9.60 ms
|
||||||
|
TLS handshake : 14.20 ms
|
||||||
|
Server (TTFB) : 118.50 ms
|
||||||
|
Transfer : 0.12 ms
|
||||||
|
─────────────────────────────
|
||||||
|
Total : 145.50 ms
|
||||||
|
exit: 6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mixed — some URLs succeed, some fail
|
||||||
|
```sh
|
||||||
|
python -m latprobe https://example.com http://no.such.host.invalid
|
||||||
|
echo "exit: $?"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
https://example.com (200)
|
||||||
|
...
|
||||||
|
Total : 118.39 ms
|
||||||
|
|
||||||
|
http://no.such.host.invalid (FAILED)
|
||||||
|
Total : 0.65 ms
|
||||||
|
✗ dns: ...
|
||||||
|
exit: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeout
|
||||||
|
```sh
|
||||||
|
python -m latprobe --timeout 500ms http://10.255.255.1/
|
||||||
|
echo "exit: $?"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
http://10.255.255.1/ (FAILED)
|
||||||
|
DNS lookup : 0.20 ms
|
||||||
|
TCP connect : 500.18 ms
|
||||||
|
─────────────────────────────
|
||||||
|
Total : 500.40 ms
|
||||||
|
✗ timeout: timed out
|
||||||
|
exit: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Makefile targets
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make py-run ARGS="-n 3 https://example.com" # run the package
|
||||||
|
make py-test # run test suite
|
||||||
|
make py-check # alias for py-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **DNS timeout is OS-controlled.** Python's `socket.getaddrinfo` does not
|
||||||
|
accept a timeout parameter. The `--timeout` flag applies to TCP connect and
|
||||||
|
subsequent phases. DNS failures from an unreachable or NXDOMAIN host still
|
||||||
|
happen quickly in practice.
|
||||||
|
- **HTTP 1.1 + `Connection: close` only.** No keep-alive, HTTP/2, auth, custom
|
||||||
|
headers, or redirect following. HTTP 3xx is shown with its raw status code.
|
||||||
|
- **Body fully drained.** Transfer time is real download time; large bodies
|
||||||
|
affect the Transfer and Total phases.
|
||||||
|
|
||||||
|
## Comparison with `simple.py` and `phases.py`
|
||||||
|
|
||||||
|
| | `simple.py` | `phases.py` | `latprobe/` |
|
||||||
|
|--|-------------|-------------|-------------|
|
||||||
|
| Phases | total only | all phases | all phases |
|
||||||
|
| Sampling | no | no | `-n` flag |
|
||||||
|
| Concurrency | no | no | `-c` flag |
|
||||||
|
| JSON | no | no | `--json` flag |
|
||||||
|
| `--fail` | implicit (urlopen raises on 4xx) | no | `--fail` flag |
|
||||||
|
| Exit codes | 0 or 1 | 0 or 1 | 0–6 per failure class |
|
||||||
|
| Config file | yes | yes | no (URL args only) |
|
||||||
143
docs/usage/py-phases.md
Normal file
143
docs/usage/py-phases.md
Normal 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
79
docs/usage/py-simple.md
Normal 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.
|
||||||
148
docs/usage/step-5-failure-handling.md
Normal file
148
docs/usage/step-5-failure-handling.md
Normal 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`) |
|
||||||
90
docs/usage/step-6-integration-tests.md
Normal file
90
docs/usage/step-6-integration-tests.md
Normal 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`.
|
||||||
76
docs/usage/step-7-concurrency.md
Normal file
76
docs/usage/step-7-concurrency.md
Normal 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
105
go/cli_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,24 @@ package probe
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
"time"
|
"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.
|
// Phase holds the measured duration of a single request phase.
|
||||||
type Phase struct {
|
type Phase struct {
|
||||||
Duration time.Duration
|
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
|
Present bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,15 +35,20 @@ type Result struct {
|
|||||||
Transfer Phase // body read: GotFirstResponseByte → body closed
|
Transfer Phase // body read: GotFirstResponseByte → body closed
|
||||||
Total Phase
|
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
|
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 is non-nil if the request failed.
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measure performs an HTTP GET to url and returns a Result with all phases
|
// Measure performs an HTTP GET to url and returns a Result with all completed
|
||||||
// populated via net/http/httptrace.
|
// phases populated. Partial phases are preserved when the request fails.
|
||||||
func Measure(url string) Result {
|
func Measure(url string, opts Options) Result {
|
||||||
r := Result{URL: url}
|
r := Result{URL: url}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -47,11 +60,16 @@ func Measure(url string) Result {
|
|||||||
tlsDone time.Time
|
tlsDone time.Time
|
||||||
wroteRequest time.Time
|
wroteRequest time.Time
|
||||||
firstByte time.Time
|
firstByte time.Time
|
||||||
|
dnsErr error
|
||||||
|
tlsErr error
|
||||||
)
|
)
|
||||||
|
|
||||||
trace := &httptrace.ClientTrace{
|
trace := &httptrace.ClientTrace{
|
||||||
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
|
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
|
||||||
DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
|
DNSDone: func(info httptrace.DNSDoneInfo) {
|
||||||
|
dnsDone = time.Now()
|
||||||
|
dnsErr = info.Err
|
||||||
|
},
|
||||||
ConnectStart: func(_, _ string) {
|
ConnectStart: func(_, _ string) {
|
||||||
if connectStart.IsZero() {
|
if connectStart.IsZero() {
|
||||||
connectStart = time.Now()
|
connectStart = time.Now()
|
||||||
@@ -59,23 +77,38 @@ func Measure(url string) Result {
|
|||||||
},
|
},
|
||||||
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
|
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
|
||||||
TLSHandshakeStart: func() { tlsStart = 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() },
|
WroteRequest: func(_ httptrace.WroteRequestInfo) { wroteRequest = time.Now() },
|
||||||
GotFirstResponseByte: func() { firstByte = time.Now() },
|
GotFirstResponseByte: func() { firstByte = time.Now() },
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := httptrace.WithClientTrace(context.Background(), trace)
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = err
|
r.Err = err
|
||||||
|
r.FailPhase = "request"
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
end := time.Now()
|
||||||
r.Err = err
|
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
|
return r
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -86,25 +119,52 @@ func Measure(url string) Result {
|
|||||||
r.StatusCode = resp.StatusCode
|
r.StatusCode = resp.StatusCode
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = err
|
r.Err = err
|
||||||
|
r.FailPhase = "transfer"
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Total = Phase{Duration: end.Sub(start), Present: true}
|
r.Total = Phase{Duration: end.Sub(start), Present: true}
|
||||||
|
r.DNS = makePhase(dnsStart, dnsDone)
|
||||||
if !dnsStart.IsZero() && !dnsDone.IsZero() {
|
r.Connect = makePhase(connectStart, connectDone)
|
||||||
r.DNS = Phase{Duration: dnsDone.Sub(dnsStart), Present: true}
|
r.TLS = makePhase(tlsStart, tlsDone)
|
||||||
}
|
r.TTFB = makePhase(wroteRequest, firstByte)
|
||||||
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}
|
|
||||||
}
|
|
||||||
if !firstByte.IsZero() {
|
if !firstByte.IsZero() {
|
||||||
r.Transfer = Phase{Duration: end.Sub(firstByte), Present: true}
|
r.Transfer = Phase{Duration: end.Sub(firstByte), Present: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
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"
|
||||||
|
}
|
||||||
|
|||||||
371
go/main.go
371
go/main.go
@@ -2,10 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"latprobe/internal/probe"
|
"latprobe/internal/probe"
|
||||||
@@ -17,110 +21,295 @@ Usage:
|
|||||||
latprobe [flags] <url> [url ...]
|
latprobe [flags] <url> [url ...]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-n, --count int Number of requests per URL (default 1)
|
-n, --count int Number of requests per URL (default 1)
|
||||||
--json Output results as JSON instead of text
|
-c, --concurrency int Max URLs probed in parallel, 0 = auto (default min(numURLs,8))
|
||||||
-h, --help Show this help
|
--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:
|
Examples:
|
||||||
latprobe https://example.com
|
latprobe https://example.com
|
||||||
latprobe -n 5 https://example.com https://www.google.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 .
|
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() {
|
func main() {
|
||||||
count := flag.Int("count", 1, "number of requests per URL")
|
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||||
flag.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
|
}
|
||||||
jsonOut := flag.Bool("json", false, "output results as JSON instead of text")
|
|
||||||
|
|
||||||
flag.Usage = func() { fmt.Fprint(os.Stderr, usageText) }
|
func run(args []string, stdout, stderr io.Writer) int {
|
||||||
flag.Parse()
|
fs := flag.NewFlagSet("latprobe", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
|
||||||
urls := flag.Args()
|
count := fs.Int("count", 1, "number of requests per URL")
|
||||||
if len(urls) == 0 {
|
fs.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
|
||||||
fmt.Fprint(os.Stderr, usageText)
|
conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)")
|
||||||
os.Exit(1)
|
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
|
var jsonEntries []jsonEntry
|
||||||
|
|
||||||
for i, url := range urls {
|
for i, rawURL := range urls {
|
||||||
results := collectSamples(url, *count, &failed)
|
succeeded := results[i].succeeded
|
||||||
if len(results) == 0 {
|
failed := results[i].failed
|
||||||
continue
|
|
||||||
|
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 {
|
if *jsonOut {
|
||||||
jsonEntries = append(jsonEntries, toJSONEntry(probe.Summarize(results)))
|
jsonEntries = append(jsonEntries, buildJSONEntry(rawURL, succeeded, failed))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
fmt.Println()
|
fmt.Fprintln(stdout)
|
||||||
}
|
|
||||||
if *count == 1 {
|
|
||||||
printResult(results[0])
|
|
||||||
} else {
|
|
||||||
printAggregate(probe.Summarize(results))
|
|
||||||
}
|
}
|
||||||
|
printURL(stdout, rawURL, succeeded, failed, *count, *fail)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *jsonOut {
|
if *jsonOut {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
if err := enc.Encode(jsonEntries); err != nil {
|
if err := enc.Encode(jsonEntries); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "json encode: %v\n", err)
|
fmt.Fprintf(stderr, "json encode: %v\n", err)
|
||||||
os.Exit(1)
|
return exitConnect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed {
|
return worstCode
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectSamples(url string, count int, failed *bool) []probe.Result {
|
// ── sampling ──────────────────────────────────────────────────────────────────
|
||||||
results := make([]probe.Result, 0, count)
|
|
||||||
for i := range count {
|
func runSamples(rawURL string, count int, opts probe.Options) (succeeded, failed []probe.Result) {
|
||||||
r := probe.Measure(url)
|
for range count {
|
||||||
|
r := probe.Measure(rawURL, opts)
|
||||||
if r.Err != nil {
|
if r.Err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error %s (sample %d/%d): %v\n", url, i+1, count, r.Err)
|
failed = append(failed, r)
|
||||||
*failed = true
|
} else {
|
||||||
return nil
|
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 ───────────────────────────────────────────────────────────────
|
// ── text output ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func printResult(r probe.Result) {
|
func printURL(w io.Writer, rawURL string, succeeded, failed []probe.Result, total int, fail bool) {
|
||||||
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
|
nOK := len(succeeded)
|
||||||
for _, ph := range singlePhases(r) {
|
nFail := len(failed)
|
||||||
if ph.p.Present {
|
|
||||||
fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
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) {
|
func printResult(w io.Writer, r probe.Result, fail bool) {
|
||||||
fmt.Printf("%s (%d, %d samples)\n", a.URL, a.StatusCode, a.Count)
|
status := fmt.Sprintf("%d", r.StatusCode)
|
||||||
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
|
if fail && r.StatusCode >= 400 {
|
||||||
for _, ph := range aggPhases(a) {
|
status += " ✗"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s (%s)\n", r.URL, status)
|
||||||
|
|
||||||
|
for _, ph := range singlePhaseList(r) {
|
||||||
if ph.p.Present {
|
if ph.p.Present {
|
||||||
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
||||||
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println(" " + strings.Repeat("─", 49))
|
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
|
||||||
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
if r.Total.Present {
|
||||||
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
|
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
|
label string
|
||||||
p probe.Phase
|
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
|
label string
|
||||||
p probe.PhaseStats
|
p probe.PhaseStats
|
||||||
} {
|
} {
|
||||||
@@ -164,34 +353,58 @@ type jsonPhase struct {
|
|||||||
MaxMS float64 `json:"max_ms"`
|
MaxMS float64 `json:"max_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonEntry struct {
|
type jsonError struct {
|
||||||
URL string `json:"url"`
|
Phase string `json:"phase"`
|
||||||
Status int `json:"status"`
|
Count int `json:"count"`
|
||||||
Samples int `json:"samples"`
|
Message string `json:"message"`
|
||||||
Phases map[string]jsonPhase `json:"phases"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
e := jsonEntry{
|
||||||
URL: a.URL,
|
URL: rawURL,
|
||||||
Status: a.StatusCode,
|
Succeeded: len(succeeded),
|
||||||
Samples: a.Count,
|
Failed: len(failed),
|
||||||
Phases: make(map[string]jsonPhase),
|
|
||||||
}
|
}
|
||||||
add := func(name string, s probe.PhaseStats) {
|
|
||||||
if s.Present {
|
if len(succeeded) > 0 {
|
||||||
e.Phases[name] = jsonPhase{
|
a := probe.Summarize(succeeded)
|
||||||
MinMS: ms(s.Min),
|
e.Status = a.StatusCode
|
||||||
AvgMS: ms(s.Avg),
|
e.Phases = make(map[string]jsonPhase)
|
||||||
MaxMS: ms(s.Max),
|
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)
|
type key struct{ phase, msg string }
|
||||||
add("tls", a.TLS)
|
counts := map[key]int{}
|
||||||
add("ttfb", a.TTFB)
|
var order []key
|
||||||
add("transfer", a.Transfer)
|
for _, r := range failed {
|
||||||
add("total", a.Total)
|
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
|
return e
|
||||||
}
|
}
|
||||||
|
|||||||
371
go/run_test.go
Normal file
371
go/run_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
python/configs/all-ok.txt
Normal file
7
python/configs/all-ok.txt
Normal 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
|
||||||
7
python/configs/connection-refused.txt
Normal file
7
python/configs/connection-refused.txt
Normal 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
|
||||||
9
python/configs/dns-failure.txt
Normal file
9
python/configs/dns-failure.txt
Normal 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.
|
||||||
21
python/configs/http-errors.txt
Normal file
21
python/configs/http-errors.txt
Normal 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
21
python/configs/mixed.txt
Normal 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
|
||||||
14
python/configs/timeout.txt
Normal file
14
python/configs/timeout.txt
Normal 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/
|
||||||
21
python/configs/tls-errors.txt
Normal file
21
python/configs/tls-errors.txt
Normal 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/
|
||||||
312
python/configs/usage-latprobe.md
Normal file
312
python/configs/usage-latprobe.md
Normal 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
|
||||||
|
```
|
||||||
347
python/configs/usage-phases.md
Normal file
347
python/configs/usage-phases.md
Normal 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
|
||||||
|
```
|
||||||
185
python/configs/usage-simple.md
Normal file
185
python/configs/usage-simple.md
Normal 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
|
||||||
|
```
|
||||||
1
python/latprobe/__init__.py
Normal file
1
python/latprobe/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""latprobe — measure per-phase HTTP request latency."""
|
||||||
5
python/latprobe/__main__.py
Normal file
5
python/latprobe/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from .cli import run
|
||||||
|
|
||||||
|
sys.exit(run(sys.argv[1:], sys.stdout, sys.stderr))
|
||||||
53
python/latprobe/aggregate.py
Normal file
53
python/latprobe/aggregate.py
Normal 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
441
python/latprobe/cli.py
Normal 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
|
||||||
14
python/latprobe/duration.py
Normal file
14
python/latprobe/duration.py
Normal 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
306
python/latprobe/probe.py
Normal 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
286
python/phases.py
Normal 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
88
python/simple.py
Normal 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
6
python/sites.txt
Normal 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
0
python/tests/__init__.py
Normal file
386
python/tests/test_cli.py
Normal file
386
python/tests/test_cli.py
Normal 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])
|
||||||
430
python/tests/test_integration.py
Normal file
430
python/tests/test_integration.py
Normal 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 20–40 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
208
python/tests/test_probe.py
Normal 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, {})
|
||||||
Reference in New Issue
Block a user