Compare commits

...

13 Commits

Author SHA1 Message Date
f487a4b1bd docs: hxprobe plans, summaries, explanations, usage, and changelog
Plans (docs/plans/):
- 2026-07-01-23-47-py-hxprobe-httpx.md — initial httpx probe design
- 2026-07-02-09-32 through 14-05 — standalone project, toolchain, usage
  doc + Makefile, file input (-f), simplification pass, run-summary footer

Summaries (docs/summaries/): one per completed feature, recording what
was actually built, deviations from the plan, and verification steps

Explanations (docs/explanations/): two deep-dives written during review —
hxprobe concurrency model and worst-exit-code + render-loop analysis

Usage (docs/usage/hxprobe.md): overview with pointer to hxprobe/USAGE.md
for the full runnable reference

Walkthrough (docs/py-latprobe-walkthrough.md): narrative tour of the
latprobe Python package for interview / code-review context

CHANGELOG.md: entries for all hxprobe features (toolchain, usage doc,
file input, simplification, run-summary footer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:22:59 +02:00
13a562e966 chore: wire hxprobe into root project (Makefile, CLAUDE.md, .gitignore)
Makefile: add hx-* targets (deps, run, lint, fmt, test, test-integration,
check, clean) that delegate into hxprobe/ via uv; fold hx-test and hx-check
into the umbrella test/check/clean targets; add HX_DIR variable and a note
that hxprobe is intentionally standalone (no cross-imports from python/).

CLAUDE.md: add "Implementation Summaries" convention — after each feature,
save a summary under docs/summaries/ with the same timestamp+slug naming as
plans. Add note about copying plan-mode scratch files into docs/plans/ before
starting implementation.

.gitignore: add *.egg-info/, .pytest_cache/, .ruff_cache/ for the hxprobe
uv+ruff+pytest toolchain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:22:43 +02:00
404b372070 feat(hxprobe): httpx-based HTTP probe — full standalone package
Third Python implementation in the latency-tool progression.
Mirrors latprobe's feature set but replaces raw sockets with httpx,
gaining HTTP/2 support and redirect following.

Package layout (hxprobe/hxprobe/):
- probe.py: asyncio + httpx.AsyncClient with a _TimingStream transport
  wrapper that captures DNS/connect/TLS/TTFB/transfer phase timings via
  httpx event hooks (get_connection_stats, request_started, etc.).
  VerboseDetail captures resolved IP, TLS version/cipher/bits, cert CN/
  expiry/issuer (from httpx's SSLObject), and response headers.
  Options: timeout, verbose, follow_redirects, http2
- aggregate.py: summarize() → per-phase min/avg/max (same schema as latprobe)
- cli.py: run(args,stdout,stderr)->int; argparse with redirect_stdout/
  redirect_stderr + SystemExit catch; -n/--count, -c/--concurrency,
  --timeout, --fail, --json, -v/--verbose, --no-follow-redirects,
  --no-http2, -f/--file (URL list from file, mutually exclusive with args);
  multi-URL summary footer (tally + exit label) when len(urls) > 1;
  worst-exit-code logic mirrors Go/latprobe; JSON output is bare array
- duration.py: same parse_duration() as latprobe
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400

Tests (hxprobe/tests/):
- test_probe.py: 18 hermetic tests using anyio + in-process ASGI servers
- test_cli.py: 36 hermetic tests (success, failures, JSON, verbose, -f flag,
  run-summary footer, worst-code accumulation)
- test_integration.py: pytest-marked @integration (excluded from hx-test)

Toolchain: uv + ruff + pytest; pyproject.toml with [dependency-groups];
hxprobe/Makefile standalone (help, deps, run, lint, fmt, test,
test-integration, check, clean); .python-version pins 3.14
Configs: 8 fixture files mirroring python/configs/ (all-ok through mixed)
USAGE.md: 16 runnable examples with real captured output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:22:31 +02:00
24ea9c9e71 feat(py): step 3 — full latprobe package with --verbose flag
Adds the complete Python port of the Go latprobe tool plus diagnostic
verbose output that the Go version does not yet have.

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:45:56 +02:00
16fff2f964 docs: add usage-phases.md beside usage-simple.md in python/configs/
- Rename python/configs/usage.md → usage-simple.md so both scripts have a
  parallel runnable reference file in the same directory
- Add python/configs/usage-phases.md: runnable sh commands + real expected
  output for phases.py against every error-path config, including a note on
  the key difference from simple.py (HTTP 4xx exits 0 in phases.py vs 1 in
  simple.py because phases.py completes at the network level)
- Update doc cross-links in docs/usage/py-simple.md and py-phases.md

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

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

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

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

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

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

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

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

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

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

8
.gitignore vendored
View File

@@ -1,12 +1,16 @@
# Go binaries
# Go binaries and artifacts
go/latprobe
python/latprobe
go/coverage.out
go/coverage.html
# Python
__pycache__/
*.pyc
*.pyo
.venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
# OS
.DS_Store

View File

@@ -4,6 +4,388 @@ All completed features are logged here in reverse-chronological order.
---
## 2026-07-02 14:05 — `hxprobe`: end-of-run summary footer for multi-URL runs
- `hxprobe/hxprobe/cli.py`: when more than one URL is probed (positional args
or `-f`), a footer is now appended after the last URL block — a tally of
every URL's outcome (`N ok`, `M failed` broken down by class) plus the
`→ exit N (label)` line tying it to the process exit code. Single-URL text
output and JSON output (still a bare array) are both byte-identical to
before — new `_print_run_summary()`, gated on `len(urls) > 1` and text mode
only
- The process **exit code itself is unchanged**: still the highest severity
across all URLs (worst-code wins), matching `latprobe`/Go and the 13
existing exit-code tests — a deliberate decision, since that scalar is a
documented cross-implementation contract (`hxprobe/USAGE.md`'s Exit codes
table). The footer exists to give humans the per-URL breakdown the scalar
can't show, not to change what gets returned
- New `_EXIT_LABELS` (inverse of `_PHASE_EXIT`) for rendering exit codes as
short labels (`dns`, `tls`, `http`, …) in the footer
- Refactored the per-URL accumulation loop to compute one worst-code per URL
first, then fold into the global `worst` — this also unified the two
"running max" idioms (`if c > worst: worst = c` vs `max(worst, ...)`) that
`docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`
had flagged as a stylistic wrinkle into a single `max(...)` call
- `hxprobe/tests/test_cli.py`: 3 new hermetic tests (`TestCLIRunSummary`) —
multi-URL mixed outcome, multi-URL all-ok, and single-URL-has-no-footer;
all 33 pre-existing tests pass unedited
- Triggered by a follow-up question: does a single worst-code exit even make
sense across multiple URLs with different error classes? Answer: keep the
scalar (parity contract) but add the missing visibility as output, not by
changing the exit code's semantics
- `hxprobe/USAGE.md`: new "Multi-URL summary footer" section with real
captured output; refreshed the "Multiple URLs" and `-f` multi-URL examples
to show the footer (previously stale — captured before this feature
existed); added a sentence to the Exit codes section
- `docs/usage/hxprobe.md`: one-line mention pointing at the new section
- Plan: `docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md`
- Summary: `docs/summaries/2026-07-02-14-05-hxprobe-run-summary-footer.md`
---
## 2026-07-02 12:05 — `hxprobe` simplification pass (no behavior change)
- `hxprobe/hxprobe/cli.py`: deleted the `_Parser`/`_ArgExit` scaffolding
(~32 lines) that hand-reimplemented what `argparse.ArgumentParser` already
does (help→stdout, usage/errors→stderr, raise instead of hard-exit);
replaced with a plain `ArgumentParser` wrapped in
`contextlib.redirect_stdout`/`redirect_stderr`, catching `SystemExit` at one
site
- `hxprobe/hxprobe/probe.py`: trimmed `_TimingStream.get_extra_info` to the
one branch (`ssl_object`) actually consumed on hxprobe's request path — the
`server_addr`/`client_addr` branches were dead (only `httpx`'s own CLI
queries them); removed `_dns_set`/`_connect_set`/`_tls_set` from `_Trace`,
redundant with the `Phase.present` flag already on `self.dns`/`connect`/`tls`
- `hxprobe/hxprobe/cli.py`: `_load_urls` no longer takes only the first
whitespace token per line ("forward-compatible with future `key=value`
annotations" that never materialized); extracted `_summarize_failures()`
to remove a duplicated dedup loop shared by `_print_failure_summary` and
`_build_json_entry`
- Triggered by a question about a dead `file=` parameter on
`_Parser.print_help`/`print_usage`; verified no behavior changed — all 33
existing tests pass unedited, plus manual smoke tests of `-h`, no-args
(stdout/stderr routing double-checked via separate file redirection), and a
live verbose request
- Plan: `docs/plans/2026-07-02-12-05-hxprobe-simplification.md`
- Summary: `docs/summaries/2026-07-02-12-05-hxprobe-simplification.md`
---
## 2026-07-02 11:14 — `hxprobe`: read target URLs from a file (`-f`/`--file`)
- `hxprobe/hxprobe/cli.py`: new `-f`/`--file PATH` flag reads URLs from a
plain-text file (one per line, `#` comments, blank lines skipped, first
whitespace-separated token per line) — same format as `simple.py`'s
`load_sites()`, reimplemented locally as `_load_urls()` rather than
imported (hxprobe still imports nothing outside its own directory)
- `-f`/`--file` is **mutually exclusive** with positional `url` args (both
or neither given → usage error); `urls` positional changed from
`nargs="+"` to `nargs="*"` to allow the file-only case
- Missing file / unreadable file / empty file all produce a clear usage
error (`EXIT_USAGE`) rather than a traceback
- `hxprobe/configs/*.txt`: 7 new fixtures mirroring `python/configs/`'s set
(all-ok, dns-failure, connection-refused, timeout, tls-errors,
http-errors, mixed), each with a verified expected exit code in its
header comment — actually run against `hxprobe -f ...` during
implementation, not assumed from the `simple.py` originals (whose
blanket 0/1 exit scheme differs from hxprobe's per-failure-class 06)
- `hxprobe/tests/test_cli.py`: 5 new hermetic tests (`TestCLIFileInput`) —
successful multi-URL read from a temp file, missing file, empty file,
both-sources-given, and neither-given error paths
- `hxprobe/USAGE.md`: new "Reading URLs from a file (`-f`)" section with
real captured output, placed after "Multiple URLs"
- Plan: `docs/plans/2026-07-02-11-14-hxprobe-file-input.md`
- Summary: `docs/summaries/2026-07-02-11-14-hxprobe-file-input.md`
---
## 2026-07-02 10:22 — `hxprobe` usage reference doc + standalone Makefile
- `hxprobe/USAGE.md`: new "Runnable Usage Reference" matching the depth of
`python/configs/usage-latprobe.md` — 16 cases with real captured output
(basic, verbose × HTTPS/plain-HTTP/redirect-followed/`--no-follow-
redirects`/`--no-http2`/TLS-failure/DNS-failure, sampling, multi-URL,
`--fail`, JSON, JSON+verbose, timeout, exit-codes table, Makefile
shortcuts). Placed inside `hxprobe/` itself (not `python/configs/`) so it
travels with the project if extracted to its own repo
- Timeout example uses `192.0.2.1` (RFC 5737 TEST-NET-1) instead of
`10.255.255.1` — the latter resolves to an immediate "connection refused"
in this dev sandbox rather than a genuine timeout; `192.0.2.1` reproduces
a real ~500ms timeout reliably
- `docs/usage/hxprobe.md`: added a one-line pointer to `hxprobe/USAGE.md` at
the top; no other changes — it stays the CLAUDE.md-mandated summary doc
- `hxprobe/Makefile`: new, fully standalone (`help`, `deps`, `run`, `lint`,
`fmt`, `test`, `test-integration`, `check`, `clean`) — same auto-generated
`## comment` help style as the root Makefile, short target names (no
`hx-` prefix needed inside `hxprobe/`'s own scope). Deliberately
independent from the root Makefile's `hx-*` targets — neither calls into
the other, per explicit user decision (avoids one Makefile's changes
silently breaking the other, at the cost of some duplicated `uv`/`pytest`
invocation logic)
- No changes to the root `Makefile` — verified its `hx-*` section is
byte-for-byte unchanged
- Plan: `docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md`
- Summary: `docs/summaries/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md`
---
## 2026-07-02 09:57 — `hxprobe` toolchain modernization (uv, ruff, pytest)
- Adopted `uv` for environment/dependency management: `hxprobe/pyproject.toml`
gets a `[dependency-groups] dev = ["pytest>=8.0", "ruff>=0.8"]` section;
`hxprobe/.python-version` pins Python 3.14; `hxprobe/uv.lock` (new, 18
packages resolved) pins every transitive dependency (`httpx`, `httpcore`,
`h2`, `certifi`, etc.) for reproducible installs — previously nothing was
pinned beyond `httpx[http2]>=0.28`
- Added `ruff` for linting + formatting: `[tool.ruff]` config
(`target-version = "py311"`, `line-length = 100`); fixed the one real
finding (`import sys` unused in `cli.py`, inherited from the original
`latprobe/cli.py`); ran `ruff format` across the project (6 files
reformatted — mostly collapsing the hand-aligned `=`/dict-key columns to
single-space, no semantic changes; verified via full syntax check + test
run before and after)
- Swapped the test runner from stdlib `unittest discover` to `pytest`.
Existing `unittest.TestCase` classes run unchanged (pytest is a superset
runner) — no test-code rewrite. Replaced the `test_[!i]*.py` filename-glob
hermetic/integration split with a proper `pytest.mark.integration` marker
(`pytestmark = pytest.mark.integration` in `tests/test_integration.py`);
registered in `[tool.pytest.ini_options]` to avoid unknown-marker warnings
- Skipped mypy for now (type hints stay as documentation only) — explicit
user decision, not an oversight
- `hxprobe/README.md`: new — quick start (`uv sync`, `uv run ...`) for the
standalone project, independent of the parent repo's docs
- Makefile: `hx-deps`/`hx-run`/`hx-test`/`hx-test-integration` now shell out
to `uv sync`/`uv run` instead of hand-managed `python -m venv` + `pip
install -e .` (removed `HX_VENV`/`HX_VENV_PYTHON` vars — uv owns this now);
added `hx-lint`/`hx-fmt`; `hx-check` now runs lint + hermetic tests
(mirrors `go-check`'s fmt+vet+test bundling, previously only ran tests)
- `docs/usage/hxprobe.md`: updated Setup and Makefile-targets sections for
the new toolchain
- `.gitignore`: added `.pytest_cache/`/`.ruff_cache/`
- No behavior change to the probe/CLI itself — confirmed identical output
before/after, `python/latprobe` untouched, zero `latprobe` imports remain
in `hxprobe/`
- Plan: `docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md`
- Summary: `docs/summaries/2026-07-02-09-57-hxprobe-toolchain-modernization.md`
---
## 2026-07-02 09:32 — `hxprobe` extracted into a standalone top-level project
- Moved `hxprobe` out of `python/` into a new top-level `hxprobe/` directory
(sibling of `go/` and `python/`), with its own `pyproject.toml`, its own
venv (`hxprobe/.venv`), and its own `hxprobe/tests/` — structured so it
could be `cp -r`'d into a separate repo and work unchanged
- Removed every `from latprobe import ...` in `hxprobe/`: `probe.py` now
defines its own `Options`/`Phase`/`Result`/`VerboseDetail`/`CertInfo`
dataclasses and its own `_parse_cert`/`_parse_cert_date` helpers (copied,
not shared); `aggregate.py`/`duration.py` are verbatim copies (their
imports were already package-relative, so no edits needed);
`cli.py` is now a full standalone implementation (own exit codes, argparse,
text/JSON rendering) instead of delegating to `latprobe.cli.run()` via an
injected `measure_fn`
- Reverted `python/latprobe/{cli.py,probe.py}` to their pre-`hxprobe` state
(`git checkout --`) — the `measure_fn`/`prog`/`description`/`protocol_flags`
injection points, `Options.follow_redirects`/`http2`, and
`VerboseDetail.http_version`/`redirect_count` only existed to support the
now-removed sharing; `python/` is back to zero third-party dependencies, no
`pyproject.toml`, no venv
- Makefile: removed `py-deps` and the `PY_VENV*` variables; `py-test`/
`py-check` reverted to running directly against `$(PYTHON)`; added a new
standalone `hx-deps`/`hx-run`/`hx-test`/`hx-test-integration`/`hx-check`/
`hx-clean` section using `HX_DIR`/`HX_VENV*`; umbrella `test`/`check`/
`clean` now include the hxprobe targets alongside Go and Python
(`go-*`/`py-*`/`hx-*`)
- Tests moved and renamed to drop the now-redundant `hx_`/`_hx` segments:
`test_hx_probe.py``hxprobe/tests/test_probe.py`,
`test_hx_cli.py``hxprobe/tests/test_cli.py`,
`test_integration_hx.py``hxprobe/tests/test_integration.py` (same
`test_[!i]*.py` hermetic-vs-integration exclusion convention as `latprobe`)
- `docs/usage/py-hxprobe.md` renamed to `docs/usage/hxprobe.md` and rewritten
for the new standalone structure and Makefile targets; the TCP_NODELAY/
Nagle TTFB-accuracy finding is preserved
- No behavior change — same CLI flags, same output, same six-phase timing,
same HTTP/2/redirect handling as before; this was a pure decoupling/
restructuring
- Plan: `docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md`
- Summary: `docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`
---
## 2026-07-02 00:24 — `hxprobe`: httpx-based Python probe, Go-client parity
- New sibling package `python/hxprobe/`, built on `httpx` instead of raw
sockets, so the client matches Go's `http.DefaultClient`: HTTP/2 negotiated
via ALPN, redirects followed by default, connection pooling, default TLS
verification — while still reporting the full six-phase breakdown (DNS, TCP
connect, TLS, TTFB, Transfer, Total)
- `hxprobe/probe.py`: DNS/TCP connect/TLS timed by instrumenting a custom
httpcore `NetworkBackend`/`NetworkStream` (`_TimingBackend`/`_TimingStream`);
HTTP framing (HTTP/1.1 or HTTP/2), redirects, and keep-alive stay entirely
owned by httpx; `measure()` returns the same `latprobe.probe.Result`
dataclass, so aggregation/rendering are reused unchanged
- `latprobe/probe.py`: added `Options.follow_redirects`/`Options.http2`
(ignored by the raw-socket `measure()`) and
`VerboseDetail.http_version`/`redirect_count` (always `""`/`0` there)
- `latprobe/cli.py`: `run()`/`_run_samples()` take an injectable `measure_fn`
(default: the existing socket `measure`), plus `prog`/`description`/
`protocol_flags` overrides — `hxprobe.cli.run()` reuses the entire argparse,
concurrency, exit-code, and text/JSON rendering pipeline unchanged
- New CLI flags (hxprobe only, via `protocol_flags=True`): `--no-http2`,
`--no-follow-redirects`
- `python/pyproject.toml`: first third-party dependency in this repo
(`httpx[http2]`); `make py-deps` provisions `python/.venv`
- Verified experimentally that `latprobe`'s raw socket — which never sets
`TCP_NODELAY` — pays a real ~40-50ms Nagle/delayed-ACK penalty on its TTFB
phase; `hxprobe` sets `TCP_NODELAY` (matching httpcore's default and Go's
`net.Dialer`) and does not. Documented in `docs/usage/py-hxprobe.md` as a
known divergence — `hxprobe`'s TTFB is the more accurate of the two, not
just different
- 17 new hermetic tests (`tests/test_hx_probe.py`), 11 new hermetic CLI tests
(`tests/test_hx_cli.py`), 9 new live integration tests
(`tests/test_integration_hx.py`, excluded from the default gate)
- Makefile: `py-deps`, `hx-run`, `hx-test-integration`; `py-test`/`py-check`
now run via the venv and include the new hermetic hxprobe tests
- User doc: `docs/usage/py-hxprobe.md`
- Plan: `docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md`
---
## 2026-07-01 12:55 — `--verbose` / `-v` flag for `latprobe` (Python)
- `-v`/`--verbose` flag added to the `latprobe` CLI
- `probe.py`: new `CertInfo` + `VerboseDetail` dataclasses; `Options.verbose`;
`Result.detail`; TTFB loop now accumulates until `\r\n\r\n` (unchanged TTFB
semantics — `t_first_byte` stamped on first `recv()`, not when headers complete);
captures resolved IP (from `getaddrinfo`), TLS version/cipher/bits
(from `sock.version()`/`sock.cipher()`), verified cert (from `getpeercert()`),
and all response headers
- `cli.py`: verbose text block appended after timing rows in all four output
branches (single, aggregate, all-failed, mixed); `_VERBOSE_HEADERS` priority
list controls which headers appear in text mode; JSON `"verbose"` object
includes all parsed headers, full cert fields, TLS metadata; `"verbose"` key
omitted when flag is absent
- 9 new hermetic probe tests, 15 new CLI tests covering verbose text and JSON
across success, connect-fail, and DNS-fail scenarios
- 15 new integration tests for real TLS cert fields (CN, expiry, issuer),
IP format, TLS version string, header presence, and verbose text/JSON output
- `python/configs/usage-latprobe.md`: runnable reference with real output for
all features including verbose and `--verbose --json`
- `docs/usage/py-latprobe.md`: updated with `--verbose` flag and examples
- Plan: `docs/plans/2026-07-01-12-55-py-latprobe-verbose.md`
---
## 2026-07-01 12:23 — Full `latprobe` Python package (Python, Step 3)
- `python/latprobe/` package: full port of the Go CLI, runnable as `python -m latprobe`
- `probe.py`: raw-socket HTTP measurement with `Options(timeout)` parameter; mirrors
`phases.py` technique; partial phases preserved on failure
- `aggregate.py`: `summarize(List[Result]) -> Aggregate` with per-phase `PhaseStats(min_ms, avg_ms, max_ms)`
- `duration.py`: parses Go-style duration strings (`10s`, `500ms`, `2m`, bare seconds)
- `cli.py`: injectable `run(args, stdout, stderr) -> int`; argparse with injected streams
(`_Parser` subclass); `ThreadPoolExecutor` concurrency across URLs; four text-rendering
branches (single, aggregate, all-failed, mixed); JSON output; worst-exit-code accumulation
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400 (`--fail`)
- `python/tests/test_probe.py`: 9 tests — success, 404, DNS fail, connect refused, TTFB
timeout, bad scheme, partial phase invariants; uses `http.server` + daemon threads
- `python/tests/test_cli.py`: 23 tests — drives `cli.run()` in-process; covers usage
errors, single/aggregate text, failure exit codes, worst-code, `--fail`, JSON schema,
JSON ordering, JSON error grouping
- Makefile `py-test`: added `PYTHONPATH=$(PY_DIR)`, removed `|| true`
- User doc: `docs/usage/py-latprobe.md`
---
## 2026-07-01 12:04 — Per-phase latency measurement (Python, Step 2)
- `python/phases.py`: self-contained script; hand-drives raw sockets to time
each HTTP phase individually — DNS (`getaddrinfo`), TCP connect, TLS handshake
(`ssl.wrap_socket`, HTTPS only), TTFB (sendall → first recv), Transfer, Total
- Partial phases preserved on failure (same invariant as Go's `probe.go`)
- Error classification mirrors Go's priority: dns → timeout → tls → connect
- Output format matches Go's single-sample text layout (14-char labels, `─`
separator, `%8.2f ms` alignment)
- Input: bare URL args or plain-text config file (same format as `simple.py`)
- Exits 0 if all URLs complete without network error; 1 if any failed
- User doc: `docs/usage/py-phases.md`
---
## 2026-07-01 11:30 — Error-path example configs for simple.py (Python, Step 1 refinement)
- `python/configs/` directory with 7 purpose-built config files, one per error class:
`all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`, `timeout.txt`,
`tls-errors.txt` (badssl.com), `http-errors.txt` (httpstat.us), `mixed.txt`
- `python/configs/usage.md`: runnable shell commands + expected output for every config
- Fixed `docs/usage/py-simple.md`: 4xx/5xx responses are reported as `FAIL` (not `OK`),
because `urlopen` raises `HTTPError` for non-2xx; added pointer to the example configs
---
## 2026-07-01 10:39 — Simple reachability checker (Python, Step 1)
- `python/simple.py`: reads a plain-text site list (one URL per line, `#` comments),
issues a GET to each, prints aligned `OK / FAIL + elapsed ms` per site
- Plain-text config format forward-compatible with future `key=value` annotations
- Exits 0 if all sites responded, 1 if any failed or config is missing
- `python/sites.txt`: committed example config
- Makefile `py-*` targets added: `py-simple-run`, `py-phases-run`, `py-run`,
`py-test`, `py-check`, `py-clean`; umbrella `test`, `check`, `clean` now include Python
- User doc: `docs/usage/py-simple.md`
---
## 2026-07-01 01:23 — Concurrency (Go, Step 7)
- Added `-c`/`--concurrency` flag: max URLs probed in parallel (0 = auto)
- Default auto-concurrency: `min(numURLs, 8)` — scales with workload, caps at 8
- Parallelism is **across URLs only**; the N samples of each URL stay sequential
to preserve the accuracy of per-URL min/avg/max statistics
- Output buffered and printed in original input order (deterministic for both
text and JSON), exit-code accumulation unchanged
- Implementation: semaphore channel + `sync.WaitGroup`, each goroutine writes
only its own indexed result slot — verified clean with `go test -race`
- Added `make go-test-race` target to the Makefile
- 3 new tests: `TestRunConcurrentOrder`, `TestConcurrentWorstCode`,
`TestConcurrentJSONOrder`
- Updated runtime estimates in `docs/custom-usage-examples.md`
- User doc: `docs/usage/step-7-concurrency.md`
---
## 2026-07-01 01:05 — Root Makefile
- Namespaced targets: `go-build`, `go-run`, `go-test`, `go-test-verbose`, `go-check`, `go-cover`, `go-fmt`, `go-vet`, `go-tidy`, `go-install`, `go-lint`, `go-clean`
- Umbrella targets (`build`, `test`, `check`, `fmt`, `vet`, `clean`, `all`) delegate to Go now; `py-*` will slot in during the Python port
- `help` is the default target; auto-generated from `##` comments
- `go-run` accepts `ARGS=` for passing flags, `go-lint` guards for `golangci-lint`
- `.gitignore` updated with coverage artifacts
---
## 2026-07-01 00:49 — Integration tests (Go, Step 6)
- Extracted `run(args, stdout, stderr) int` from `main()` to make the CLI testable in-process
- Fixed TLS classification bug: `TLSHandshakeDone` fires with the error on cert rejection, so `tlsErr` is now captured and checked before the stale `tlsStart.IsZero()` guard
- `run_test.go`: 14 in-process tests covering exit codes 06, multi-URL, sampling, and JSON structure assertions
- `cli_test.go`: `TestMain` builds the real binary; 3 subprocess smoke tests exercise the actual `os.Exit` path
- All test servers use `httptest` + stdlib; `.invalid` TLD for deterministic DNS failures; no external network dependency
---
## 2026-07-01 00:38 — Failure handling (Go, Step 5)
- Classified network failures into `dns` / `connect` / `timeout` / `tls` with distinct exit codes (25)
- Partial timing preserved up to the failure point (e.g. DNS phase shown on NXDOMAIN)
- `--timeout` flag (default 10s) applied via `context.WithTimeout`
- `--fail` flag: HTTP status ≥ 400 → exit code 6 (curl-style)
- `-n` sampling continues on network failure; aggregates successes, reports fail count + cause
- JSON output extended with `succeeded`, `failed`, `errors[]` fields
- Highest exit code across all URLs/failure types is used as the process exit
---
## 2026-07-01 00:08 — JSON output flag (Go, Step 4)
- `--json` flag emits a JSON array with one entry per URL

View File

@@ -10,6 +10,23 @@ a short kebab-case description.
Example: `docs/plans/2026-07-01-00-08-go-latency-tool.md`
If a plan is drafted via plan-mode tooling that restricts writes to a separate
scratch file, copy the approved plan verbatim into `docs/plans/` as the first
implementation step, before making any code changes. The scratch file existing
elsewhere does not satisfy this convention.
## Implementation Summaries
After finishing the implementation of a feature, save a summary under
`docs/summaries/` with a filename that starts with the **current timestamp in
`yyyy-mm-dd-hh-mm` format** followed by a short kebab-case description (same
convention as plans). The summary covers what was actually built (as opposed
to the plan, which covers what was intended): files added/changed, key design
decisions, any deviations from the plan, notable findings made along the way,
and how it was verified.
Example: `docs/summaries/2026-07-02-00-29-py-hxprobe-httpx.md`
## Changelog
Every completed feature is appended to `CHANGELOG.md` at the project root with

169
Makefile Normal file
View File

@@ -0,0 +1,169 @@
GO_DIR := go
PY_DIR := python
BINARY := latprobe
PYTHON := python3.14
ARGS ?=
SITES ?= $(PY_DIR)/sites.txt
# hxprobe is a fully standalone project (own pyproject.toml, own uv-managed
# venv/lockfile) — it imports nothing from python/latprobe, and could be
# `cp -r`'d into its own repo as-is.
HX_DIR := hxprobe
.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 hx-test ## Run all tests (Go + Python + hxprobe)
.PHONY: check
check: go-check py-check hx-check ## Run fmt + vet + test gate (Go + Python + hxprobe)
.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 hx-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
# ── hxprobe (standalone httpx-based probe; own pyproject.toml/uv.lock, no
# imports from python/latprobe — see hxprobe/pyproject.toml) ─────────────────
.PHONY: hx-deps
hx-deps: ## hx: sync hxprobe's uv-managed environment from its lockfile; idempotent
cd $(HX_DIR) && uv sync
.PHONY: hx-run
hx-run: hx-deps ## hx: run hxprobe (pass flags via ARGS="…")
cd $(HX_DIR) && uv run python -m hxprobe $(ARGS)
.PHONY: hx-lint
hx-lint: hx-deps ## hx: lint hxprobe with ruff
cd $(HX_DIR) && uv run ruff check .
.PHONY: hx-fmt
hx-fmt: hx-deps ## hx: format hxprobe with ruff
cd $(HX_DIR) && uv run ruff format .
.PHONY: hx-test
hx-test: hx-deps ## hx: run hxprobe unit tests (hermetic)
cd $(HX_DIR) && uv run pytest tests -m "not integration" -v $(ARGS)
.PHONY: hx-test-integration
hx-test-integration: hx-deps ## hx: run hxprobe integration tests against live internet services (HTTP/2, redirects)
cd $(HX_DIR) && uv run pytest tests -m integration -v $(ARGS)
.PHONY: hx-check
hx-check: hx-lint hx-test ## hx: run hxprobe test gate (lint + hermetic tests)
.PHONY: hx-clean
hx-clean: ## hx: remove hxprobe bytecode, caches, and egg-info
@find $(HX_DIR) -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null; \
find $(HX_DIR) -name '*.pyc' -delete 2>/dev/null; \
rm -rf $(HX_DIR)/*.egg-info $(HX_DIR)/.pytest_cache $(HX_DIR)/.ruff_cache; true

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
# How does concurrency work in hxprobe?
## Question
Selection in `hxprobe/hxprobe/cli.py:448-449`:
```python
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
all_results = list(ex.map(_probe, urls))
```
> explain how concurrency works in hxprobe
## Answer
There are two independent levels of repetition:
1. **Across URLs** — parallel, via a thread pool.
2. **Across samples of the same URL** (`-n`/`--count`) — sequential, within a single thread.
### Worker count
`cli.py:440-443`
```python
workers = ns.concurrency
if workers <= 0:
workers = min(len(urls), 8)
workers = max(1, min(workers, len(urls)))
```
`-c`/`--concurrency` picks the pool size; `0` (default) means "auto" → `min(len(urls), 8)`. The final clamp guarantees `1 ≤ workers ≤ len(urls)` — never more threads than there are URLs to probe, never zero.
### The pool itself
`cli.py:445-449`
```python
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))
```
One task per URL is submitted to the pool. `_probe` delegates to `_run_samples`, `cli.py:64-69`:
```python
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
```
So within one URL's thread, the `count` samples run **one at a time** — never concurrently. This is deliberate: if N samples fired at the same host in parallel, they'd contend for the same TCP/TLS handshake path and connection setup, and the resulting min/avg/max per phase would reflect that contention rather than the host's actual latency. Keeping samples sequential is what makes `-n`'s statistics meaningful.
### Why threads work here despite the GIL
`measure()` does blocking socket/SSL I/O — `socket.connect()`, `.recv()`, `ssl.wrap_socket()`, etc. (see the custom `_TimingBackend`/`_TimingStream` in `probe.py`). CPython releases the GIL around blocking syscalls, so N threads genuinely overlap in wall-clock time waiting on the network, even though only one thread executes Python bytecode at once. This is I/O-bound concurrency, not CPU parallelism — threads are the right tool, not `asyncio` or multiprocessing.
### Ordering guarantee
`ex.map(_probe, urls)` returns results in the *same order as the input `urls`*, regardless of which thread finishes first — that's a documented property of `Executor.map`. That's why `list(ex.map(...))` can be zipped directly against `urls` afterward (`cli.py:454`) to produce deterministic text/JSON output order, even though the underlying probes complete out of order.
### No shared mutable state / no locks needed
Each thread's `_probe` call returns its own `(succeeded, failed)` tuple; nothing is written to a shared structure until back in the main thread after the `with` block exits (which also blocks until every submitted task completes, since `ThreadPoolExecutor.__exit__` calls `shutdown(wait=True)`). The worst-exit-code accumulation and JSON building (`cli.py:451-472`) then run single-threaded over `all_results`.

View File

@@ -0,0 +1,144 @@
# What does the per-URL worst-exit-code / render loop do, and does it make sense?
## Question
Selection in `hxprobe/hxprobe/cli.py:454-472`:
```python
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)
```
> explain following block and if it makes sense, other related stuff, and save it in explanations
## What it does
This is the single pass over per-URL results that runs after the thread pool
(`cli.py:448-449`, see [2026-07-02-13-10-hxprobe-concurrency.md](2026-07-02-13-10-hxprobe-concurrency.md))
finishes. It does two jobs in one loop: compute the process's final exit code,
and render output (text or accumulate JSON) — one URL at a time, in input
order (guaranteed by `zip(urls, all_results)` since `ex.map` preserves order).
**1. Network-failure exit code (`cli.py:455-458`)**
```python
for r in failed:
c = _phase_code(r.fail_phase)
if c > worst:
worst = c
```
Every failed sample (across every URL, since `worst` is declared once before
the loop) is mapped to an exit code via `_phase_code` / `_PHASE_EXIT`
(`cli.py:24-32`):
```python
_PHASE_EXIT: dict[str, int] = {
"dns": EXIT_DNS, # 2
"timeout": EXIT_TIMEOUT, # 4
"tls": EXIT_TLS, # 5
}
def _phase_code(fail_phase: str) -> int:
return _PHASE_EXIT.get(fail_phase, EXIT_CONNECT) # 3, the fallback
```
Any `fail_phase` not in the table — `"connect"`, `"transfer"`, `"request"`
falls back to `EXIT_CONNECT` (3). `worst` tracks the running max across all
URLs/samples, so the process exit code always reflects the single worst
failure class seen, per the exit-code table (0 ok … 6 http via `--fail`).
**2. `--fail` (HTTP status ≥ 400) exit code (`cli.py:459-462`)**
```python
if ns.fail:
for r in succeeded:
if r.status_code >= 400:
worst = max(worst, EXIT_HTTP)
```
Only runs when `--fail` is passed. Note this scans `succeeded` — a 404 is not
a network failure, so those `Result`s land in `succeeded` with a populated
`status_code`; `--fail` is what turns "successfully got a bad status" into a
non-zero exit, curl-style.
**3. Verbose-detail selection (`cli.py:464`)**
```python
last_detail = succeeded[-1].detail if succeeded else (failed[-1].detail if failed else None)
```
Prefers the last successful sample's `detail` (freshest full picture: IP,
protocol, TLS, headers); falls back to the last *failed* sample's `detail` if
nothing succeeded (e.g. resolved IP is still known even on a connection
refusal); `None` if there's nothing to show. Reasonable design — surfaces
partial diagnostic info even on total failure.
**4. JSON accumulation vs. text rendering (`cli.py:466-472`)**
```python
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)
```
JSON mode builds up `json_items` (dumped once after the loop) and skips
straight to the next URL via `continue`. Text mode writes a blank-line
separator before every URL block except the first (`i > 0`), then delegates
actual formatting to `_print_url`, which picks one of four branches
(single / aggregate / all-failed / mixed) based on `n_ok`/`n_fail`/`total_count`.
## Does it make sense?
**Yes, structurally.** Combining exit-code accumulation and rendering into one
O(n) pass is reasonable for a CLI at this scale — no need to split into two
loops. The `last_detail` fallback logic is a genuinely good touch. The one
stylistic wrinkle — the DNS/connect/timeout/tls loop uses
`if c > worst: worst = c` while the `--fail` branch uses `worst = max(worst,
EXIT_HTTP)` for the same "keep the running max" purpose — is harmless
inconsistency, not a bug.
**Update (2026-07-02):** the "does a single worst-code exit even make sense
across multiple URLs with different errors" question came back as a follow-up
and led to a real feature — see
[2026-07-02-14-05-hxprobe-run-summary-footer.md](2026-07-02-14-05-hxprobe-run-summary-footer.md).
Short version: the scalar exit code is kept (it's a documented cross-
implementation contract with `latprobe`/Go), but multi-URL runs now get an
end-of-run summary footer tallying every URL's outcome, so the "worst code"
is no longer the only visibility into what happened. That change also
unified the two idioms noted above into one `max(...)` call.
**One real gap, found while checking this: `-n`/`--count` is unvalidated.**
`cli.py:349-355` declares `--count` as `type=int, default=1` with no minimum.
`_run_samples` (`cli.py:64-69`) does `for _ in range(count): ...`, so
`--count 0` (or any negative value) makes the loop body never execute, and
both `succeeded` and `failed` come back empty for that URL. Confirmed live:
```
$ hxprobe --count 0 https://example.com
(0, 0 samples)
exit=0
$ hxprobe --count -2 https://example.com
(0, 0 samples)
exit=0
```
The URL itself is missing from the header, `status` reads `0`, and the exit
code is `0` (success) — because `_print_url` falls into the `elif n_fail == 0`
aggregate branch with `summarize([])`, which returns a bare
`Aggregate()` (all defaults, `url=""`) rather than anything referencing the
actual `url` variable. This is silent garbage output instead of a clear
usage error, and it's inconsistent with how the rest of `run()` already
validates arguments (e.g. the `parser.error(...)` calls for the
`urls`/`--file` mutual-exclusion checks at `cli.py:407-410`). Worth a
`parser.error("count must be >= 1")`-style guard if this is ever picked up —
not fixed here since it wasn't asked for, just flagged as a finding.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
# Plan: `hxprobe` — httpx-based Python probe (Go-client parity)
## Context
The existing Python `latprobe` package measures per-phase HTTP latency with raw
sockets. That gives an excellent DNS/TCP/TLS/TTFB/Transfer breakdown, but the
cost is that it always speaks **HTTP/1.1** and **does not follow redirects**
diverging from the Go implementation, whose `http.DefaultClient` negotiates
**HTTP/2** via ALPN, **follows redirects** (up to 10), pools connections, and
verifies TLS by default (confirmed: `go/internal/probe/probe.go` uses
`http.DefaultClient.Do` with no custom transport/`CheckRedirect`).
Goal: add a **second Python implementation, `hxprobe`**, built on the `httpx`
library so it matches the Go client's protocol capabilities (HTTP/2, redirects,
pooling, TLS verification) **while preserving the full 6-phase timing** that is
latprobe's whole point. Python has no equivalent of Go's `net/http/httptrace`,
so the phase breakdown is recovered by instrumenting httpx's network backend.
This is an **additive, Python-only experiment** — intentionally outside the
CLAUDE.md "Go first, then Python port" flow, since the user explicitly asked for
a library-based Python variant. No Go change is required.
## Approach
New sibling package `python/hxprobe/`, reusing everything reusable from
`latprobe` (dataclasses, aggregation, duration parsing, CLI rendering) so the
only genuinely new code is the httpx probe backend.
### Key design: instrumented httpx transport
`httpx` (sync `httpx.Client`) runs on `httpcore`. To recover per-phase timing we
subclass httpcore's sync network backend and time the phases at the socket
level, letting httpx own HTTP framing, HTTP/2, redirects, and keep-alive:
- `connect_tcp(...)` — reimplement DNS + TCP as separate steps (port the
`socket.getaddrinfo``socket.connect` split already in
`latprobe/probe.py:167-203`), timestamping **DNS** and **TCP connect**
independently, and capturing the resolved IP.
- `start_tls(...)` — timestamp the **TLS handshake**; pull negotiated version /
cipher / peer cert from the SSL object for verbose mode.
- **TTFB** = headers-received minus end-of-TLS (server processing), measured via
`client.stream("GET", ...)` (the `stream()` context yields once response
headers arrive).
- **Transfer** = iterating `resp.iter_raw()` to EOF, minus headers-received.
- **Total** = wraps the whole `measure()` call.
Each `measure()` call uses a **fresh `httpx.Client` (no cross-sample pooling)** so
every `-n` sample yields a full phase breakdown — matching the current
raw-socket `latprobe` behavior rather than Go's pool-reuse quirk.
A per-call trace object (held by the backend instance) records timings and the
`fail_phase` at the exact point a phase raises, giving precise error
classification (`dns`/`connect`/`timeout`/`tls`/`transfer`/`request`) without
guessing from httpx exception types.
**Redirects (followed by default, Go parity):** DNS/connect/TLS are reported
from the **first** connection (mirrors Go's `connectStart.IsZero()` guard);
TTFB/Transfer/Total span the full followed chain. `redirect_count` and the
negotiated `http_version` (`h2` vs `http/1.1`) are surfaced as new verbose
fields — a genuine capability the socket version lacks.
## Files
**New:**
- `python/pyproject.toml` — project metadata; dependency `httpx[http2]` (pulls
`h2`). Makes `latprobe` + `hxprobe` `pip install -e .`-able; tests still run
via `PYTHONPATH`.
- `python/hxprobe/__init__.py`
- `python/hxprobe/probe.py``measure(url, opts) -> latprobe.probe.Result`
(imports & returns the **same `Result`** so aggregation/rendering just work);
`_TimingBackend`, the per-call trace, error classification, verbose capture.
- `python/hxprobe/cli.py` — thin: delegates to `latprobe.cli.run(...)` passing
`measure_fn=hxprobe.probe.measure` (see reuse edit below).
- `python/hxprobe/__main__.py``sys.exit(cli.run(sys.argv[1:], ...))`.
- `python/tests/test_hx_probe.py` — hermetic, local `http.server`: phase
presence, **redirect following** (302 handler — validates the Go-parity
feature), connection-refused → `connect`, black-hole port → `timeout`.
- `python/tests/test_hx_cli.py` — hermetic CLI via `run()` with `io.StringIO`
(mirrors `tests/test_cli.py:73-76`).
- `python/tests/test_integration_hx.py` — live, **excluded from default gate**
(filename starts `test_i…`, so the `test_[!i]*.py` glob skips it): probe a
real HTTP/2 host and assert negotiated `http_version == "h2"`; guard with a
`@skipUnless(_online())` like `tests/test_integration.py:29-40`.
- `docs/usage/py-hxprobe.md` — usage doc (what it does, flags, example with
expected output, and an explicit socket-vs-httpx capability comparison table),
per CLAUDE.md.
**Edited (small, backward-compatible):**
- `python/latprobe/cli.py` — parameterize `run()` and `_run_samples()` with an
injectable `measure_fn` (default = current `latprobe.probe.measure`), so
`hxprobe` reuses all argparse, concurrency, exit-code, text/JSON rendering
logic. Extend `_print_verbose_block` and `_build_json_entry` to show
`http_version` / `redirect_count` **when present** (existing socket path never
sets them → output unchanged).
- `python/latprobe/probe.py` — add optional fields with safe defaults:
`Options.follow_redirects=True`, `Options.http2=True` (ignored by the socket
measure); `VerboseDetail.http_version=""`, `VerboseDetail.redirect_count=0`.
- `Makefile` — add `py-deps` (create `.venv`, `pip install -e python`),
`hx-run` (`cd python && python -m hxprobe $(ARGS)`), and fold `test_hx_*` into
the existing `py-test` gate; add `hx-test-integration` for the live h2 check.
- `CHANGELOG.md` — append a timestamped one-line entry on completion.
**Reused as-is:** `latprobe.aggregate.summarize`,
`latprobe.duration.parse_duration`, `latprobe.probe.{Result,Phase,CertInfo}`,
and the entire `latprobe.cli` renderer via the `measure_fn` injection.
## Flags / behavior
Same surface as `latprobe` (`-n/--count`, `-c/--concurrency`, `--timeout`,
`--fail`, `--json`, `-v/--verbose`) via the reused parser, plus two new opt-outs
for the Go-like defaults: `--no-http2` and `--no-follow-redirects`. Exit codes
06 stay identical to Go/`latprobe`.
## Verification
1. `make py-deps` — create venv, install `httpx[http2]`.
2. `make hx-run ARGS="https://example.com"` — full 6-phase text output renders.
3. `make hx-run ARGS="-v https://www.cloudflare.com"` — verbose block shows
`http_version: h2` and TLS/cert details.
4. **Go-parity spot checks:**
- HTTP/2: `python -m hxprobe -v <h2-host>` reports `h2` where
`python -m latprobe -v <h2-host>` reports HTTP/1.1.
- Redirects: `python -m hxprobe http://github.com` follows to https and shows
`redirect_count > 0` (socket `latprobe` shows a raw 301).
5. `make py-test` — hermetic suite (now including `test_hx_probe.py`,
`test_hx_cli.py`) is green; `latprobe`'s existing tests still pass (proves the
`measure_fn`/`Options`/`VerboseDetail` edits are backward-compatible).
6. `make hx-test-integration` — live test confirms real `h2` negotiation.
7. Confirm `--json` output for `hxprobe` matches the `latprobe` schema plus the
optional `verbose.http_version` / `verbose.redirect_count` keys.

View File

@@ -0,0 +1,151 @@
# Plan: extract `hxprobe` into a fully standalone top-level project
## Context
`hxprobe` currently lives at `python/hxprobe/` and imports shared code from
`latprobe` (`latprobe.probe.{Options,Phase,Result,VerboseDetail,_parse_cert}`,
`latprobe.cli.run`). That coupling was a deliberate reuse choice at the time,
but the user now wants `hxprobe` to be a genuinely independent project — no
`from latprobe import ...` anywhere, duplication accepted — and, per their
follow-up answer, independent enough that it could be `cp -r`'d into its own
repo tomorrow: own `pyproject.toml`, own venv, own Makefile section, own
top-level directory (sibling to `go/` and `python/`), not nested under `python/`.
Confirmed via `git status`/`git log`: everything hxprobe-related
(`python/hxprobe/`, `python/pyproject.toml`, `python/tests/test_hx_*.py`,
`docs/usage/py-hxprobe.md`, the two `docs/plans`/`docs/summaries` entries) is
still uncommitted from this session, and `python/latprobe/cli.py` /
`python/latprobe/probe.py` only diverge from the last commit (`24ea9c9`) by
the reuse-oriented additions made to support hxprobe. So this is a clean,
low-risk restructuring: move already-debugged code, revert latprobe with
`git checkout --`, no git history surgery needed.
## Target layout
```
hxprobe/ (new, top-level, sibling of go/ and python/)
├── pyproject.toml (own manifest: httpx[http2] dependency)
├── hxprobe/
│ ├── __init__.py
│ ├── probe.py (own Options/Phase/Result/VerboseDetail/CertInfo
│ │ + own _parse_cert/_parse_cert_date + existing
│ │ _Trace/_TimingStream/_TimingBackend/
│ │ _TimingTransport/measure() — unchanged logic)
│ ├── aggregate.py (verbatim copy of latprobe/aggregate.py —
│ │ its `from .probe import Result` is already
│ │ package-relative, needs zero edits)
│ ├── duration.py (verbatim copy of latprobe/duration.py — no
│ │ imports at all)
│ ├── cli.py (full standalone CLI — see below)
│ └── __main__.py (unchanged: `from .cli import run`)
└── tests/
├── __init__.py (empty, matches python/tests/__init__.py)
├── test_probe.py (moved from python/tests/test_hx_probe.py)
├── test_cli.py (moved from python/tests/test_hx_cli.py)
└── test_integration.py (moved from python/tests/test_integration_hx.py)
```
`python/` reverts to containing only `latprobe` — zero third-party deps, no
`pyproject.toml`, no venv, exactly its pre-hxprobe state.
## Step-by-step
**1. Move already-debugged files (preserve the bug fixes already made):**
`git mv`/`mv` (untracked, so plain `mv` is fine) `python/hxprobe/{probe.py,__init__.py,__main__.py}`
to `hxprobe/hxprobe/`, and the three `python/tests/test_hx_*.py` /
`test_integration_hx.py` files to `hxprobe/tests/` with the `hx_`/`_hx` name
segments dropped (`test_probe.py`, `test_cli.py`, `test_integration.py`).
Do **not** rewrite these from scratch — they already have the mark_dns /
verbose-on-failure-path / Content-Length-on-keep-alive fixes found during
the original implementation.
**2. Inline the dataclasses into `hxprobe/hxprobe/probe.py`:**
Replace `from latprobe.probe import Options, Phase, Result, VerboseDetail, _parse_cert`
with local definitions copied verbatim from `python/latprobe/probe.py`:
`CertInfo`, `VerboseDetail` (its `http_version`/`redirect_count` fields are
now simply always-meaningful, no more "populated only by hxprobe" caveat
comment needed), `Options` (with `follow_redirects`/`http2` as normal fields,
no more "ignored by socket measure()" caveat), `Phase`, `Result`,
`_parse_cert`, `_parse_cert_date`. Everything else in the file (`_Trace`,
`_TimingStream`, `_TimingBackend`, `_TimingTransport`, `measure()`,
`_classify`, `_unwrap`, `_fill_phases`, `_fill_verbose`) is untouched.
**3. Create `hxprobe/hxprobe/aggregate.py` and `duration.py`:**
Verbatim copies of `python/latprobe/aggregate.py` and `duration.py`.
**4. Write `hxprobe/hxprobe/cli.py` as a full standalone CLI:**
Start from the *current* `python/latprobe/cli.py` (it already has 100% of
the needed logic, including the `--no-http2`/`--no-follow-redirects` flags,
the verbose "Protocol" row, and the JSON `http_version`/`redirect_count`
keys — all added earlier specifically for hxprobe). Strip the
generalization scaffolding that only existed to let `latprobe` share this
code:
- Remove the `MeasureFn` type alias and the `measure_fn` parameter from
`_run_samples`/`run` — call `measure` directly (module-level import from
`.probe`).
- Remove `run()`'s `prog`/`description`/`protocol_flags` parameters —
hardcode `prog="hxprobe"` and the httpx-specific description.
- Make the `--no-http2`/`--no-follow-redirects` `add_argument` calls
unconditional (drop the `if protocol_flags:` guard).
- Everything else (exit codes, `_ArgExit`/`_Parser`, phase-label/verbose
constants, all `_print_*`/`_build_json_entry` rendering) carries over
unchanged — it's already correct standalone logic.
**5. Revert `python/latprobe/` to its pre-hxprobe state:**
`git checkout -- python/latprobe/cli.py python/latprobe/probe.py` (safe:
confirmed these are the only diffs since the last commit, and both diffs
are exactly the reuse scaffolding being removed here).
**6. Clean up the old shared-package artifacts:**
Delete `python/hxprobe/`, `python/pyproject.toml`, `python/.venv/`, and the
three `python/tests/test_hx_*`/`test_integration_hx.py` files (now moved).
**7. Makefile:**
- Remove `PY_VENV`/`PY_VENV_PYTHON` vars and the `py-deps` target; revert
`py-test`/`py-test-integration`/`py-check` to invoke `$(PYTHON)` directly
with no `py-deps` prerequisite (their pre-hxprobe form). Remove `hx-run`/
`hx-test-integration` from the Python section (moving out).
- Add `HX_DIR := hxprobe`, `HX_VENV := $(HX_DIR)/.venv`,
`HX_VENV_PYTHON := $(HX_VENV)/bin/$(PYTHON)` and a new "── hxprobe
(standalone) ──" section: `hx-deps` (idempotent venv+pip install, mirrors
the removed `py-deps`), `hx-run`, `hx-test` (hermetic, `test_[!i]*.py`
glob), `hx-test-integration` (`test_integration.py` pattern), `hx-check`,
`hx-clean`.
- Fold into the umbrella targets: `test: go-test py-test hx-test`,
`check: go-check py-check hx-check`, `clean: go-clean py-clean hx-clean`.
**8. Docs:**
- Rename `docs/usage/py-hxprobe.md``docs/usage/hxprobe.md` (drop the
`py-` prefix — it's no longer part of the Python port). Update: remove the
"reuses `latprobe.cli.run()` in full" claim (now false — say it's a fully
standalone implementation sharing only the *design*, not the code, with
`latprobe`); update setup/Makefile-target sections to `make hx-deps`/
`hx-run`/`hx-test`/`hx-test-integration`; update file paths from
`python/hxprobe/` to `hxprobe/hxprobe/`. Keep the TCP_NODELAY/Nagle finding
and the example transcripts (still accurate — behavior is unchanged, only
location/packaging changed).
- `CHANGELOG.md`: new entry describing the extraction.
- `docs/summaries/`: new dated summary per the CLAUDE.md convention
(leave the original `2026-07-02-00-29-py-hxprobe-httpx.md` as-is — it's a
historical record of that implementation; this is a follow-up).
- `docs/plans/`: save this plan as
`docs/plans/<yyyy-mm-dd-hh-mm>-hxprobe-standalone-project.md` at
implementation start, per CLAUDE.md.
## Verification
1. `rm -rf hxprobe/.venv python/.venv` (fresh state) then `make hx-deps`
creates `hxprobe/.venv`, installs `httpx[http2]` from `hxprobe/pyproject.toml`.
2. `grep -rn "latprobe" hxprobe/` — must return nothing (proves the
decoupling).
3. `make hx-run ARGS="-v http://github.com"` — same output as before (HTTP/2,
1 redirect followed, IP/TLS/cert shown).
4. `make hx-test` — all hermetic hxprobe tests pass standalone.
5. `make hx-test-integration` — live HTTP/2 + redirect tests still pass.
6. `make py-test` — confirms `latprobe`'s own suite is back to its original,
dependency-free form and still green (no `py-deps` needed to run it).
7. `make check` (top-level) — Go + latprobe + hxprobe all green in one gate.
8. `diff <(python -m latprobe --help) <(git show 24ea9c9:python/latprobe/cli.py | ...)`
or simpler: confirm `python -m latprobe --help` output is byte-identical
to before this whole feature existed (no leftover `--no-http2` etc.).

View File

@@ -0,0 +1,115 @@
# Plan: modernize hxprobe's Python toolchain
## Context
`hxprobe` (`hxprobe/`) is a fully standalone Python project (own
`pyproject.toml`, own venv, own tests — see
`docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`). Its
toolchain is currently bare-minimum: hand-rolled `python -m venv` + `pip
install -e .`, no linter/formatter, no type checker, no dependency lockfile,
and stdlib `unittest` with a filename-glob convention
(`test_[!i]*.py`/`test_integration.py`) to separate hermetic from live tests.
User confirmed direction (via AskUserQuestion): adopt **uv** for env/deps
(with a lockfile), add **ruff** for lint+format, **skip mypy** for now, and
**swap the test runner to pytest** (existing `unittest.TestCase` classes run
unchanged under pytest — no test-code rewrite) using a proper
`@pytest.mark.integration` marker instead of the filename-glob trick.
Confirmed via inspection: `uv` (v0.11.19) is already installed globally
(Homebrew); `ruff`/`mypy`/`pytest` only exist under an unrelated pyenv 3.12.3
shim, not on the 3.14 interpreter this project targets — `uv` sidesteps that
mismatch by installing everything into the project's own venv. Git remote is
a self-hosted Gitea instance, not GitHub, so no CI is being set up here.
## Changes
**`hxprobe/pyproject.toml`:**
- Add `[dependency-groups]` with `dev = ["pytest>=8.0", "ruff>=0.8"]` (PEP
735, the current uv-native way to declare dev-only deps — keeps the
install-as-a-library `dependencies` list clean).
- Add `[tool.pytest.ini_options]`: `testpaths = ["tests"]` and a registered
`integration` marker (avoids `PytestUnknownMarkWarning`).
- Add `[tool.ruff]`: `target-version = "py311"` (matches `requires-python`)
and `line-length = 100` (close to the codebase's existing longest lines,
~103 chars, to minimize reformatting churn).
- Leave `[build-system]`/`[tool.setuptools]` untouched — build backend
wasn't part of the discussion, setuptools works fine here.
**`hxprobe/.python-version`:** new file, `3.14`, so `uv sync`/`uv run` pin the
same interpreter the rest of the repo uses without relying on `$PATH` order.
**`hxprobe/uv.lock`:** generated by `uv lock` — first real lockfile pinning
`httpx`, `httpcore`, `h2`, `certifi`, and friends. Committed (not gitignored;
lockfiles belong in version control).
**Test changes (runner swap only, no rewrite):**
- `hxprobe/tests/test_integration.py`: add `pytestmark = pytest.mark.integration`
at module level. This replaces the "named `test_integration.py` so the
`test_[!i]*.py` glob skips it" convention — selection becomes `-m
"not integration"` / `-m integration`, independent of filename.
- `test_probe.py`/`test_cli.py`: no changes — they're already hermetic
(local `http.server` fixtures only) and need no marker.
- `unittest.TestCase` classes, `_NEEDS_NET = unittest.skipUnless(...)`, and
the `if __name__ == "__main__": unittest.main()` guards all stay exactly
as-is — pytest natively discovers and runs unittest-style tests and
respects `unittest.skip*` decorators with zero changes required.
**Lint fixes:** run `ruff check --fix` / `ruff format` over `hxprobe/` and
review the diff. One known pre-existing issue it will flag: `import sys` in
`cli.py` is unused (inherited from the original `latprobe/cli.py`) — remove
it. Otherwise expect mostly whitespace/quote-style normalization.
**`Makefile`:** replace the `hx-*` section to run through `uv` instead of a
hand-managed venv:
```makefile
HX_DIR := hxprobe # (drop HX_VENV / HX_VENV_PYTHON — uv owns this now)
hx-deps: cd $(HX_DIR) && uv sync
hx-run: (deps: hx-deps) cd $(HX_DIR) && uv run python -m hxprobe $(ARGS)
hx-lint: (deps: hx-deps) cd $(HX_DIR) && uv run ruff check .
hx-fmt: (deps: hx-deps) cd $(HX_DIR) && uv run ruff format .
hx-test: (deps: hx-deps) cd $(HX_DIR) && uv run pytest tests -m "not integration" -v $(ARGS)
hx-test-integration: (deps: hx-deps) cd $(HX_DIR) && uv run pytest tests -m integration -v $(ARGS)
hx-check: hx-lint hx-test (test gate now includes lint, mirroring go-check's fmt+vet+test bundling)
hx-clean: also removes .pytest_cache / .ruff_cache alongside __pycache__/egg-info
```
Umbrella `test`/`check`/`clean` targets keep delegating to `hx-test`/
`hx-check`/`hx-clean` unchanged.
**`hxprobe/README.md`:** new, minimal — since this project is meant to be
`cp -r`-able to its own repo, it should carry its own quick-start
(`uv sync`, `uv run python -m hxprobe <url>`, `uv run pytest`) rather than
relying on the monorepo's root docs.
**Docs:** update `docs/usage/hxprobe.md`'s "Setup" section (`uv sync`
instead of manual venv+pip) and "Makefile targets" section (add
`hx-lint`/`hx-fmt`, update test invocation description). New
`docs/plans/<timestamp>-hxprobe-toolchain-modernization.md` and
`docs/summaries/<timestamp>-hxprobe-toolchain-modernization.md` per
CLAUDE.md convention. New `CHANGELOG.md` entry.
**`.gitignore`:** already covers `.venv/`/`__pycache__/`/`*.egg-info/`
unanchored; add `.pytest_cache/` and `.ruff_cache/` (new caches these tools
create).
## Verification
1. `cd hxprobe && uv sync` — creates `.venv`, generates/uses `uv.lock`,
installs `httpx[http2]` + dev deps (`pytest`, `ruff`).
2. `make hx-run ARGS="-v http://github.com"` — same output as before
(HTTP/2, 1 redirect, IP/TLS/cert shown) — confirms the runtime behavior
is untouched by the toolchain swap.
3. `make hx-lint` — clean (after fixing whatever `ruff check` surfaces,
including the unused `import sys`).
4. `make hx-test` — all hermetic tests pass under pytest; confirm the
`integration`-marked tests are excluded (test count matches the current
28 hermetic tests).
5. `make hx-test-integration` — the 9 live tests run and pass under the
`integration` marker selection.
6. `make check` (top-level) — Go + latprobe + hxprobe (lint + test) all
green in one gate.
7. `grep -rn "latprobe" hxprobe/` — still zero matches (toolchain change
must not reintroduce coupling).
8. Confirm `python/latprobe/` is untouched (`git diff --stat python/` empty)
— this is a hxprobe-only change.

View File

@@ -0,0 +1,112 @@
# Plan: hxprobe-specific usage doc (case-by-case) + hxprobe-specific Makefile
## Context
`hxprobe` is a fully standalone project (own `pyproject.toml`, `uv.lock`,
`README.md` — see `docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md`
and `...-toolchain-modernization.md`). Two gaps remain versus the other
Python implementations and versus hxprobe's own "could be `cp -r`'d to its
own repo" design goal:
1. **Docs**: `latprobe`/`phases.py`/`simple.py` each have two usage docs —
the CLAUDE.md-mandated `docs/usage/py-<name>.md` (what/flags/one example)
_and_ a much richer `python/configs/usage-<name>.md` "Runnable Usage
Reference" walking through a full set of concrete cases with real
captured output (confirmed by reading `python/configs/usage-latprobe.md`,
313 lines: basic, verbose × 4 variants, sampling, multi-URL, `--fail`,
JSON × 2, timeout, exit-codes table, Makefile shortcuts). `hxprobe` only
has the first kind. User confirmed: add the second kind, placed _inside_
`hxprobe/` itself (not under `python/configs/`, since hxprobe no longer
lives there) so the doc travels with the project if extracted.
2. **Makefile**: hxprobe currently has no `Makefile` of its own — the only
way to run/test/lint it is through the parent repo's root `Makefile`.
Extracted to its own repo, there'd be no `make` interface left. User
confirmed: add `hxprobe/Makefile`, fully independent from the root
Makefile's existing `hx-*` targets (no delegation either direction —
both keep their own complete logic, at the cost of some duplication).
Confirmed: neither `latprobe` nor `hxprobe` accept a config file (both take
URLs as positional CLI args — only `simple.py`/`phases.py` read the
`python/configs/*.txt` files), so no `.txt`-config-file equivalent is needed
for hxprobe; this is a docs+Makefile-only task.
## Changes
**`hxprobe/USAGE.md`** (new) — modeled directly on
`python/configs/usage-latprobe.md`'s structure and tone (concrete `sh`
command blocks immediately followed by real captured output, real IPs/certs/
timings, brief explanatory notes, `---` section separators). Cases, in order:
1. Basic — single URL
2. Verbose — HTTPS site (shows `Protocol: HTTP/2`, TLS, cert)
3. Verbose — plain HTTP (no TLS block)
4. Verbose — redirect followed by default (`http://github.com` → 200,
`Protocol: HTTP/2 (1 redirect)`) — **hxprobe-specific**, latprobe has no
equivalent
5. `--no-follow-redirects` — same URL, raw `301` instead — **hxprobe-specific**
6. `--no-http2` — forces `Protocol: HTTP/1.1`**hxprobe-specific**
7. Verbose — TLS failure (expired cert, badssl.com)
8. Verbose — DNS failure (empty verbose block, suppressed)
9. Sampling (`-n`) — min/avg/max table, plus verbose+sampling
10. Multiple URLs (parallel probing)
11. `--fail` flag — exit 6 on HTTP 4xx
12. JSON output
13. JSON + verbose (includes `http_version`/`redirect_count` keys)
14. Timeout
15. Exit codes table (same 06 scheme as `latprobe`/Go)
16. Makefile shortcuts — both the new `hxprobe/Makefile` (`make run`,
`make test`, etc., run from inside `hxprobe/`) and the parent repo's
root shortcuts (`make hx-run`, run from the repo root)
Cases 1, 2, 3, 4, 5, 6, 8, 11 can reuse real output already captured earlier
in this session (still accurate — no code changed since). Cases 7, 9, 10,
12, 13, 14 need fresh live runs during implementation to get real numbers
(same standard the other `usage-*.md` docs hold themselves to — no
fabricated timings).
**`docs/usage/hxprobe.md`** (edit) — add a one-line pointer near the top:
"For a full case-by-case runnable reference, see `hxprobe/USAGE.md`." No
other changes; it stays the CLAUDE.md-mandated summary doc.
**`hxprobe/Makefile`** (new) — fully self-sufficient, same auto-generated
`## comment` help style as the root Makefile, short target names (no `hx-`
prefix needed since it's already scoped by being inside `hxprobe/`):
```makefile
PYTHON ?= python3.14
ARGS ?=
.DEFAULT_GOAL := help
help # auto-generated from ## comments, same style as root Makefile
deps # uv sync
run # uv run python -m hxprobe $(ARGS) (deps: deps)
lint # uv run ruff check . (deps: deps)
fmt # uv run ruff format . (deps: deps)
test # uv run pytest tests -m "not integration" -v $(ARGS) (deps: deps)
test-integration # uv run pytest tests -m integration -v $(ARGS) (deps: deps)
check # lint + test
clean # remove __pycache__/*.pyc/*.egg-info/.pytest_cache/.ruff_cache
```
No changes to the root `Makefile` — its existing `hx-*` targets are left
exactly as-is per the "keep both independent" decision.
**Docs housekeeping** (per CLAUDE.md convention): save this plan to
`docs/plans/<timestamp>-hxprobe-usage-doc-and-makefile.md`, write a summary
to `docs/summaries/<timestamp>-hxprobe-usage-doc-and-makefile.md` after
implementation, and append a `CHANGELOG.md` entry.
## Verification
1. `cd hxprobe && make help` — lists all targets with descriptions, works
with zero dependency on the parent repo's Makefile.
2. `cd hxprobe && make run ARGS="-v https://example.com"` — same output as
`make hx-run ARGS="-v https://example.com"` from the repo root (proves
the two Makefiles agree, without one calling the other).
3. `cd hxprobe && make check` — lint + hermetic tests pass (28 tests).
4. `cd hxprobe && make test-integration` — 9 live tests pass.
5. Re-run every command block in `hxprobe/USAGE.md` and confirm the
captured output matches what's printed in the doc (structure must be
stable even if exact millisecond timings drift).
6. Confirm the root `Makefile`'s `hx-*` targets are byte-for-byte unchanged
(`git diff Makefile` shows no `hx-*` section changes from this task).

View File

@@ -0,0 +1,102 @@
# Plan: read target URLs from a file for hxprobe
## Context
`hxprobe` currently only accepts URLs as positional CLI arguments
(`hxprobe/hxprobe/cli.py:368`, `nargs="+"`). The user wants a file-based
input mode too, matching the existing convention `simple.py`/`phases.py`
already use (`python/simple.py:20-30`'s `load_sites()`: plain text, one URL
per line, `#`-comments and blank lines skipped, first whitespace-separated
token taken per line).
User-confirmed decisions:
- **Mutually exclusive** with positional URL args (either pass URLs on the
command line, or `-f FILE`, never both).
- **hxprobe only** — `latprobe` is intentionally left untouched.
- **Add example fixture files** (`hxprobe/configs/*.txt`, mirroring
`python/configs/*.txt`'s exact set: all-ok, dns-failure,
connection-refused, timeout, tls-errors, http-errors, mixed) plus one new
section in the existing `hxprobe/USAGE.md` demonstrating the flag.
## Implementation
**`hxprobe/hxprobe/cli.py`:**
- `urls` positional becomes `nargs="*"` (was `nargs="+"`) — no longer
required on its own, since `-f` is now a second valid source.
- New flag: `-f, --file PATH` — "read URLs from a file, one per line,
`#` comments allowed (mutually exclusive with positional url args)".
Placed right after the `urls` positional definition in the argparse
block, since the two are the two ways of specifying what to probe.
- New helper `_load_urls(path: str) -> list[str]`, duplicating (not
importing) `simple.py`'s `load_sites()` logic — consistent with hxprobe's
established "imports nothing outside its own directory" rule from the
standalone-extraction work.
- After `parser.parse_args()`, manual validation (mirrors the existing
`--timeout` invalid-value handling style — write to the injected
`stderr`, `return EXIT_USAGE`, rather than routing through
`argparse`'s mutually-exclusive-group machinery, which doesn't mix
cleanly with a variadic positional):
- both `ns.urls` and `ns.file` given → `parser.error(...)` (usage error,
consistent with how `_Parser.error()` already handles bad usage)
- neither given → `parser.error(...)`
- `ns.file` given but unreadable (`FileNotFoundError`/`OSError`) →
`stderr.write(...)`; `return EXIT_USAGE`
- `ns.file` given but yields zero URLs → same treatment
- otherwise `urls = _load_urls(ns.file)` or `urls = ns.urls`
**`hxprobe/tests/test_cli.py`:** new hermetic tests — successful multi-URL
run from a file, missing-file error, empty-file error, and the
both-sources-given usage error. Uses a temp file (`tempfile`), no network
needed for the parsing-error cases.
**`hxprobe/configs/*.txt`** (new directory) — same 7 fixtures as
`python/configs/`, adapted:
- `all-ok.txt`, `dns-failure.txt`, `connection-refused.txt`,
`tls-errors.txt` — same URLs, same behavior (DNS/TCP/TLS failures are
identical regardless of HTTP client sophistication); only the header
comments change (`hxprobe -f configs/<name>.txt` instead of
`python3.14 python/simple.py ...`).
- `timeout.txt` — same two targets (`10.255.255.1`, `192.0.2.1`, RFC 5737
TEST-NET-1) as the original; "expected exit code" documents normal-network
behavior (exit 4), same caveat the original file already carries about
network-dependent behavior.
- `http-errors.txt` — same 404 URLs; header comment updated to show the
demo command with `--fail` (hxprobe treats 4xx as success without
`--fail`, unlike `simple.py`, which always raises on HTTPError) —
"expected exit code" becomes 6, not `simple.py`'s blanket 1.
- `mixed.txt` — same mixed set; demo command includes `--fail`; expected
exit code recalculated as the worst code across the included classes
(dns=2, connect=3, tls=5, http=6 with `--fail`) → 6.
- Each header's "Expected exit code" will be verified by actually running
the fixture through `hxprobe -f ...` during implementation, not assumed
from the `simple.py` originals — hxprobe's worst-code-wins exit scheme
(`hxprobe/hxprobe/cli.py:455-481`) differs fundamentally from
`simple.py`'s blanket 0/1.
**`hxprobe/USAGE.md`:** new section "Reading URLs from a file (`-f`)",
placed after the "Multiple URLs" case (same family of "what to probe"
examples) — command + real captured output using `configs/all-ok.txt` or
`configs/mixed.txt`, plus a short list of the other fixture files available
and what each demonstrates.
**Docs housekeeping** (per CLAUDE.md convention): save this plan under
`docs/plans/`, write a summary under `docs/summaries/` after implementation,
append a `CHANGELOG.md` entry.
## Verification
1. `cd hxprobe && uv run python -m hxprobe -f configs/all-ok.txt` — probes
all 3 URLs, exit 0.
2. `cd hxprobe && uv run python -m hxprobe -f configs/dns-failure.txt`
exit 2; `connection-refused.txt` → exit 3; `tls-errors.txt` → exit 5;
`--fail -f configs/http-errors.txt` → exit 6; `--fail -f configs/mixed.txt`
→ exit 6 (confirms the worst-code documented in each header is accurate).
3. `uv run python -m hxprobe https://example.com -f configs/all-ok.txt`
usage error (both sources given).
4. `uv run python -m hxprobe -f /no/such/file` — usage error, clear message.
5. `cd hxprobe && make check` — new hermetic tests pass alongside the
existing 28.
6. Re-run the new `hxprobe/USAGE.md` section's command and confirm captured
output matches what's printed in the doc.
7. `grep -n "import" hxprobe/hxprobe/cli.py` — confirm no new import from
`python/simple.py` or anywhere outside `hxprobe/`.

View File

@@ -0,0 +1,174 @@
# hxprobe simplification plan
## Context
The `hxprobe` Python package works and is well-tested, but it carries several
pieces of **speculative scaffolding** — generality added for things that "might
happen later" — plus some **redundant state** and **duplicated logic**. The
user's explicit directive: keep the code simple, with no unnecessary
abstractions or scaffolding for hypothetical future features.
This plan removes that overhead without changing any observable behavior. All
existing tests in `tests/test_cli.py` and `tests/test_probe.py` must continue
to pass unchanged (they are the behavioral contract). Net effect: ~4050 fewer
lines, fewer moving parts, no new abstractions.
The trigger was a question about the `_Parser._print_message`/`print_help`/
`print_usage` overrides, whose `file=` parameter is accepted but never used —
the classic "kept for a future that never came" smell. Investigation showed the
whole `_Parser` subclass re-implements behavior the standard library already
provides.
---
## Changes
### 1. Delete the `_Parser` subclass — use stdlib stream redirection
**File:** `hxprobe/hxprobe/cli.py` (lines 6094, and the `run()` wiring ~375455)
The `_Parser` class + `_ArgExit` exception (~32 lines) exist only to (a) route
argparse's help/usage/error output to the injected `stdout`/`stderr` streams and
(b) raise instead of calling `sys.exit()`. The standard library already does
both:
- `argparse.ArgumentParser` writes help to `sys.stdout` and usage/errors to
`sys.stderr` by default, and already raises `SystemExit` (not a hard exit) —
so it is already testable.
- `contextlib.redirect_stdout(stdout)` / `redirect_stderr(stderr)` patch the
streams argparse writes to.
**Do:**
- Remove `class _ArgExit`, `class _Parser`, and all four overrides
(`_print_message`, `print_help`, `print_usage`, `error`, `exit`).
- In `run()`, build a plain `argparse.ArgumentParser(prog="hxprobe", ...)` (drop
the `out=`/`err=` kwargs).
- Wrap the parse + the two manual validations in redirection and catch
`SystemExit`:
```python
import contextlib
...
try:
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
ns = parser.parse_args(args)
if ns.urls and ns.file:
parser.error("cannot combine positional url arguments with -f/--file")
if not ns.urls and not ns.file:
parser.error("no URLs given (pass as arguments or with -f/--file)")
except SystemExit as exc:
return EXIT_OK if not exc.code else EXIT_USAGE
```
**Behavior parity (verified against tests):**
- `-h` → argparse prints help to `stdout`, raises `SystemExit(0)` → returns
`EXIT_OK`. (`test_help_shows_hxprobe_prog_name`, `test_help_lists_protocol_flags`)
- `parser.error(...)` → prints `prog: error: msg` + usage to `stderr`, raises
`SystemExit(2)` → mapped to `EXIT_USAGE`. (`test_*_is_usage_error`)
- Note: stdlib `error()` exits with code 2; we normalise any non-zero parse exit
to `EXIT_USAGE` (1) at the single catch site, replacing the per-method code
baked into the old override.
Only the parse/validate block needs redirection; the rest of `run()` keeps
writing directly to the passed `stdout`/`stderr`.
### 2. Trim `_TimingStream.get_extra_info` to the branch that's actually used
**File:** `hxprobe/hxprobe/probe.py` (lines 262275)
`server_addr` and `client_addr` are only ever queried by `httpx/_main.py` (the
`httpx` CLI command), never by the request path hxprobe drives — verified by
grepping the installed `httpcore`/`httpx`. The only branch httpcore's sync
connection path calls is `ssl_object` (and `is_readable`, which we intentionally
leave unhandled → `None`).
**Do:** reduce the method to:
```python
def get_extra_info(self, info: str):
if info == "ssl_object" and isinstance(self._sock, ssl.SSLSocket):
return self._sock
return None
```
### 3. Drop the `_load_urls` "future annotations" scaffolding
**File:** `hxprobe/hxprobe/cli.py` (lines 358369)
The `line.split()[0]` + docstring ("forward-compatible with future
'url key=value' annotations") is scaffolding for a feature that doesn't exist.
Use the stripped line directly and simplify the docstring to describe what it
actually does (one URL per line, `#` comments and blank lines skipped).
`test_reads_urls_from_file` (comments + blanks) still passes.
### 4. Remove redundant `_set` flags in `_Trace`
**File:** `hxprobe/hxprobe/probe.py` (lines 126191)
`_dns_set`, `_connect_set`, `_tls_set` duplicate information already carried by
`Phase.present` on the corresponding `self.dns` / `self.connect` / `self.tls`.
The initial `Phase()` has `present=False`, so the first-hop-wins guard is
identical.
**Do:** delete the three boolean fields; replace each guard, e.g.
`if not self._dns_set:``if not self.dns.present:` (same for connect/tls).
Keeps the redirect first-hop-wins semantics documented at probe.py:114123.
### 5. De-duplicate the failure counting logic
**File:** `hxprobe/hxprobe/cli.py`
The identical "dedupe failures into ordered (phase, message, count)" loop appears
twice: `_print_failure_summary` (lines 242258) and `_build_json_entry`
(lines 314325). Extract one helper next to the other rendering helpers:
```python
def _summarize_failures(failed: list[Result]) -> list[tuple[str, str, int]]:
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
return [(ph, msg, counts[(ph, msg)]) for ph, msg in order]
```
Rewrite both call sites to consume it (text side keeps the `1 ×` vs `N ×`
formatting; JSON side maps to `{"phase", "count", "message"}`).
---
## Explicitly NOT changing (considered, kept)
- **The custom httpcore backend** (`_TimingBackend`/`_TimingStream`/
`_TimingTransport`) — this *is* the tool's reason to exist (splitting DNS/TCP,
timing TLS). Not scaffolding.
- **`ThreadPoolExecutor` concurrency** — backs the shipped `-c/--concurrency`
and multi-URL/`-f` features. Real, not speculative.
- **Explicit per-phase dataclass fields** in `probe.py`/`aggregate.py` — a loop
would be shorter but less readable; explicit is clearer and matches the text/
JSON renderers. Leave as-is.
- **`CertInfo.sans`** — not shown in the text block but *is* emitted in JSON
verbose output; it's a real feature, not dead.
---
## Verification
1. **Unit/CLI tests (primary contract):**
```
cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q
```
All must pass with no edits to the test files. These already cover: `-h`
help→stdout + exit 0, the four usage errors→stderr + exit 1, DNS/connect/
timeout failures, `--fail`, `--json`, redirects, `--no-http2`,
`--no-follow-redirects`, `-f` file input, and verbose/cert/TLS detail.
2. **Lint:** `cd hxprobe && .venv/bin/ruff check hxprobe/`
3. **Manual smoke (help + error routing, since #1 rewrites that path):**
```
.venv/bin/python -m hxprobe -h # help on stdout, exit 0
.venv/bin/python -m hxprobe # "no URLs given" on stderr, exit 1
.venv/bin/python -m hxprobe https://example.com -v # phases + verbose block
```
4. **Per-CLAUDE.md project conventions:** after implementing, add a
`docs/summaries/<yyyy-mm-dd-hh-mm>-hxprobe-simplification.md` summary and a
`CHANGELOG.md` entry. (No `docs/usage/` change — behavior is unchanged.)

View File

@@ -0,0 +1,136 @@
# hxprobe: end-of-run summary footer for multi-URL runs
## Context
A single scalar exit code is inherently lossy when probing multiple URLs with
different failure classes: today `run()` returns the numeric max across all
URLs (e.g. DNS-fail on one URL + TLS-fail on another → exit `5`, and the DNS
failure is invisible in the code). The failures *are* all printed per-URL, but
there's no consolidated view — for many URLs you must scroll and eyeball each
block to know what happened overall.
The exit code's job is a coarse pass/fail + severity hint for scripts, and it's
a **deliberate cross-implementation contract** shared with `latprobe` and the
Go version (documented in `hxprobe/USAGE.md:475-488`, asserted by 13 tests in
`tests/test_cli.py`). So we keep the worst-code exit unchanged and instead give
humans the full picture the scalar can't: an **end-of-run summary footer** that
tallies every URL's outcome and shows how the exit code was derived.
Decisions (confirmed with the user):
- Exit code: **unchanged** (worst/highest severity across URLs).
- Summary: **text footer, multi-URL only** (`len(urls) > 1`). Single-URL text
output stays byte-identical; JSON output stays a bare array (unchanged).
## Design
### Per-URL classification (reuses existing helpers)
Each URL gets one "worst outcome code" using the *existing* mapping — no new
severity scheme:
- start at `EXIT_OK`;
- for each failed sample: `code = max(code, _phase_code(r.fail_phase))`
(`_phase_code` at `cli.py:31`);
- if `--fail`: for each succeeded sample with `status_code >= 400`:
`code = max(code, EXIT_HTTP)`.
A URL is "ok" iff its code is `EXIT_OK`, else "failed" and bucketed by its code.
A partially-failed URL (some samples ok, some failed) classifies by its worst
sample — consistent with how its own block and the global exit code already
behave.
### Footer format (text, only when `len(urls) > 1`)
Printed once after the last URL block, before `return worst`. Failure classes
use the same `✗` bullet style as `_print_failure_summary` (`cli.py:219-224`).
The final `→ exit N (label)` line explicitly ties the tally to the returned
code — directly answering "why is the exit code what it is". Example (3 URLs):
```
https://example.com (200)
... phase rows ...
http://no.such.host.invalid (FAILED)
✗ dns: [Errno 8] nodename nor servname provided
https://self-signed.badssl.com (FAILED)
✗ tls: certificate verify failed
─────────────────────────────────────────────────
Summary: 3 URLs — 1 ok, 2 failed
✗ dns : 1
✗ tls : 1
→ exit 5 (tls)
```
All-ok multi-URL run → `Summary: 3 URLs — 3 ok`, no `✗` lines, `→ exit 0 (ok)`.
## Changes
All in `hxprobe/hxprobe/cli.py` unless noted.
1. **Reverse label map** next to the exit-code constants (`cli.py:16-32`): a
small `_EXIT_LABELS: dict[int, str]` mapping `EXIT_DNS→"dns"`,
`EXIT_CONNECT→"connect"`, `EXIT_TIMEOUT→"timeout"`, `EXIT_TLS→"tls"`,
`EXIT_HTTP→"http"`, `EXIT_OK→"ok"`. (Inverse of the existing forward mapping;
kept explicit for readability, matching the codebase's style.)
2. **Refactor the accumulation loop** (`cli.py:454-462`) to compute a per-URL
code and fold it into `worst`, collecting `url_codes: list[int]` (one per
URL, index-aligned with `urls`). This also unifies the two "running max"
idioms flagged in
`docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`
(`if c > worst` vs `max(...)`) into one — a small simplification bonus.
3. **New `_print_run_summary(urls, url_codes, worst, out)` helper** (near the
other `_print_*` renderers): builds the ok/failed tally + per-class counts
from `url_codes` and writes the footer. No-op guard is the caller's
`len(urls) > 1` check.
4. **Call site** after the loop (`cli.py:470-475`): in text mode only
(`if not ns.json_out and len(urls) > 1:`), call `_print_run_summary(...)`
before `return worst`. JSON path untouched — still `json.dumps(json_items)`
as a bare array.
## Tests (`hxprobe/tests/test_cli.py`)
Add hermetic tests (reuse the existing `_OKHandler`/`_start_server`/`_invoke`
harness and `_free_port` for a refused connection):
- **multi-URL mixed** — one OK server URL + one `http://127.0.0.1:<free>`
(connection refused): assert `code == EXIT_CONNECT`, and the footer strings
are present (`"Summary: 2 URLs"`, `"1 ok"`, `"1 failed"`, `"connect : 1"`,
`"→ exit 3"`).
- **multi-URL all ok** — two OK URLs: assert `code == EXIT_OK` and
`"Summary: 2 URLs — 2 ok"` present; verify no `"✗"` in the footer region.
- **single URL has NO footer** — one OK URL: assert `"Summary:"` NOT in `out`
(locks the multi-URL-only rule).
Backward-compat guard: the footer contains no `"200"` substring, so the
existing `test_reads_urls_from_file` assertion `out.count("200") == 2` still
holds; all 13 existing exit-code tests are unaffected (worst-code unchanged).
## Docs / project conventions (per CLAUDE.md)
- **Copy this approved plan** into
`docs/plans/<yyyy-mm-dd-hh-mm>-hxprobe-run-summary-footer.md` as the first
implementation step (before code) — see the `feedback_plan_mode_docs_plans`
memory note.
- `hxprobe/USAGE.md`: add a short "Multi-URL summary footer" subsection with a
real captured example; add a sentence to the Exit-codes section noting the
footer shows the per-class breakdown behind the scalar. Exit-code table
itself is unchanged.
- `docs/usage/hxprobe.md`: one-line mention if it lists features.
- New `docs/explanations/<ts>-hxprobe-run-summary-footer.md` is optional; the
existing worst-exit-code explanation can get a short "Update:" pointer.
- `CHANGELOG.md`: new timestamped entry.
- `docs/summaries/<ts>-hxprobe-run-summary-footer.md`: implementation summary.
## Verification
1. `cd hxprobe && .venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
— all existing + new tests pass.
2. `.venv/bin/ruff check hxprobe/` — clean.
3. Manual, capturing exit codes:
```
.venv/bin/python -m hxprobe https://example.com https://example.org # footer, exit 0
.venv/bin/python -m hxprobe https://example.com http://no.such.host.invalid; echo $? # footer w/ dns:1, exit 2
.venv/bin/python -m hxprobe https://example.com # single URL: NO footer, unchanged
.venv/bin/python -m hxprobe --json https://example.com https://example.org # bare JSON array, NO footer
```

View File

@@ -0,0 +1,139 @@
# latprobe (Python) — code walkthrough
A guided tour of `python/latprobe/` for anyone about to edit the code by hand.
For CLI usage/flags, see [docs/usage/py-latprobe.md](usage/py-latprobe.md).
## Layout
```
python/
├── simple.py step 1 (standalone) — reachability + total elapsed time
├── phases.py step 2 (standalone) — per-phase timing, single file
├── sites.txt sample config for simple.py / phases.py
├── configs/ fixture configs (all-ok, dns-failure, timeout, tls-errors, ...)
├── latprobe/ step 3 — the real package
│ ├── __main__.py
│ ├── probe.py
│ ├── aggregate.py
│ ├── duration.py
│ └── cli.py
└── tests/
├── test_probe.py
├── test_cli.py
└── test_integration.py
```
`simple.py` and `phases.py` are earlier milestones, kept as reference (mirrors
the Go step-by-step convention: simple → phases → full package). They are not
imported by the package.
## The package: `python/latprobe/`
### `probe.py` — the engine
No argparse, no printing. `measure(url, opts) -> Result` does one HTTP(S)
request over raw sockets (not `requests`/`urllib`) so it can time each phase
itself:
- DNS (`socket.getaddrinfo`)
- TCP connect
- TLS handshake (`ssl.wrap_socket`, HTTPS only)
- TTFB (write request → first byte, reading until `\r\n\r\n`)
- Transfer (drain the rest of the body)
Each phase is timed with `time.perf_counter()` and wrapped by the `_p()`
helper into a `Phase(ms, present)`.
Every failure path (DNS error, connect timeout, TLS error, etc.) returns
early with a **partially-filled** `Result``fail_phase` says where it died,
`err` holds the exception, and whatever phases completed before the failure
are preserved. This is why the CLI can still print e.g. "DNS: 12ms" even if
TCP connect then failed.
`Options(timeout, verbose)` goes in, `Result` comes out. When `verbose=True`,
`Result.detail` (`VerboseDetail`) is also filled in: resolved IP, TLS
version/cipher, parsed certificate (`CertInfo`), and all response headers.
This is the only file with actual measurement logic — change *how* something
is measured here (e.g. add a redirect-follow phase, redefine what counts as
TTFB).
### `aggregate.py` — pure math, no I/O
`summarize(results: list[Result]) -> Aggregate` takes a list of successful
`Result`s (from repeated `-n` sampling of the same URL) and computes
min/avg/max per phase into `PhaseStats`. `Aggregate.status_code` uses the
*last* result's status. Small and self-contained — safe to extend (e.g. add
p50/p95) without touching anything else.
### `duration.py`
One function: `parse_duration("500ms" | "10s" | "2m") -> float` seconds, used
for `--timeout`.
### `cli.py` — everything else
Argument parsing, orchestration, and both text/JSON rendering. The file
you'll touch most for UX changes.
- `_Parser` subclasses `argparse.ArgumentParser` to redirect all output
through injected `stdout`/`stderr` streams and raise `_ArgExit` instead of
calling `sys.exit` — this is what makes `run()` fully testable without
subprocess (tests just pass in `io.StringIO()`).
- Exit codes (`EXIT_DNS=2`, `EXIT_CONNECT=3`, etc.) are commented as
mirroring the Go version. `_phase_code()` maps a `fail_phase` string to the
matching code, and `run()` tracks the *worst* code across all URLs/samples.
- `run(args, stdout, stderr) -> int` is the entry point:
1. parse args → build `Options`
2. run `_run_samples()` per URL concurrently via `ThreadPoolExecutor`
(`--concurrency`, defaulting to `min(len(urls), 8)`)
3. for each URL, pick one of four print paths in `_print_url`
(`_print_single` / `_print_aggregate` / `_print_all_failed`), based on
success/failure counts and whether `-n` > 1
4. or, if `--json`, build dict entries via `_build_json_entry` and dump
them all at the end.
- Verbose rendering (`_print_verbose_block`) is shared between the single
and aggregate text paths; JSON verbose data is built separately in
`_build_json_entry`.
### `__main__.py`
Trivial shim: `sys.exit(run(sys.argv[1:], sys.stdout, sys.stderr))`, letting
you run `python -m latprobe <url>`.
## Tests (`python/tests/`)
- `test_probe.py` — unit tests against `measure()` directly (against the
`configs/*.txt` failure-mode fixtures: DNS failure, connection refused,
TLS errors, timeouts).
- `test_cli.py` — drives `run()` with injected `io.StringIO` streams,
checking text/JSON output and exit codes.
- `test_integration.py` — end-to-end, against the `configs/*.txt` files
(`all-ok.txt`, `mixed.txt`, `http-errors.txt`, etc.).
## How it connects, end to end
```
__main__.py
→ cli.run()
→ parses flags
→ for each URL: probe.measure() (× count, across a thread pool)
→ collects Result objects
→ if count > 1: aggregate.summarize() → Aggregate
→ cli.py's _print_* / _build_json_entry render text or JSON
→ returns the worst exit code seen
```
## Where to make changes
| Change you want | File |
|-------------------------------------------|------------------|
| Timing/measurement behavior | `probe.py` |
| min/avg/max or new stats | `aggregate.py` |
| Flags, output formatting, exit-code logic | `cli.py` |
| Duration string parsing | `duration.py` |
The dataclasses (`Result`, `Phase`, `Options`, `VerboseDetail`, `Aggregate`,
`PhaseStats`) are the contracts between these files — adding a field to
`Result` in `probe.py` typically means threading it through `aggregate.py`
(if it should be averaged) and `cli.py` (if it should be printed/JSON-encoded).

View File

@@ -0,0 +1,100 @@
# Summary: `hxprobe` — httpx-based Python probe
Plan: [docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md](../plans/2026-07-01-23-47-py-hxprobe-httpx.md)
## What was built
A new sibling package, `python/hxprobe/`, alongside the existing raw-socket
`latprobe`. It uses `httpx` so the client matches Go's `http.DefaultClient`:
HTTP/2 negotiated via ALPN, redirects followed by default, connection
pooling, and default TLS verification — while still reporting the full
six-phase breakdown (DNS, TCP connect, TLS, TTFB, Transfer, Total).
Files:
- `python/pyproject.toml` — first third-party dependency in this repo
(`httpx[http2]`)
- `python/hxprobe/probe.py` — the core: `_Trace`, `_TimingStream`,
`_TimingBackend`, `_TimingTransport`, `measure()`
- `python/hxprobe/cli.py`, `__main__.py`, `__init__.py` — thin wrappers
- `python/latprobe/probe.py` (edited) — added `Options.follow_redirects`/
`Options.http2` (ignored by the socket `measure()`) and
`VerboseDetail.http_version`/`redirect_count` (always `""`/`0` there)
- `python/latprobe/cli.py` (edited) — `run()`/`_run_samples()` take an
injectable `measure_fn`, plus `prog`/`description`/`protocol_flags`
overrides, so `hxprobe.cli.run()` reuses the entire argparse/concurrency/
exit-code/rendering pipeline unchanged
- `python/tests/test_hx_probe.py` (17 tests), `test_hx_cli.py` (11 tests),
`test_integration_hx.py` (9 live tests, excluded from the default gate via
the existing `test_i*` naming convention)
- `Makefile``py-deps` (creates `python/.venv`, installs `httpx[http2]`),
`hx-run`, `hx-test-integration`; `py-test`/`py-check` now run through the
venv and include the new hermetic hxprobe tests
- `docs/usage/py-hxprobe.md` — usage doc with real captured output
- `.gitignore` — added `*.egg-info/` (editable-install artifact)
## Key design decisions
- **Instrument the transport, don't hand-roll HTTP.** Subclassed
`httpcore.NetworkBackend`/`NetworkStream` to time DNS/TCP connect/TLS at
the socket level, letting httpx own HTTP/1.1 vs HTTP/2 framing, redirects,
and keep-alive. This was the reason to use httpx at all — get the protocol
behavior of a real client while keeping latprobe's phase granularity.
- **First-hop-wins for dns/connect/tls; last-hop-wins for ttfb/transfer.**
When redirects are followed, connection-identity fields (dns/connect/tls
timing, resolved IP, TLS/cert info) reflect the *first* connection.
`wrote_request`/`first_byte` are simply overwritten on every write/read, so
they naturally end up reflecting the *last* hop — which mirrors how Go's
own unguarded `httptrace.ClientTrace` hooks behave for a followed redirect.
- **Fresh `httpx.Client` per `measure()` call, no cross-sample pooling** —
matches latprobe's per-call socket creation so every `-n` sample gets a
full phase breakdown.
- **`measure_fn` injection over subclassing/duplication** in `latprobe.cli`,
so hxprobe reuses argparse, concurrency, exit codes, and text/JSON
rendering with zero duplicated logic — the socket and httpx probes only
differ in `probe.py`.
- **New CLI flags gated behind `protocol_flags=True`** so `latprobe`'s own
`--help` output stays byte-for-byte unchanged (verified) — `--no-http2`/
`--no-follow-redirects` only appear for `hxprobe`.
## Notable finding (not part of the original plan)
While comparing `hxprobe` and `latprobe` timings against the same live host,
`hxprobe`'s TTFB was consistently ~40-50ms *lower*. Verified experimentally
(not just assumed) that this is a real effect, not noise: `latprobe`'s raw
socket never sets `TCP_NODELAY`, so its request write is subject to Nagle's
algorithm interacting with the server's delayed-ACK timer — a well-known
artifact. Forcing `TCP_NODELAY` onto `latprobe`'s socket collapsed its TTFB
to match `hxprobe`'s. `hxprobe` sets `TCP_NODELAY` (matching httpcore's own
default backend and Go's `net.Dialer`), so its TTFB numbers are the more
accurate of the two — not just different. Did not change `latprobe` itself
(out of scope for this task); documented the divergence in
`docs/usage/py-hxprobe.md`, in the CHANGELOG, and inline in
`hxprobe/probe.py`.
## Deviations from the plan
- Plan sketched `TTFB = headers-received minus end-of-TLS`; implemented as
`TTFB = first-byte minus wrote-request` instead (matches both Go and the
existing `latprobe` definition — the plan's phrasing was an approximation).
- Plan said verbose TLS/cert metadata could follow "last hop"; implemented
as first-hop-wins uniformly across dns/connect/tls/ip/tls-info for
simplicity and consistency (only `ttfb`/`transfer` are last-hop).
- Everything else (library choice, transport-instrumentation approach,
pyproject.toml, sibling-package placement, flag surface) matches the
approved plan as written.
## Verification
- `make check` (Go + Python full gate): exit 0, 83 hermetic Python tests
(72 pre-existing + 11 new hermetic CLI + hxprobe's share of the 17 probe
tests already counted), zero regressions to latprobe's original 39.
- `make hx-test-integration`: 9/9 live tests pass, including real HTTP/2
negotiation against example.com (Cloudflare) and a real http→https redirect
follow against github.com.
- Manual spot checks: `--json` schema, `--fail` exit code 6, DNS failure
(exit 2), connection-refused (exit 3), `--no-http2`/`--no-follow-redirects`
flag behavior, `latprobe --help` output diffed byte-for-byte against
pre-change output to confirm no regression.
- Caught and reverted an incidental `gofmt` whitespace diff in
`go/internal/probe/probe.go` that `make check`'s `go-fmt` step produced —
unrelated to this change, out of scope, not committed.

View File

@@ -0,0 +1,117 @@
# Summary: extract `hxprobe` into a standalone top-level project
Plan: [docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md](../plans/2026-07-02-09-32-hxprobe-standalone-project.md)
## What was built
`hxprobe` moved from `python/hxprobe/` to a new top-level `hxprobe/`
directory (sibling of `go/` and `python/`), structured as a genuinely
independent project: own `pyproject.toml`, own venv (`hxprobe/.venv`), own
`tests/` directory. It imports nothing from `python/latprobe` — verified with
`grep -rn "latprobe" hxprobe/` returning zero matches in code (a few comments
mention Go's `http.DefaultClient` for context, which is fine; no comment
references `latprobe` as a concrete path/module anymore either).
Files:
- `hxprobe/pyproject.toml` — own manifest, `httpx[http2]` dependency,
`packages = ["hxprobe"]`
- `hxprobe/hxprobe/probe.py` — moved from `python/hxprobe/probe.py`, with
`Options`, `Phase`, `Result`, `VerboseDetail`, `CertInfo` dataclasses and
`_parse_cert`/`_parse_cert_date` helpers now defined locally (copied from
`latprobe/probe.py`) instead of imported; all the httpx-instrumentation
logic (`_Trace`, `_TimingStream`, `_TimingBackend`, `_TimingTransport`,
`measure()`) is unchanged
- `hxprobe/hxprobe/aggregate.py`, `duration.py` — verbatim copies of
`latprobe`'s; their imports were already package-relative (`from .probe
import Result`), so copying required zero edits
- `hxprobe/hxprobe/cli.py` — rewritten as a full standalone CLI. Previously a
27-line wrapper delegating to `latprobe.cli.run()` via an injected
`measure_fn`; now has its own exit codes, argparse, and text/JSON
rendering (copied from the shared `latprobe/cli.py`, then stripped of the
`measure_fn`/`prog`/`description`/`protocol_flags` parameterization that
only existed to let two packages share one `run()`)
- `hxprobe/tests/{test_probe,test_cli,test_integration}.py` — moved from
`python/tests/test_hx_*.py` / `test_integration_hx.py`, `hx`-prefix
dropped, imports repointed from `latprobe.*` to `hxprobe.*`
- `python/latprobe/{cli.py,probe.py}` — reverted via `git checkout --` to
their exact pre-`hxprobe` committed state (confirmed zero diff afterward)
- `Makefile``py-deps`/`PY_VENV*`/shared `hx-run`/`hx-test-integration`
removed from the Python section; new standalone `hx-deps`/`hx-run`/
`hx-test`/`hx-test-integration`/`hx-check`/`hx-clean` targets under
`HX_DIR`/`HX_VENV*`; umbrella `test`/`check`/`clean` now run all three
(`go-*`/`py-*`/`hx-*`)
- `docs/usage/py-hxprobe.md``docs/usage/hxprobe.md` (renamed + rewritten
for the new structure)
- Old `python/hxprobe/`, `python/pyproject.toml`, `python/.venv`,
`python/latprobe_python.egg-info/` deleted
## Key design decisions
- **Move, don't rewrite, the already-debugged files.** `probe.py` and the
three test files carry real bug fixes found during the original
implementation (the `mark_dns` present-on-failure bug, verbose detail not
populated on the failure path, `Content-Length` needed once the test
fixtures switched to HTTP/1.1 keep-alive). Rewriting from scratch would
have risked reintroducing them.
- **`aggregate.py`/`duration.py` needed zero import changes** — their
existing relative imports (`from .probe import ...`) already resolve
correctly once copied into a new package with its own `probe.py`. Not
every file needed the same treatment as `probe.py`/`cli.py`.
- **`cli.py`'s starting point was the *current* shared `latprobe/cli.py`**,
not a from-scratch rewrite — it already contained 100% of the needed
logic (including the `--no-http2`/`--no-follow-redirects` flags and the
verbose Protocol row/JSON keys, both added earlier specifically for
hxprobe). The only work was deleting the generalization scaffolding
(`measure_fn`, `MeasureFn`, `prog`/`description`/`protocol_flags` params)
that existed solely to let two packages share one `run()`.
- **Reverting `latprobe` via `git checkout --` rather than hand-editing** —
confirmed first via `git log`/`git diff --stat` that `cli.py`/`probe.py`
had no changes besides the hxprobe-sharing scaffolding since the last
commit, making this a safe, exact, zero-risk revert.
- **Comments referencing `latprobe` by file path were reworded**, not just
the imports. A comment like "already used by latprobe/probe.py" becomes a
dangling reference once this directory is genuinely portable to another
repo. Reworded ~5 comments/docstrings to describe the technique generically
(e.g., "ports the getaddrinfo → connect split" → "splits DNS and TCP
connect into two timed steps") instead of naming the other project.
## Deviations from the plan
None of substance. One judgment call not spelled out in the plan: the old
`test_cli.py`'s docstring said its tests "focus on what's different... since
run() delegates almost entirely to latprobe.cli.run()" — no longer true now
that `cli.py` is a full standalone reimplementation. Rewrote that docstring
for accuracy rather than leaving a stale claim, without expanding the test
suite itself (that would be a larger, separate scope-creep beyond what was
asked — see the coverage note below).
## Notable follow-up worth flagging
`hxprobe/tests/test_cli.py` has ~11 tests versus `latprobe/tests/test_cli.py`'s
~23. That gap was fine when hxprobe's `cli.py` was a thin wrapper around
already-tested shared code; it's a real gap now that `cli.py` is an
independent ~440-line reimplementation with its own copy of every rendering
branch. Existing tests do exercise the core paths (single URL, `--fail`,
JSON, DNS/connect failures) so this isn't uncovered, but reaching
`latprobe`-level depth (multi-URL separator, sampling aggregate output, JSON
error grouping, all-failed multi-header) would be a reasonable next step if
full independent confidence in the standalone project matters. Not done here
— out of scope for a decoupling/move task.
## Verification
- `grep -rn "latprobe" hxprobe/` — zero matches in code; comment mentions
reworded to be self-contained
- `git diff --stat python/latprobe/` — empty (exact revert to last commit)
- `make hx-deps` — creates `hxprobe/.venv`, installs `httpx[http2]` from
`hxprobe/pyproject.toml`
- `make hx-run ARGS="-v http://github.com"` — same output as before the
move (HTTP/2, 1 redirect followed, IP/TLS/cert shown)
- `make hx-test` — all hermetic hxprobe tests pass standalone (own venv,
own `PYTHONPATH=hxprobe`)
- `make hx-test-integration` — live HTTP/2 + redirect tests still pass
- `make py-test``latprobe`'s own suite passes with zero setup (no
`py-deps`/venv needed), confirming the revert didn't leave residue
- `make check` (top-level) — Go + latprobe + hxprobe all green in one gate
- `python -m latprobe --help` output confirmed unchanged from before the
whole hxprobe feature existed (no leftover `--no-http2` etc.)

View File

@@ -0,0 +1,73 @@
# Summary: hxprobe toolchain modernization (uv, ruff, pytest)
Plan: [docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md](../plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md)
## What was built
Modernized `hxprobe`'s Python toolchain per the user's confirmed choices
(uv, ruff, pytest; mypy skipped). No behavior change to the probe/CLI
itself — this is purely tooling.
- `hxprobe/pyproject.toml`: added `[dependency-groups] dev = ["pytest>=8.0",
"ruff>=0.8"]` (PEP 735), `[tool.pytest.ini_options]` (testpaths, a
registered `integration` marker), `[tool.ruff]` (`target-version =
"py311"`, `line-length = 100`)
- `hxprobe/.python-version`: new, pins `3.14`
- `hxprobe/uv.lock`: new, 18 packages resolved and pinned (`httpx`,
`httpcore`, `h2`, `certifi`, `pytest`, `ruff`, and their transitive deps)
- `hxprobe/tests/test_integration.py`: added `pytestmark =
pytest.mark.integration`, replacing the `test_[!i]*.py` filename-glob
convention with a real pytest marker
- Ran `ruff check --fix` (one fix: removed unused `import sys` in `cli.py`,
inherited from the original `latprobe/cli.py`) and `ruff format .`
(6 files reformatted — collapsed the hand-aligned `=`/dict-key columns to
single-space; no semantic changes)
- `hxprobe/README.md`: new, standalone quick-start
- `Makefile`: `hx-deps`/`hx-run`/`hx-test`/`hx-test-integration` now shell
out to `uv sync`/`uv run` instead of manual venv+pip; added `hx-lint`/
`hx-fmt`; `hx-check` now bundles lint + hermetic tests (mirrors
`go-check`'s fmt+vet+test pattern)
- `docs/usage/hxprobe.md`: Setup and Makefile-targets sections updated
- `.gitignore`: added `.pytest_cache/`, `.ruff_cache/`
## Key design decisions
- **`[dependency-groups]` (PEP 735) over `[tool.uv.dev-dependencies]`** —
the standardized, current uv-recommended way to declare dev-only deps,
keeps them out of the installable package's `dependencies` list.
- **Runner swap only, no test rewrite.** `unittest.TestCase` classes,
`unittest.skipUnless` decorators, and `if __name__ == "__main__":
unittest.main()` guards are untouched — pytest is a superset runner for
unittest-style tests. Only the marker-based selection mechanism changed.
- **`hx-check` now includes lint**, not just tests — matches how
`go-check: go-fmt go-vet go-test` already bundles static checks with
tests in this repo, rather than treating lint as a separate, easy-to-skip
step.
- **Verified the `ruff format` diff was purely cosmetic** before accepting
it: reviewed the actual diff (whitespace/alignment only, no reordering or
logic changes), then confirmed with `ast.parse` on every file post-format
and a full pytest run afterward (37 tests: 28 hermetic + 9 integration,
all passing) — did not run the suite pre-format, so this confirms the
post-format state is correct rather than a strict before/after diff.
## Deviations from the plan
None. Implemented exactly as planned; the `import sys` removal and the
~6-file reformatting were both explicitly anticipated in the plan text
("Lint fixes" section) rather than being surprises.
## Verification
- `cd hxprobe && uv sync` — creates `.venv`, installs from `uv.lock`
(`httpx[http2]`, `pytest`, `ruff` + transitive deps)
- `make hx-run ARGS="-v http://github.com"` — unchanged output (HTTP/2,
1 redirect, IP/TLS/cert)
- `make hx-lint` — `ruff check .` clean (`All checks passed!`)
- `make hx-test` — `pytest tests -m "not integration"`: 28 passed, 9
deselected (matches the pre-toolchain-change hermetic test count exactly)
- `make hx-test-integration` — `pytest tests -m integration`: 9 passed, 28
deselected
- `grep -rn "latprobe" hxprobe/` — zero matches (toolchain change didn't
reintroduce coupling)
- `git diff --stat python/` — empty (hxprobe-only change, `latprobe`
untouched)

View File

@@ -0,0 +1,74 @@
# Summary: hxprobe usage reference doc + standalone Makefile
Plan: [docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md](../plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md)
## What was built
- **`hxprobe/USAGE.md`** (new): a "Runnable Usage Reference" matching the
depth/format of `python/configs/usage-latprobe.md` (concrete `sh` command
→ real captured output, brief explanatory notes, `---` section
separators). 16 cases: basic, verbose (HTTPS, plain HTTP, redirect
followed, `--no-follow-redirects`, `--no-http2`, TLS failure, DNS
failure), sampling, multi-URL, `--fail`, JSON, JSON+verbose, timeout,
exit-codes table, Makefile shortcuts (both `hxprobe/Makefile`'s own and
the parent repo's `hx-*` ones). Placed inside `hxprobe/` per the user's
explicit choice, so the doc travels with the project if it's ever
extracted to its own repo.
- **`docs/usage/hxprobe.md`** (edit): one-line pointer added at the top to
`hxprobe/USAGE.md`. No other changes.
- **`hxprobe/Makefile`** (new): standalone, `help`/`deps`/`run`/`lint`/
`fmt`/`test`/`test-integration`/`check`/`clean`, same auto-generated
`## comment` help style as the root Makefile. Deliberately independent
from the root Makefile's `hx-*` targets (neither calls into the other) —
explicit user decision over the "delegate" alternative.
## Key design decisions
- **Every output in `USAGE.md` is real, freshly captured this session** —
10 of 16 cases were run live during implementation specifically for this
doc (TLS failure, sampling ×2, multi-URL, `--fail`, JSON ×2, timeout,
basic, plain-HTTP), the rest reused real captures from earlier in the
same session (redirect-follow, `--no-follow-redirects`, `--no-http2`, DNS
failure) since the code hadn't changed since those were taken. No numbers
were fabricated or extrapolated.
- **Timeout example uses `192.0.2.1`, not `10.255.255.1`.** Tested both:
`10.255.255.1` resolves to an immediate `connect: Connection refused` in
this dev sandbox (the sandbox's network layer actively rejects the
packet rather than dropping it silently), which would misrepresent the
timeout path. `192.0.2.1` (RFC 5737 TEST-NET-1, reserved/unreachable)
reliably produces a genuine ~500ms timeout here, so that's what the doc
uses and explains.
- **No delegation between the two Makefiles**, per explicit user
instruction — both have complete, independent implementations of the
same `uv sync`/`uv run pytest`/`ruff` commands. This is deliberate
duplication: a change to one Makefile's command flags won't silently
break the other, at the cost of needing to update both if the underlying
`uv run ...` invocations ever change.
- **`hxprobe/Makefile` target names have no `hx-` prefix** (`run`, `test`,
not `hx-run`, `hx-test`) since the prefix's whole purpose — disambiguating
from `go-*`/`py-*` targets in the same file — doesn't apply once you're
already inside `hxprobe/`'s own Makefile.
## Deviations from the plan
One, driven by what the live network in this sandbox actually does: the
plan didn't anticipate the timeout target needing to change from
`10.255.255.1` (used in `latprobe`'s own timeout example) to `192.0.2.1`.
Discovered and resolved by testing both live before writing the doc, rather
than assuming `latprobe`'s example target would work identically for
hxprobe.
## Verification
- `cd hxprobe && make help` — lists all 9 targets, no dependency on the
root Makefile
- `cd hxprobe && make run ARGS="-v https://example.com"` — matches
`make hx-run ARGS="-v https://example.com"` output shape from the repo
root (both Makefiles agree independently)
- `cd hxprobe && make check` — 28 hermetic tests pass (lint + test)
- `cd hxprobe && make test-integration` — 9 live tests pass
- Every command block in `USAGE.md` was actually executed during
authoring; output pasted directly from the terminal, not hand-edited
beyond JSON float-precision rounding (documented as such in the doc)
- `git diff Makefile` — confirms the root Makefile's `hx-*` section is
unchanged by this task

View File

@@ -0,0 +1,77 @@
# Summary: hxprobe reads target URLs from a file
Plan: [docs/plans/2026-07-02-11-14-hxprobe-file-input.md](../plans/2026-07-02-11-14-hxprobe-file-input.md)
## What was built
- **`hxprobe/hxprobe/cli.py`**: new `-f`/`--file PATH` flag, mutually
exclusive with positional `url` args. `urls` positional changed
`nargs="+"``nargs="*"`. New `_load_urls()` helper (same format as
`simple.py`'s `load_sites()`: one URL per line, `#` comments, blank
lines skipped, first token per line) — reimplemented locally rather than
imported, keeping hxprobe's "no imports outside its own directory" rule
intact. Validation added right after `parser.parse_args()`, inside the
same `try/except _ArgExit` block: both-given and neither-given are usage
errors via `parser.error()`; missing/unreadable/empty file are usage
errors via direct `stderr.write()` + `return EXIT_USAGE` (mirroring the
existing `--timeout` invalid-value handling style already in the file).
- **`hxprobe/tests/test_cli.py`**: new `TestCLIFileInput` class, 5 tests —
reads URLs from a temp file successfully, missing file, empty file,
both-sources error, neither-given error.
- **`hxprobe/configs/*.txt`**: 7 new fixtures mirroring
`python/configs/`'s exact set. Each header comment states an "Expected
exit code" that was verified by actually running the fixture through
`hxprobe -f ...` during implementation (not assumed from the
`simple.py` originals, whose blanket 0/1 exit scheme is fundamentally
different from hxprobe's per-failure-class 06 worst-code-wins scheme).
- **`hxprobe/USAGE.md`**: new "Reading URLs from a file (`-f`)" section,
placed after "Multiple URLs", with real captured output for the
successful case, the mutually-exclusive error case, and one failure
fixture (`dns-failure.txt`), plus a table listing all 7 fixtures and
their expected exit codes.
## Key design decisions
- **Manual post-parse validation instead of `argparse`'s
`add_mutually_exclusive_group`** — a variadic positional (`nargs="*"`)
doesn't mix cleanly with argparse's built-in mutually-exclusive-group
machinery. Manual checks after `parser.parse_args()` (still inside the
same `try/except _ArgExit`, using `parser.error()`) give the same
usage-error behavior with full control over the message text.
- **File I/O errors don't go through `parser.error()`** — they use the
same direct `stderr.write()` + `return EXIT_USAGE` pattern already
established for `--timeout` parsing failures, since they're discovered
after parsing succeeds, not during it.
- **Every fixture's exit code was verified live, not assumed.** Two
required real judgment calls the `simple.py` originals didn't need:
`http-errors.txt` and `mixed.txt` both needed `--fail` added to their
demo command (hxprobe treats 4xx as success without it, unlike
`simple.py` which always raises on `HTTPError`) to actually demonstrate
a failure — without `--fail` both would silently show exit 0.
## Deviations from the plan
None of substance. The plan anticipated needing to verify exit codes
live rather than assume them; that anticipation paid off exactly as
expected for `http-errors.txt`/`mixed.txt` (needed `--fail` added) and
`timeout.txt` (needed `--timeout 2s` added to keep the demo fast, and a
note added about this sandbox occasionally short-circuiting one of the two
timeout targets to an immediate "connection refused" — observed directly:
in this session's test run, `10.255.255.1` genuinely timed out; in an
earlier, unrelated test earlier in the session it instead got refused
immediately. The fixture keeps both targets so at least one demonstrates
the real timeout path regardless).
## Verification
- `all-ok.txt` → exit 0, `dns-failure.txt` → 2, `connection-refused.txt`
→ 3, `tls-errors.txt` → 5, `timeout.txt` (with `--timeout 2s`) → 4,
`http-errors.txt` (with `--fail`) → 6, `mixed.txt` (with `--fail`) → 6 —
all confirmed via real `$?` checks (not through a `grep` pipe, which
masks the real exit code — caught and corrected this during testing)
- `uv run pytest tests/test_cli.py -m "not integration"` — 16/16 pass
(11 pre-existing + 5 new)
- `uv run ruff check .` / `uv run ruff format --check .` — clean
- Both-sources-given and missing-file error messages verified against the
doc's pasted output, including the `usage:` block that's actually
printed (an early draft of the doc omitted it — caught on review)

View File

@@ -0,0 +1,83 @@
# hxprobe simplification — summary
Plan: `docs/plans/2026-07-02-12-05-hxprobe-simplification.md` (five targeted
removals of speculative scaffolding / redundant state / duplicated logic, no
behavior change). Trigger: a question about a dead `file=` parameter on
`_Parser.print_help`/`print_usage` in `hxprobe/hxprobe/cli.py`, which led to a
broader pass over the whole package for the same pattern.
## What changed
1. **`hxprobe/hxprobe/cli.py` — deleted the `_Parser` subclass and `_ArgExit`
exception** (~32 lines). It re-implemented, by hand, behavior the standard
library already provides: `argparse.ArgumentParser` already writes help to
`sys.stdout` and usage/errors to `sys.stderr`, and already raises
`SystemExit` rather than hard-exiting. `run()` now builds a plain
`argparse.ArgumentParser`, wraps the parse + validation calls in
`contextlib.redirect_stdout(stdout)` / `redirect_stderr(stderr)`, and
catches `SystemExit` at a single site:
```python
except SystemExit as exc:
return EXIT_OK if not exc.code else EXIT_USAGE
```
Deviation from the original plan sketch: the plan's snippet used
`EXIT_OK if not exc.code else EXIT_USAGE`, matching what was actually
implemented (equivalent to, but slightly more defensive than, the
`exc.code == 0` check first drafted, since argparse can in principle pass
`None`).
2. **`hxprobe/hxprobe/probe.py` — trimmed `_TimingStream.get_extra_info`** to
the single branch actually consumed on the request path (`ssl_object`).
Verified by grepping the installed `httpcore`/`httpx` packages: the
`server_addr`/`client_addr` branches were only ever queried by `httpx`'s own
`_main.py` (the `httpx` CLI command), never by anything hxprobe's request
path touches.
3. **`hxprobe/hxprobe/cli.py` — simplified `_load_urls`**: dropped the
`line.split()[0]` "forward-compatible with future `url key=value`
annotations" scaffolding; a stripped line is used directly. Docstring
updated to describe only what the function does today.
4. **`hxprobe/hxprobe/probe.py` — removed `_dns_set`/`_connect_set`/
`_tls_set`** from `_Trace`. These booleans duplicated `Phase.present` on
`self.dns`/`self.connect`/`self.tls` (each starts as `Phase()`, i.e.
`present=False`). Guards rewritten from `if not self._dns_set:` to
`if not self.dns.present:` (and analogously for connect/tls) — identical
first-hop-wins semantics for redirects, one less piece of parallel state.
5. **`hxprobe/hxprobe/cli.py` — extracted `_summarize_failures`**, a shared
helper that dedupes `failed: list[Result]` into `[(phase, message, count),
...]` in first-seen order. `_print_failure_summary` (text rendering) and
`_build_json_entry` (JSON rendering) previously each carried an identical
~12-line dedup loop; both now call the helper and only differ in how they
format the tuple.
## Not changed (considered, kept — per the plan)
- The custom httpcore backend (`_TimingBackend`/`_TimingStream`/
`_TimingTransport`) — this is the tool's actual reason to exist.
- `ThreadPoolExecutor` concurrency — backs the shipped `-c/--concurrency` and
multi-URL/`-f` features.
- Explicit per-phase dataclass fields in `probe.py`/`aggregate.py`.
- `CertInfo.sans` — unused in text output but real (emitted in JSON verbose
output).
## Verification
- `hxprobe/.venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
→ **33 passed**, no test file edits.
- `hxprobe/.venv/bin/ruff check hxprobe/` → **all checks passed**.
- Manual smoke tests (`python -m hxprobe -h`, no-args, and a live
`https://example.com -v` request):
- `-h` → help text on stdout, exit `0`.
- No args → usage + `no URLs given` error on **stderr only** (confirmed via
separate stdout/stderr redirection to files — stdout was empty), exit `1`.
- Live verbose request → correct 6-phase timing, resolved IP, HTTP/2
protocol, TLS version/cipher, and certificate detail — confirms the
`get_extra_info` trim didn't break `ssl_object` retrieval and the
`_Trace` refactor didn't break phase/first-hop-wins tracking.
Net: `cli.py` and `probe.py` are shorter and carry less parallel/duplicated
state; no observable behavior changed.

View File

@@ -0,0 +1,105 @@
# hxprobe: end-of-run summary footer — summary
Plan: `docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md`.
Trigger: a follow-up to
`docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`
— does a single worst-code exit even make sense across multiple URLs with
different possible errors? The answer landed on: keep the scalar exit code
(it's a documented cross-implementation contract with `latprobe`/Go — see
`hxprobe/USAGE.md`'s Exit codes table, asserted by 13 existing tests), and
instead add the missing visibility as an end-of-run summary footer.
## What changed
All in `hxprobe/hxprobe/cli.py` unless noted, matching the plan exactly (no
deviations):
1. **`_EXIT_LABELS`** (new, next to `_PHASE_EXIT`): inverse mapping from exit
code → short label (`ok`, `dns`, `connect`, `timeout`, `tls`, `http`), used
to render the footer's `→ exit N (label)` line.
2. **Accumulation loop refactor**: previously computed a single running
`worst` directly inside the per-URL loop via two different idioms
(`if c > worst: worst = c` for network failures, `max(worst, EXIT_HTTP)`
for `--fail`). Now each URL first gets its own `code` (`EXIT_OK` folded up
via `max()` across its failed samples and, if `--fail`, its ≥400 successes),
appended to a new `url_codes: list[int]` (index-aligned with `urls`), and
*then* folded into `worst = max(worst, code)`. Unifies both idioms into one.
3. **`_print_run_summary(urls, url_codes, worst, out)`** (new helper, next to
`_print_failure_summary`): writes a separator line, an
`N URLs — X ok[, Y failed]` header, one `✗ {label:<8}: {count}` line per
non-OK class present (first-seen order, same dedup style as
`_summarize_failures`), and the closing `→ exit N (label)` line.
4. **Call site**: `elif len(urls) > 1: _print_run_summary(urls, url_codes,
worst, stdout)` — added after the per-URL loop, in the `else` branch of the
existing `if ns.json_out:` check (so it's text-mode-only), right before
`return worst`. JSON path (`json.dumps(json_items, ...)`) is completely
untouched — still a bare array, no top-level summary object, preserving the
documented "same shape as `latprobe`'s JSON" contract.
## Design decisions (confirmed with user before implementing)
- **Exit code stays a scalar** (worst/highest severity across URLs) — not
count-of-failed-URLs, not binary 0/1. Both alternatives were presented and
rejected because they'd break the documented 06 table, Go/`latprobe`
parity, and the 13 existing exit-code tests.
- **Summary is a text footer, multi-URL only** (`len(urls) > 1`) — not always
shown, and not also duplicated into JSON as a top-level object (which would
turn the JSON array into an object and break the documented array-shape
parity). Single-URL text output is untouched; JSON output is untouched.
## Tests
`hxprobe/tests/test_cli.py`: new `TestCLIRunSummary` class, 3 tests, reusing
the existing `_OKHandler`/`_start_server`/`_free_port`/`_invoke` harness:
- `test_multi_url_mixed_shows_summary` — one OK URL + one connection-refused
URL: asserts `EXIT_CONNECT`, and the footer strings (`"Summary: 2 URLs"`,
`"1 ok"`, `"1 failed"`, `"connect : 1"`, `"→ exit 3"`).
- `test_multi_url_all_ok_summary` — two OK URLs: asserts `EXIT_OK`,
`"Summary: 2 URLs — 2 ok"`, and no `"✗"` anywhere in output.
- `test_single_url_has_no_summary` — one OK URL: asserts `"Summary:"` is
absent (locks the multi-URL-only rule).
All 33 pre-existing tests pass unedited (33 + 3 new = 36 total).
## Verification
- `hxprobe/.venv/bin/python -m pytest tests/test_cli.py tests/test_probe.py -q`
→ **36 passed**.
- `hxprobe/.venv/bin/ruff check hxprobe/ tests/` → **all checks passed**.
- Manual smoke tests, all matching the plan's expected behavior exactly:
- `hxprobe https://example.com https://example.org` → footer
`Summary: 2 URLs — 2 ok` / `→ exit 0 (ok)`.
- `hxprobe https://example.com http://no.such.host.invalid` → footer
`Summary: 2 URLs — 1 ok, 1 failed` / `✗ dns : 1` / `→ exit 2 (dns)`;
process exit code confirmed `2` via `echo $?`.
- `hxprobe https://example.com` (single URL) → **no** footer, output
byte-for-byte the same shape as before this change.
- `hxprobe --json https://example.com https://example.org` → still a bare
JSON array, no summary object.
- Also captured a 3-URL mixed run (`example.com` ok, DNS failure, TLS
failure against `self-signed.badssl.com`) to confirm severity ordering in
the footer: DNS (2) and TLS (5) both counted, exit reported as `5 (tls)`
— the higher-severity class correctly wins the scalar while the footer
still shows the DNS failure that the scalar alone would hide.
## Docs updated (per CLAUDE.md conventions)
- `hxprobe/USAGE.md`: new "Multi-URL summary footer" section with a real
3-URL mixed-outcome capture; refreshed the pre-existing "Multiple URLs" and
`-f configs/all-ok.txt` / `-f configs/dns-failure.txt` examples, which were
captured before this feature existed and were now stale (missing the
footer) — replaced with fresh live captures; added a sentence to the Exit
codes section pointing at the new section.
- `docs/usage/hxprobe.md`: one-line addition to the exit-codes bullet
pointing at `hxprobe/USAGE.md`'s new section.
- `docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md`:
appended an "Update (2026-07-02)" paragraph pointing forward to this work,
per the plan's "optional — pointer instead of a new file" option.
- `CHANGELOG.md`: new entry at the top.
- This file.
No deviations from the approved plan.

232
docs/usage/hxprobe.md Normal file
View File

@@ -0,0 +1,232 @@
# `hxprobe/` — httpx-based HTTP latency probe (Go-client parity)
> For a full case-by-case runnable reference (every flag, every failure
> mode, real captured output), see [`hxprobe/USAGE.md`](../../hxprobe/USAGE.md).
## What it does
A standalone Python CLI, independent of anything else in this repo (own
`pyproject.toml`, own venv — see [Setup](#setup)). It measures per-phase HTTP
latency like [`python/latprobe`](py-latprobe.md), but is built on the
[httpx](https://www.python-httpx.org/) library instead of raw sockets, so the
client matches Go's `http.DefaultClient`: **HTTP/2 negotiated via ALPN**,
**redirects followed by default**, connection pooling, and default TLS
verification — while still reporting the same six-phase breakdown (DNS, TCP
connect, TLS, TTFB, Transfer, Total).
Python has no equivalent of Go's `net/http/httptrace`, so the phase timing is
recovered by instrumenting httpx's network backend directly
(`hxprobe/probe.py`): a custom `NetworkBackend`/`NetworkStream` pair times
DNS, TCP connect, and TLS at the socket level, while HTTP framing (HTTP/1.1
or HTTP/2), redirect-following, and keep-alive stay entirely owned by httpx.
Run it from the `hxprobe/` directory with `uv run python -m hxprobe`, or via
`make hx-run ARGS="…"` from the repo root.
## Setup
`hxprobe` is a fully self-contained project — it could be copied out of this
repo into its own tomorrow and still work, with its own `pyproject.toml`,
lockfile (`uv.lock`), and [uv](https://docs.astral.sh/uv/)-managed venv
(`hxprobe/.venv`), separate from anything under `python/`. Install once:
```sh
make hx-deps
```
This runs `uv sync` inside `hxprobe/`, creating `.venv` and installing
`httpx` (plus `h2` for HTTP/2), `pytest`, and `ruff` from `uv.lock` — pinned,
reproducible versions, not whatever the resolver happens to pick at install
time. It's a prerequisite of `make hx-run`/`make hx-test`/etc., so those
targets set it up automatically on first run — `make hx-deps` is only needed
if you want to call `uv run python -m hxprobe` directly from inside
`hxprobe/`. See [hxprobe/README.md](../../hxprobe/README.md) for the
from-inside-the-directory quick start.
## Flags / arguments
Same surface as `latprobe`, plus two opt-outs for the Go-like defaults:
```
python -m hxprobe [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, negotiated protocol/redirects, TLS info, certificate, response headers |
| `--no-http2` | off (HTTP/2 on) | Disable HTTP/2 negotiation, force HTTP/1.1 |
| `--no-follow-redirects` | off (follow on) | Report the raw redirect response instead of following it |
| `-h`, `--help` | — | Show help and exit 0 |
**Exit codes:** identical to `latprobe` (0 ok, 1 usage, 2 dns, 3 connect,
4 timeout, 5 tls, 6 http≥400 with `--fail`); the highest code across all URLs
is returned. When more than one URL is probed, a summary footer is appended
after the last URL block tallying every URL's outcome (ok / dns / connect /
timeout / tls / http) and the resulting exit code — see "Multi-URL summary
footer" in [`hxprobe/USAGE.md`](../../hxprobe/USAGE.md) for a real example.
## Examples
### Single URL — negotiates HTTP/2 by default
```sh
make hx-run ARGS="https://example.com"
```
```
https://example.com (200)
DNS lookup : 2.81 ms
TCP connect : 8.28 ms
TLS handshake : 16.13 ms
Server (TTFB) : 1.04 ms
Transfer : 0.62 ms
─────────────────────────────
Total : 42.53 ms
```
### Verbose — shows the negotiated protocol
```sh
make hx-run ARGS="-v https://example.com"
```
```
https://example.com (200)
DNS lookup : 2.93 ms
TCP connect : 8.58 ms
TLS handshake : 17.71 ms
Server (TTFB) : 4.35 ms
Transfer : 0.84 ms
─────────────────────────────
Total : 44.06 ms
IP : 104.20.23.154
Protocol : HTTP/2
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
### Redirects followed by default
```sh
make hx-run ARGS="-v http://github.com"
```
```
http://github.com (200)
DNS lookup : 14.01 ms
TCP connect : 19.42 ms
TLS handshake : 22.72 ms
Server (TTFB) : 0.01 ms
Transfer : 65.66 ms
─────────────────────────────
Total : 196.72 ms
IP : 140.82.121.4
Protocol : HTTP/2 (1 redirect)
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
Cert : CN=github.com valid until 2026-08-02 Sectigo Limited
```
Pass `--no-follow-redirects` to get the raw redirect response instead:
```sh
make hx-run ARGS="--no-follow-redirects http://github.com"
```
```
http://github.com (301)
DNS lookup : 2.85 ms
TCP connect : 24.16 ms
Server (TTFB) : 24.50 ms
Transfer : 0.59 ms
─────────────────────────────
Total : 52.58 ms
```
### Forcing HTTP/1.1
```sh
make hx-run ARGS="-v --no-http2 https://example.com"
```
```
https://example.com (200)
DNS lookup : 2.31 ms
TCP connect : 8.75 ms
TLS handshake : 13.61 ms
Server (TTFB) : 13.51 ms
Transfer : 0.44 ms
─────────────────────────────
Total : 39.07 ms
IP : 104.20.23.154
Protocol : HTTP/1.1
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
### Sampling, JSON, `--fail`, DNS failure
Same shape as `latprobe`'s own examples — see
[py-latprobe.md](py-latprobe.md#examples) for `-n`, `--json`, multi-URL,
`--fail`, and timeout output. hxprobe's JSON verbose object adds two keys:
```json
"verbose": {
"ip": "104.20.23.154",
"http_version": "HTTP/2",
"redirect_count": 1,
"tls_version": "TLSv1.3",
...
}
```
`http_version`/`redirect_count` are omitted from the text view when
`http_version` is unknown (only happens on failures before headers arrived).
## HTTP/2, redirects, and a TTFB accuracy note
**HTTP/2 and redirects** are the headline reasons this project exists
alongside the raw-socket `latprobe` — see [py-latprobe.md](py-latprobe.md) for
what that implementation does instead (always HTTP/1.1, never follows
redirects).
**A TTFB finding worth knowing, if you've compared numbers against another
raw-socket HTTP client:** during development, this implementation's TTFB
came in consistently ~40-50ms *lower* than a comparable raw-socket
implementation that didn't set `TCP_NODELAY` on its connect socket. That gap
wasn't noise — a socket that never sets `TCP_NODELAY` is subject to Nagle's
algorithm interacting with the server's delayed-ACK timer, a well-known
~40ms artifact. This implementation sets `TCP_NODELAY` on every connection
(matching both httpcore's own default backend and Go's `net.Dialer`),
avoiding that penalty. Forcing `TCP_NODELAY` onto the other socket
experimentally collapsed its TTFB to match this one's — confirming the
cause. If you're comparing hxprobe's numbers against some other HTTP/1.1
client that doesn't set `TCP_NODELAY`, expect this implementation's TTFB to
read lower, and correctly so.
**Redirect semantics for `dns`/`connect`/`tls` vs `ttfb`/`transfer`:** when
redirects are followed, `dns`/`connect`/`tls` (and the verbose IP/TLS/cert
fields) reflect the **first** connection only — "cost of reaching the origin
server." `ttfb`/`transfer` reflect the **last** hop, because each write/read
call overwrites them — which mirrors how Go's own `httptrace.ClientTrace`
hooks behave for a followed redirect (they aren't guarded either, so the last
hop wins there too).
## Limitations
- Always `GET`, no custom headers/body/auth — matches Go's `http.DefaultClient`.
- `Options.timeout` applies uniformly to connect/read/write/pool phases (a
single value); it is not split into separate per-phase budgets.
- No UNIX socket support.
- HTTP/2 requires TLS (`https://`) in practice — cleartext `h2c` is not
attempted for `http://` URLs (matches nearly every real HTTP/2 deployment).
## Makefile targets
```sh
make hx-deps # one-time: uv sync (venv + lockfile install)
make hx-run ARGS="-v -n 3 https://example.com" # run it
make hx-test # hermetic unit tests (pytest -m "not integration")
make hx-test-integration # live tests: real HTTP/2 negotiation, redirects
make hx-lint # ruff check
make hx-fmt # ruff format
make hx-check # lint + hermetic tests (the pre-commit-style gate)
```
`hx-check` (lint + hermetic tests) also runs as part of the repo-root
`make check`; `hx-test` runs as part of `make test`.

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

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

294
docs/usage/py-latprobe.md Normal file
View File

@@ -0,0 +1,294 @@
# `latprobe/` — Full Python Port
## What it does
A packaged Python CLI that mirrors the Go `latprobe` tool: per-phase HTTP
latency measurement (DNS, TCP connect, TLS, TTFB, Transfer, Total) with
configurable sampling, cross-URL concurrency, JSON output, and distinct exit
codes per failure class.
Run it from the `python/` directory with `python -m latprobe`.
Phases measured:
| Phase | What is timed |
|-------|---------------|
| DNS lookup | `socket.getaddrinfo()` — hostname resolution |
| TCP connect | `sock.connect()` — SYN to connection established |
| TLS handshake | `ssl.wrap_socket()` — full handshake (HTTPS only) |
| Server (TTFB) | `sendall()` return → first `recv()` byte |
| Transfer | First byte → EOF — body download time |
| Total | DNS start → body EOF — wall-clock end-to-end |
## Flags / arguments
```
python -m latprobe [flags] <url> [url ...]
```
| Flag | Default | Description |
|------|---------|-------------|
| `url …` (positional) | required | One or more URLs to probe |
| `-n N`, `--count N` | `1` | Number of requests per URL |
| `-c N`, `--concurrency N` | `0` (auto) | Max parallel URLs; `0` = `min(len(urls), 8)` |
| `--timeout DURATION` | `10s` | Per-request timeout; supports `ms`, `s`, `m`, or bare seconds |
| `--fail` | off | Exit non-zero when any HTTP status ≥ 400 |
| `--json` | off | Output as JSON array instead of text |
| `-v`, `--verbose` | off | Show resolved IP, TLS version/cipher, certificate details, and response headers |
| `-h`, `--help` | — | Show help and exit 0 |
**Exit codes:**
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded |
| 1 | Usage / argument error |
| 2 | DNS failure |
| 3 | TCP connect failure |
| 4 | Timeout |
| 5 | TLS error |
| 6 | HTTP status ≥ 400 (`--fail` only) |
The highest exit code across all URLs is used as the process exit.
## Examples
### Single URL
```sh
python -m latprobe https://example.com
```
```
https://example.com (200)
DNS lookup : 18.87 ms
TCP connect : 9.76 ms
TLS handshake : 14.71 ms
Server (TTFB) : 67.07 ms
Transfer : 0.14 ms
─────────────────────────────
Total : 118.39 ms
```
### Sampling (`-n`) — shows min/avg/max table
```sh
python -m latprobe -n 5 https://example.com
```
```
https://example.com (200, 5 samples)
min avg max
DNS lookup : 1.80 ms 3.14 ms 5.00 ms
TCP connect : 9.50 ms 10.25 ms 11.40 ms
TLS handshake : 13.80 ms 15.20 ms 18.90 ms
Server (TTFB) : 62.00 ms 66.50 ms 71.30 ms
Transfer : 0.10 ms 0.25 ms 0.40 ms
─────────────────────────────────────────────────
Total : 97.10 ms 101.20 ms 109.80 ms
```
### Multiple URLs (probed in parallel)
```sh
python -m latprobe https://example.com https://www.google.com
```
Output for each URL is separated by a blank line. Exit code = worst across all.
### JSON output
```sh
python -m latprobe --json -n 3 https://example.com
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 3,
"failed": 0,
"phases": {
"dns": {"min_ms": 1.80, "avg_ms": 3.14, "max_ms": 5.00},
"connect": {"min_ms": 9.50, "avg_ms": 10.25, "max_ms": 11.40},
"tls": {"min_ms": 13.80, "avg_ms": 15.20, "max_ms": 18.90},
"ttfb": {"min_ms": 62.00, "avg_ms": 66.50, "max_ms": 71.30},
"transfer": {"min_ms": 0.10, "avg_ms": 0.25, "max_ms": 0.40},
"total": {"min_ms": 97.10, "avg_ms": 101.20, "max_ms": 109.80}
}
}
]
```
`phases` is omitted when all samples failed. `tls` key is omitted for `http://`
URLs. `errors` is included (and `phases` omitted) when some samples fail.
### Verbose mode (`-v` / `--verbose`)
Shows the resolved IP address, TLS version/cipher/bits, certificate details
(CN, expiry, issuer), and useful response headers. Appended to the standard
timing block.
```sh
python -m latprobe --verbose https://example.com
```
```
https://example.com (200)
DNS lookup : 16.47 ms
TCP connect : 9.76 ms
TLS handshake : 13.09 ms
Server (TTFB) : 60.31 ms
Transfer : 0.20 ms
─────────────────────────────
Total : 112.76 ms
IP : 104.20.23.154
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
Server : cloudflare
Content-Type : text/html
```
For **TLS failures**, the verbose block still shows the IP (DNS + TCP
succeeded) so you can tell which server you actually reached:
```sh
python -m latprobe --verbose https://expired.badssl.com/
```
```
https://expired.badssl.com/ (FAILED)
DNS lookup : 30.98 ms
TCP connect : 126.58 ms
TLS handshake : 293.58 ms
─────────────────────────────
Total : 463.37 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
IP : 104.154.89.105
```
For **DNS failures**, the verbose block is empty (IP unknown), so it is not
printed.
Combined with `--json`, verbose detail appears in a `"verbose"` object:
```sh
python -m latprobe --verbose --json https://example.com
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": { ... },
"verbose": {
"ip": "104.20.23.154",
"tls_version": "TLSv1.3",
"tls_cipher": "TLS_AES_256_GCM_SHA384",
"tls_bits": 256,
"cert": {
"cn": "example.com",
"sans": ["example.com", "*.example.com"],
"expiry": "2026-08-29",
"issuer_cn": "SSL Corporation",
"verified": true
},
"headers": {
"Content-Type": "text/html",
"Server": "cloudflare",
...
}
}
}
]
```
The `"verbose"` key is omitted when `--verbose` is not set. The `"cert"` key
is omitted for `http://` URLs and when TLS fails (Python's stdlib does not
expose parsed cert data from a failed handshake). `"headers"` contains **all**
parsed response headers (the text block shows a curated priority list).
### DNS failure
```sh
python -m latprobe http://no.such.host.invalid
echo "exit: $?"
```
```
http://no.such.host.invalid (FAILED)
Total : 0.65 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
exit: 2
```
### `--fail` flag (exit non-zero on HTTP 4xx/5xx)
```sh
python -m latprobe --fail https://www.google.com/this-page-does-not-exist-at-all
echo "exit: $?"
```
```
https://www.google.com/this-page-does-not-exist-at-all (404 ✗)
DNS lookup : 3.10 ms
TCP connect : 9.60 ms
TLS handshake : 14.20 ms
Server (TTFB) : 118.50 ms
Transfer : 0.12 ms
─────────────────────────────
Total : 145.50 ms
exit: 6
```
### Mixed — some URLs succeed, some fail
```sh
python -m latprobe https://example.com http://no.such.host.invalid
echo "exit: $?"
```
```
https://example.com (200)
...
Total : 118.39 ms
http://no.such.host.invalid (FAILED)
Total : 0.65 ms
✗ dns: ...
exit: 2
```
### Timeout
```sh
python -m latprobe --timeout 500ms http://10.255.255.1/
echo "exit: $?"
```
```
http://10.255.255.1/ (FAILED)
DNS lookup : 0.20 ms
TCP connect : 500.18 ms
─────────────────────────────
Total : 500.40 ms
✗ timeout: timed out
exit: 4
```
## Makefile targets
```sh
make py-run ARGS="-n 3 https://example.com" # run the package
make py-test # run test suite
make py-check # alias for py-test
```
## Limitations
- **DNS timeout is OS-controlled.** Python's `socket.getaddrinfo` does not
accept a timeout parameter. The `--timeout` flag applies to TCP connect and
subsequent phases. DNS failures from an unreachable or NXDOMAIN host still
happen quickly in practice.
- **HTTP 1.1 + `Connection: close` only.** No keep-alive, HTTP/2, auth, custom
headers, or redirect following. HTTP 3xx is shown with its raw status code.
- **Body fully drained.** Transfer time is real download time; large bodies
affect the Transfer and Total phases.
## Comparison with `simple.py` and `phases.py`
| | `simple.py` | `phases.py` | `latprobe/` |
|--|-------------|-------------|-------------|
| Phases | total only | all phases | all phases |
| Sampling | no | no | `-n` flag |
| Concurrency | no | no | `-c` flag |
| JSON | no | no | `--json` flag |
| `--fail` | implicit (urlopen raises on 4xx) | no | `--fail` flag |
| Exit codes | 0 or 1 | 0 or 1 | 06 per failure class |
| Config file | yes | yes | no (URL args only) |

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

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

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

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

View File

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

View File

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

View File

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

105
go/cli_test.go Normal file
View File

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

View File

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

View File

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

371
go/run_test.go Normal file
View File

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

1
hxprobe/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

55
hxprobe/Makefile Normal file
View File

@@ -0,0 +1,55 @@
# hxprobe — standalone Makefile. Requires uv (https://docs.astral.sh/uv/).
#
# This project is fully self-contained (own pyproject.toml, own uv.lock) and
# this Makefile works the same whether run from inside a parent monorepo or
# after `cp -r`'ing hxprobe/ into its own repo. It does not depend on, and is
# not depended on by, the parent repo's root Makefile — the two are kept
# independent on purpose so a change to one can't break the other.
PYTHON ?= python3.14
ARGS ?=
.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
# ── targets ───────────────────────────────────────────────────────────────────
.PHONY: deps
deps: ## sync the venv from uv.lock; idempotent
uv sync
.PHONY: run
run: deps ## run hxprobe (pass flags via ARGS="…")
uv run $(PYTHON) -m hxprobe $(ARGS)
.PHONY: lint
lint: deps ## lint with ruff
uv run ruff check .
.PHONY: fmt
fmt: deps ## format with ruff
uv run ruff format .
.PHONY: test
test: deps ## run hermetic unit tests
uv run pytest tests -m "not integration" -v $(ARGS)
.PHONY: test-integration
test-integration: deps ## run integration tests against live internet services (HTTP/2, redirects)
uv run pytest tests -m integration -v $(ARGS)
.PHONY: check
check: lint test ## run the full gate: lint + hermetic tests
.PHONY: clean
clean: ## remove bytecode, caches, and egg-info
@find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null; \
find . -name '*.pyc' -delete 2>/dev/null; \
rm -rf *.egg-info .pytest_cache .ruff_cache; true

38
hxprobe/README.md Normal file
View File

@@ -0,0 +1,38 @@
# hxprobe
Per-phase HTTP latency probe built on [httpx](https://www.python-httpx.org/),
matching Go's `http.DefaultClient`: HTTP/2 negotiated via ALPN, redirects
followed by default, connection pooling, and default TLS verification —
while reporting a six-phase timing breakdown (DNS, TCP connect, TLS, TTFB,
Transfer, Total).
This is a fully self-contained project: no imports outside this directory,
own `pyproject.toml`, own lockfile (`uv.lock`). It can be copied out of its
parent repo and used standalone.
See [docs/usage/hxprobe.md](../docs/usage/hxprobe.md) in the parent repo for
full flag reference, example output, and design notes (redirect semantics,
a TTFB-accuracy finding worth knowing about).
## Quick start
Requires [uv](https://docs.astral.sh/uv/).
```sh
uv sync # create .venv, install deps + lockfile
uv run python -m hxprobe https://example.com
uv run python -m hxprobe -v http://github.com # verbose: shows HTTP/2, redirects, TLS, cert
```
## Development
```sh
uv run pytest tests -m "not integration" -v # hermetic unit tests
uv run pytest tests -m integration -v # live tests (hits the real internet)
uv run ruff check . # lint
uv run ruff format . # format
```
From the parent repo's root, the equivalent Makefile targets are
`make hx-run`, `make hx-test`, `make hx-test-integration`, `make hx-lint`,
`make hx-fmt`, and `make hx-check` (lint + hermetic tests).

703
hxprobe/USAGE.md Normal file
View File

@@ -0,0 +1,703 @@
# `hxprobe` — Runnable Usage Reference
All commands below assume you're inside this directory (`cd hxprobe`) with
dependencies installed (`make deps` or `uv sync`). Timings will differ on
your machine and network; the output structure is stable. Every example
below was run against the live internet.
---
## Basic — single URL
```sh
make run ARGS="https://example.com"
# or directly:
uv run python -m hxprobe https://example.com
```
```
https://example.com (200)
DNS lookup : 17.98 ms
TCP connect : 8.18 ms
TLS handshake : 15.41 ms
Server (TTFB) : 3.38 ms
Transfer : 7.99 ms
─────────────────────────────
Total : 61.93 ms
```
---
## Verbose mode — IP, protocol, TLS, certificate
```sh
uv run python -m hxprobe --verbose https://example.com
```
```
https://example.com (200)
DNS lookup : 2.33 ms
TCP connect : 14.73 ms
TLS handshake : 15.22 ms
Server (TTFB) : 5.63 ms
Transfer : 0.57 ms
─────────────────────────────
Total : 49.16 ms
IP : 104.20.23.154
Protocol : HTTP/2
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
`Protocol` is the one row `latprobe` (the raw-socket sibling implementation)
never prints — it's the ALPN-negotiated HTTP version, only meaningful for a
client that can actually speak more than one.
---
## Verbose — plain HTTP (no TLS block)
```sh
uv run python -m hxprobe --verbose http://example.com
```
```
http://example.com (200)
DNS lookup : 2.15 ms
TCP connect : 8.52 ms
Server (TTFB) : 16.33 ms
Transfer : 0.42 ms
─────────────────────────────
Total : 27.80 ms
IP : 104.20.23.154
Protocol : HTTP/1.1
```
No `TLS` or `Cert` rows for `http://` URLs. `Protocol` still shows —
HTTP/2 is not attempted over cleartext (see Limitations in
[`docs/usage/hxprobe.md`](../docs/usage/hxprobe.md)), so this is always
`HTTP/1.1`.
---
## Verbose — redirect followed by default
```sh
uv run python -m hxprobe --verbose http://github.com
```
```
http://github.com (200)
DNS lookup : 14.34 ms
TCP connect : 20.88 ms
TLS handshake : 25.91 ms
Server (TTFB) : 3.44 ms
Transfer : 63.33 ms
─────────────────────────────
Total : 197.21 ms
IP : 140.82.121.3
Protocol : HTTP/2 (1 redirect)
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
Cert : CN=github.com valid until 2026-08-02 Sectigo Limited
```
`http://github.com` 301-redirects to `https://github.com`; hxprobe follows
it by default (matching Go's `http.DefaultClient`) and reports the final
response. `latprobe` has no equivalent — it would print the bare `301` and
stop. `DNS`/`TCP connect`/`TLS` are timed from the *first* connection only;
`Server (TTFB)`/`Transfer` reflect the final hop (see "Divergence from
`latprobe`" in [`docs/usage/hxprobe.md`](../docs/usage/hxprobe.md) for why).
---
## `--no-follow-redirects` — report the raw redirect instead
```sh
uv run python -m hxprobe --no-follow-redirects http://github.com
```
```
http://github.com (301)
DNS lookup : 2.85 ms
TCP connect : 24.16 ms
Server (TTFB) : 24.50 ms
Transfer : 0.59 ms
─────────────────────────────
Total : 52.58 ms
```
Same shape `latprobe` would show for any redirect — hxprobe just makes it
opt-in rather than the default.
---
## `--no-http2` — force HTTP/1.1
```sh
uv run python -m hxprobe --verbose --no-http2 https://example.com
```
```
https://example.com (200)
DNS lookup : 2.31 ms
TCP connect : 8.75 ms
TLS handshake : 13.61 ms
Server (TTFB) : 13.51 ms
Transfer : 0.44 ms
─────────────────────────────
Total : 39.07 ms
IP : 104.20.23.154
Protocol : HTTP/1.1
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
Useful for isolating whether a latency difference is due to protocol version
rather than network conditions.
---
## Verbose — TLS failure (certificate expired)
```sh
uv run python -m hxprobe --verbose https://expired.badssl.com/
echo "exit: $?"
```
```
https://expired.badssl.com/ (FAILED)
DNS lookup : 33.34 ms
TCP connect : 1129.53 ms
TLS handshake : 318.54 ms
─────────────────────────────
Total : 1484.12 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1081)
IP : 104.154.89.105
exit: 5
```
The IP is shown even on TLS failure (DNS and TCP both succeeded). badssl.com
is a shared, often-loaded demo host — `TCP connect` here is unusually slow;
that's the host, not hxprobe.
---
## Verbose — DNS failure (no IP to show)
```sh
uv run python -m hxprobe --verbose http://no.such.host.invalid
echo "exit: $?"
```
```
http://no.such.host.invalid (FAILED)
Total : 13.22 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
exit: 2
```
The verbose block is empty (IP never resolved), so it's suppressed entirely
— same behavior as `latprobe`.
---
## Sampling (`-n`) — min / avg / max table
```sh
uv run python -m hxprobe -n 3 https://example.com
```
```
https://example.com (200, 3 samples)
min avg max
DNS lookup : 1.23 ms 2.29 ms 3.02 ms
TCP connect : 8.05 ms 8.73 ms 9.81 ms
TLS handshake : 12.99 ms 15.65 ms 17.48 ms
Server (TTFB) : 0.01 ms 7.23 ms 11.95 ms
Transfer : 0.63 ms 2.18 ms 5.15 ms
─────────────────────────────────────────────────
Total : 38.56 ms 49.16 ms 54.70 ms
```
With `--verbose`, the block is appended below the table using the last
successful sample's detail:
```sh
uv run python -m hxprobe --verbose -n 3 https://example.com
```
```
https://example.com (200, 3 samples)
min avg max
...
Total : 39.95 ms 42.55 ms 44.66 ms
IP : 104.20.23.154
Protocol : HTTP/2
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
```
---
## Multiple URLs (probed in parallel)
```sh
uv run python -m hxprobe https://example.com https://www.iana.org
```
```
https://example.com (200)
DNS lookup : 3.96 ms
TCP connect : 13.13 ms
TLS handshake : 15.31 ms
Server (TTFB) : 0.01 ms
Transfer : 7.20 ms
─────────────────────────────
Total : 50.16 ms
https://www.iana.org (200)
DNS lookup : 26.67 ms
TCP connect : 9.05 ms
TLS handshake : 14.73 ms
Server (TTFB) : 3.37 ms
Transfer : 0.63 ms
─────────────────────────────
Total : 70.42 ms
─────────────────────────────────────────────────
Summary: 2 URLs — 2 ok
→ exit 0 (ok)
```
Output for each URL is separated by a blank line. Exit code is the worst
across all — see "Multi-URL summary footer" below for how that scalar breaks
down when URLs have different outcomes.
---
## Multi-URL summary footer
Whenever more than one URL is probed (positional args or `-f`), a footer is
appended after the last URL block: a per-outcome tally plus the exit code it
produced. Single-URL runs never show it — text output is otherwise unchanged.
```sh
uv run python -m hxprobe https://example.com http://no.such.host.invalid https://self-signed.badssl.com
echo "exit: $?"
```
```
https://example.com (200)
DNS lookup : 4.65 ms
TCP connect : 9.56 ms
TLS handshake : 14.90 ms
Server (TTFB) : 0.01 ms
Transfer : 8.84 ms
─────────────────────────────
Total : 47.28 ms
http://no.such.host.invalid (FAILED)
Total : 4.54 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
https://self-signed.badssl.com (FAILED)
DNS lookup : 24.75 ms
TCP connect : 1126.05 ms
TLS handshake : 327.05 ms
─────────────────────────────
Total : 1478.35 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1081)
─────────────────────────────────────────────────
Summary: 3 URLs — 1 ok, 2 failed
✗ dns : 1
✗ tls : 1
→ exit 5 (tls)
exit: 5
```
Each URL is classified by its own worst sample (same `_phase_code` mapping the
overall exit code uses), then tallied. The `→ exit N (label)` line ties the
tally directly to the process exit code, since the code alone can't show
*which* URLs failed *how* — here a DNS failure (would be exit `2` alone) is
outranked by the TLS failure (`5`), and the footer is what makes that visible.
JSON output (`--json`) is unaffected — it stays a bare array; per-URL failures
are already in each entry's `errors[]`.
---
## Reading URLs from a file (`-f`)
```sh
uv run python -m hxprobe -f configs/all-ok.txt
```
```
https://example.com (200)
DNS lookup : 3.60 ms
TCP connect : 17.61 ms
TLS handshake : 16.39 ms
Server (TTFB) : 4.06 ms
Transfer : 0.60 ms
─────────────────────────────
Total : 56.24 ms
https://www.google.com (200)
DNS lookup : 4.18 ms
TCP connect : 17.23 ms
TLS handshake : 30.62 ms
Server (TTFB) : 2.68 ms
Transfer : 69.83 ms
─────────────────────────────
Total : 142.95 ms
https://www.iana.org (200)
DNS lookup : 4.25 ms
TCP connect : 17.18 ms
TLS handshake : 18.87 ms
Server (TTFB) : 8.13 ms
Transfer : 0.50 ms
─────────────────────────────
Total : 60.76 ms
─────────────────────────────────────────────────
Summary: 3 URLs — 3 ok
→ exit 0 (ok)
```
`-f`/`--file` reads a plain-text URL list — one per line, blank lines and
`#`-prefixed comment lines skipped — the same format `simple.py`/`phases.py`
use elsewhere in this repo. It's **mutually exclusive** with positional URL
arguments: pass one or the other, not both.
```sh
uv run python -m hxprobe -f configs/all-ok.txt https://extra.example.com
```
```
usage: hxprobe [-h] [-f PATH] [-n N] [-c N] [--timeout DURATION] [--fail]
[--json] [-v] [--no-http2] [--no-follow-redirects]
[url ...]
hxprobe: error: cannot combine positional url arguments with -f/--file
```
`configs/` ships one fixture per failure class, each self-documenting its
expected exit code in a header comment (verified by actually running it,
not just asserted):
| File | Demonstrates | Exit code |
|------|---------------|-----------|
| `configs/all-ok.txt` | Everything succeeds | 0 |
| `configs/dns-failure.txt` | Unresolvable hostnames | 2 |
| `configs/connection-refused.txt` | Loopback ports with no listener | 3 |
| `configs/timeout.txt` | Non-routable IPs (RFC 5737 TEST-NET-1) | 4 |
| `configs/tls-errors.txt` | badssl.com cert failures | 5 |
| `configs/http-errors.txt` | 404s, run with `--fail` | 6 |
| `configs/mixed.txt` | One of each class, run with `--fail` | 6 |
| `configs/large-mixed.txt` | 30 URLs, ≥10 failing across all 5 classes — good demo of the multi-URL summary footer | 6 |
```sh
uv run python -m hxprobe -f configs/dns-failure.txt
echo "exit: $?"
```
```
https://this-host-does-not-exist.invalid (FAILED)
Total : 16.91 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://no.such.host.invalid (FAILED)
Total : 4.56 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
─────────────────────────────────────────────────
Summary: 2 URLs — 0 ok, 2 failed
✗ dns : 2
→ exit 2 (dns)
exit: 2
```
`configs/large-mixed.txt` scales this up to 30 URLs so the summary footer has
something substantial to tally — 20 real sites expected to succeed plus 10
deliberately broken across all five failure classes at once:
```sh
uv run python -m hxprobe --fail --timeout 2s -f configs/large-mixed.txt
echo "exit: $?"
```
```
https://example.com (200)
DNS lookup : 3.96 ms
TCP connect : 10.24 ms
TLS handshake : 14.76 ms
Server (TTFB) : 4.14 ms
Transfer : 3.71 ms
─────────────────────────────
Total : 49.54 ms
... 18 more successful URLs ...
https://stackoverflow.com (200)
DNS lookup : 12.99 ms
TCP connect : 9.00 ms
TLS handshake : 16.58 ms
Server (TTFB) : 197.72 ms
Transfer : 289.92 ms
─────────────────────────────
Total : 684.24 ms
https://this-host-does-not-exist.invalid (FAILED)
Total : 1.25 ms
✗ dns: [Errno 8] nodename nor servname provided, or not known
http://no.such.host.invalid (FAILED)
Total : 0.92 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.09 ms
─────────────────────────────
Total : 0.26 ms
✗ connect: [Errno 61] Connection refused
http://127.0.0.1:19999 (FAILED)
DNS lookup : 0.01 ms
TCP connect : 0.09 ms
─────────────────────────────
Total : 0.23 ms
✗ connect: [Errno 61] Connection refused
http://10.255.255.1/ (FAILED)
DNS lookup : 0.03 ms
TCP connect : 16.50 ms
─────────────────────────────
Total : 16.64 ms
✗ connect: [Errno 61] Connection refused
http://192.0.2.1/ (FAILED)
DNS lookup : 0.01 ms
TCP connect : 2001.27 ms
─────────────────────────────
Total : 2001.56 ms
✗ timeout: timed out
https://expired.badssl.com/ (FAILED)
DNS lookup : 16.37 ms
TCP connect : 1125.42 ms
TLS handshake : 329.12 ms
─────────────────────────────
Total : 1471.48 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1081)
https://self-signed.badssl.com/ (FAILED)
DNS lookup : 13.27 ms
TCP connect : 1127.27 ms
TLS handshake : 325.84 ms
─────────────────────────────
Total : 1466.90 ms
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1081)
https://www.google.com/this-page-does-not-exist-at-all-1234567890 (404 ✗)
DNS lookup : 0.96 ms
TCP connect : 7.61 ms
TLS handshake : 25.25 ms
Server (TTFB) : 0.01 ms
Transfer : 99.63 ms
─────────────────────────────
Total : 142.38 ms
https://github.com/this-repo-does-not-exist-abcxyz123/no-way (404 ✗)
DNS lookup : 1.23 ms
TCP connect : 19.57 ms
TLS handshake : 22.02 ms
Server (TTFB) : 209.47 ms
Transfer : 67.20 ms
─────────────────────────────
Total : 339.82 ms
─────────────────────────────────────────────────
Summary: 30 URLs — 20 ok, 10 failed
✗ dns : 2
✗ connect : 3
✗ timeout : 1
✗ tls : 2
✗ http : 2
→ exit 6 (http)
exit: 6
```
The individual URL blocks above are unchanged in shape from every other
example on this page — this is purely a matter of scale. The summary footer
is where scale actually pays off: instead of scanning 30 blocks to count
outcomes, `Summary: 30 URLs — 20 ok, 10 failed` plus the per-class breakdown
answers "what happened" at a glance, and `→ exit 6 (http)` explains *why*
that particular exit code came back (the two 404s under `--fail`, at
`EXIT_HTTP = 6`, outrank every other class present).
---
## `--fail` flag — exit non-zero on HTTP 4xx/5xx
```sh
uv run python -m hxprobe --fail https://www.google.com/this-page-does-not-exist-at-all-1234567890
echo "exit: $?"
```
```
https://www.google.com/this-page-does-not-exist-at-all-1234567890 (404 ✗)
DNS lookup : 2.96 ms
TCP connect : 11.57 ms
TLS handshake : 24.95 ms
Server (TTFB) : 0.01 ms
Transfer : 104.22 ms
─────────────────────────────
Total : 152.77 ms
exit: 6
```
Without `--fail`, 4xx/5xx responses are shown normally and the exit code is `0`.
---
## JSON output
```sh
uv run python -m hxprobe --json https://example.com | python3.14 -m json.tool
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": {
"dns": {"min_ms": 2.80, "avg_ms": 2.80, "max_ms": 2.80},
"connect": {"min_ms": 11.68, "avg_ms": 11.68, "max_ms": 11.68},
"tls": {"min_ms": 14.52, "avg_ms": 14.52, "max_ms": 14.52},
"ttfb": {"min_ms": 6.49, "avg_ms": 6.49, "max_ms": 6.49},
"transfer": {"min_ms": 0.81, "avg_ms": 0.81, "max_ms": 0.81},
"total": {"min_ms": 45.59, "avg_ms": 45.59, "max_ms": 45.59}
}
}
]
```
Same shape as `latprobe`'s JSON — `phases` is omitted when all samples
failed, `tls` is omitted for `http://` URLs.
---
## JSON + verbose
```sh
uv run python -m hxprobe --verbose --json https://example.com
```
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 1,
"failed": 0,
"phases": { "...": "..." },
"verbose": {
"ip": "104.20.23.154",
"http_version": "HTTP/2",
"redirect_count": 0,
"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"
}
}
}
]
```
`"http_version"`/`"redirect_count"` are the two keys `latprobe`'s JSON never
has (its `VerboseDetail.http_version` stays `""`). `"headers"` contains
**all** parsed response headers (the text view shows only a priority list).
---
## Timeout
```sh
uv run python -m hxprobe --timeout 500ms http://192.0.2.1/
echo "exit: $?"
```
```
http://192.0.2.1/ (FAILED)
DNS lookup : 3.11 ms
TCP connect : 501.51 ms
─────────────────────────────
Total : 505.20 ms
✗ timeout: timed out
exit: 4
```
`192.0.2.1` is in RFC 5737's TEST-NET-1 range — reserved for documentation,
guaranteed unreachable, and the kernel gets no reply so the connect phase
runs the full `--timeout` before giving up. `--timeout` accepts `ms`, `s`,
`m` suffixes or a bare number of 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 —
identical scheme to `latprobe` and the Go implementation. This scalar is kept
unchanged for compatibility; for multi-URL runs, the "Multi-URL summary
footer" section above shows the full per-URL breakdown behind it.
---
## Makefile shortcuts
From inside this directory:
```sh
make run ARGS="--verbose https://example.com" # run hxprobe
make test # hermetic tests only
make test-integration # live internet tests
make lint # ruff check
make fmt # ruff format
make check # lint + hermetic tests
```
From the parent repo's root, the equivalent shortcuts are prefixed `hx-`:
```sh
make hx-run ARGS="--verbose https://example.com"
make hx-test
make hx-test-integration
make hx-check
```
Both Makefiles are independent — neither calls into the other — so either
works whether you're inside a checkout of the full monorepo or a standalone
copy of just `hxprobe/`.

View File

@@ -0,0 +1,7 @@
# All sites are expected to respond successfully.
# Run: uv run python -m hxprobe -f configs/all-ok.txt
# Expected exit code: 0
https://example.com
https://www.google.com
https://www.iana.org

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
# HTTP error status codes — without --fail, hxprobe treats 4xx/5xx as a
# normal (successful) probe outcome, matching Go's http.DefaultClient
# semantics. --fail opts back into "HTTP error = failure", which is what
# this fixture demonstrates. 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 --fail's effect on the exit code.
#
# Requires internet access.
#
# Run: uv run python -m hxprobe --fail -f configs/http-errors.txt
# Expected exit code: 6 (0 without --fail — the requests still succeed)
# 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

View File

@@ -0,0 +1,67 @@
# Large mixed run — 30 URLs, 20 expected to succeed and 10 to fail, covering
# all five failure classes (dns/connect/timeout/tls/http) at once. A bigger
# sibling of mixed.txt, and a good demo of the multi-URL summary footer
# (only shown when more than one URL is probed) — the footer tallies exactly
# how many of the 10 fall into each class, since the single worst exit code
# can't show that on its own.
#
# --timeout 2s shortens the two timeout entries from the 10s default; --fail
# is required for the two 404s to count as failures (without it they're
# "successful" 404 responses, per Go http.DefaultClient semantics, and only
# 8 of the 30 would fail).
#
# Requires internet access.
#
# Run: uv run python -m hxprobe --fail --timeout 2s -f configs/large-mixed.txt
# Expected exit code: 6 (http wins — the highest class present)
# Expected: at least 10 of 30 fail, spanning all 5 classes. The *exact*
# per-class mix can shift with network conditions — same caveat as
# tls-errors.txt (badssl.com may reset/timeout instead of a clean TLS error)
# and timeout.txt (a sandboxed network may refuse instantly instead of
# timing out); a couple of the "OK" sites were swapped out during testing
# for being unreliable in some sandboxes (Wikipedia's bot detection,
# httpbin.org's frequent overload). Verified by an actual run:
# 30 URLs — 20 ok, 10 failed (dns: 2, connect: 3, timeout: 1, tls: 2,
# http: 2) → exit 6.
# ── OK (20) ──────────────────────────────────────────────────────────────
https://example.com
https://example.org
https://example.net
https://www.google.com
https://www.iana.org
https://github.com
https://www.debian.org
https://www.postgresql.org
https://www.mozilla.org
https://developer.mozilla.org
https://www.python.org
https://pypi.org
https://www.cloudflare.com
https://www.apache.org
https://www.rust-lang.org
https://go.dev
https://nodejs.org
https://www.w3.org
https://www.ietf.org
https://stackoverflow.com
# ── DNS failure (2) — .invalid is IANA-reserved, RFC 2606 ──────────────────
https://this-host-does-not-exist.invalid
http://no.such.host.invalid
# ── Connection refused (2) — loopback ports with no listener ───────────────
http://127.0.0.1:9999
http://127.0.0.1:19999
# ── Timeout (2) — RFC 5737 TEST-NET-1, non-routable ─────────────────────────
http://10.255.255.1/
http://192.0.2.1/
# ── TLS certificate errors (2) — badssl.com ─────────────────────────────────
https://expired.badssl.com/
https://self-signed.badssl.com/
# ── HTTP errors (2) — real 404s, need --fail to count as failures ──────────
https://www.google.com/this-page-does-not-exist-at-all-1234567890
https://github.com/this-repo-does-not-exist-abcxyz123/no-way

23
hxprobe/configs/mixed.txt Normal file
View File

@@ -0,0 +1,23 @@
# Mixed — one entry from each error class alongside a successful site.
# Shows that OK and FAIL lines can interleave in the same run, and that the
# exit code is the *worst* code across every URL (2=dns, 3=connect, 5=tls,
# 6=http-with-fail — 6 wins here). Timeout is omitted so the run completes
# in a few seconds; --fail is needed for the 404 line to count as a failure.
#
# Run: uv run python -m hxprobe --fail -f configs/mixed.txt
# Expected exit code: 6
# 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; needs --fail to count as a failure)
https://github.com/this-repo-does-not-exist-abcxyz123/no-way

View File

@@ -0,0 +1,16 @@
# Timeout — non-routable IP addresses that accept no TCP traffic.
# The kernel sends a SYN but never gets a reply; hxprobe waits the full
# --timeout per host before giving up. Default --timeout is 10s (~20s total
# for two hosts); --timeout 2s below shortens the demo.
#
# 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. (In some sandboxed
# dev environments one of these may instead get an immediate "connection
# refused" from the network layer rather than a true timeout — if that
# happens here, the other target still demonstrates the timeout path.)
#
# Run: uv run python -m hxprobe --timeout 2s -f configs/timeout.txt
# Expected exit code: 4
http://10.255.255.1/
http://192.0.2.1/

View File

@@ -0,0 +1,22 @@
# TLS certificate errors — badssl.com provides endpoints with intentionally
# broken certificates. hxprobe uses the same stdlib ssl verification as
# latprobe and 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 hxprobe still correctly reports a tls/connect failure in either case.
#
# Requires internet access.
#
# Run: uv run python -m hxprobe -f configs/tls-errors.txt
# Expected exit code: 5
# Certificate has expired
https://expired.badssl.com/
# Certificate is self-signed (not trusted by the system CA store)
https://self-signed.badssl.com/
# Certificate chain is incomplete (intermediate CA missing)
https://incomplete-chain.badssl.com/

View File

@@ -0,0 +1,7 @@
"""hxprobe — httpx-based per-phase HTTP latency probe.
Uses the httpx library so the client matches Go's http.DefaultClient: HTTP/2
via ALPN, redirects followed by default, and connection pooling — while
still reporting a six-phase breakdown (DNS, TCP connect, TLS, TTFB, Transfer,
Total) by instrumenting httpx's network backend directly.
"""

View File

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

View File

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

523
hxprobe/hxprobe/cli.py Normal file
View File

@@ -0,0 +1,523 @@
from __future__ import annotations
import argparse
import concurrent.futures
import contextlib
import datetime
import json
from typing import IO
from .aggregate import Aggregate, PhaseStats, summarize
from .duration import parse_duration
from .probe import Options, Result, VerboseDetail, measure
# ── exit codes ────────────────────────────────────────────────────────────────
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)
# Labels for the end-of-run summary footer — inverse of _PHASE_EXIT plus the
# two codes it doesn't cover (EXIT_OK, EXIT_HTTP via --fail).
_EXIT_LABELS: dict[int, str] = {
EXIT_OK: "ok",
EXIT_DNS: "dns",
EXIT_CONNECT: "connect",
EXIT_TIMEOUT: "timeout",
EXIT_TLS: "tls",
EXIT_HTTP: "http",
}
# ── 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",
]
# ── 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, protocol, 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.http_version:
proto = detail.http_version
if detail.redirect_count:
proto += (
f" ({detail.redirect_count} redirect{'s' if detail.redirect_count != 1 else ''})"
)
_row("Protocol", proto)
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 {ps.avg_ms:6.2f} ms {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 _summarize_failures(failed: list[Result]) -> list[tuple[str, str, int]]:
"""Dedupe failures into (phase, message, count), preserving first-seen order."""
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
return [(phase, msg, counts[(phase, msg)]) for phase, msg in order]
def _print_failure_summary(failed: list[Result], out: IO) -> None:
for phase, msg, n in _summarize_failures(failed):
if n == 1:
out.write(f"{phase}: {msg}\n")
else:
out.write(f"{n} × {phase}: {msg}\n")
def _print_run_summary(urls: list[str], url_codes: list[int], worst: int, out: IO) -> None:
"""End-of-run footer tallying every URL's outcome — only called for
multi-URL runs, where the single worst-code exit can't show the mix of
failure classes behind it."""
n_ok = sum(1 for c in url_codes if c == EXIT_OK)
n_failed = len(urls) - n_ok
out.write("\n" + _AGG_SEP + "\n")
header = f" Summary: {len(urls)} URLs — {n_ok} ok"
if n_failed:
header += f", {n_failed} failed"
out.write(header + "\n")
if n_failed:
counts: dict[int, int] = {}
order: list[int] = []
for c in url_codes:
if c == EXIT_OK:
continue
if c not in counts:
order.append(c)
counts[c] = 0
counts[c] += 1
for c in order:
label = _EXIT_LABELS.get(c, str(c))
out.write(f"{label:<8}: {counts[c]}\n")
out.write(f" → exit {worst} ({_EXIT_LABELS.get(worst, str(worst))})\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:
entry["errors"] = [
{"phase": phase, "count": n, "message": msg}
for phase, msg, n in _summarize_failures(failed)
]
if detail is not None:
v: dict = {}
if detail.resolved_ip:
v["ip"] = detail.resolved_ip
if detail.http_version:
v["http_version"] = detail.http_version
v["redirect_count"] = detail.redirect_count
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
# ── URL sources ───────────────────────────────────────────────────────────────
def _load_urls(path: str) -> list[str]:
"""Read URLs from a plain-text file: one per line, '#' comments and blank
lines skipped."""
urls: list[str] = []
with open(path) as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
urls.append(line)
return urls
# ── entry point ───────────────────────────────────────────────────────────────
def run(args: list[str], stdout: IO, stderr: IO) -> int:
parser = argparse.ArgumentParser(
prog="hxprobe",
description=(
"measure per-phase HTTP request latency via httpx "
"(HTTP/2, redirects, connection pooling — matches Go's http.DefaultClient)"
),
)
parser.add_argument("urls", nargs="*", metavar="url")
parser.add_argument(
"-f",
"--file",
metavar="PATH",
help="read URLs from a file, one per line, '#' comments allowed "
"(mutually exclusive with positional url args)",
)
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, negotiated protocol/redirects, TLS version/cipher, "
"certificate, and response headers",
)
parser.add_argument(
"--no-http2",
action="store_false",
dest="http2",
default=True,
help="disable HTTP/2 negotiation, force HTTP/1.1",
)
parser.add_argument(
"--no-follow-redirects",
action="store_false",
dest="follow_redirects",
default=True,
help="do not follow HTTP redirects",
)
try:
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
ns = parser.parse_args(args)
if ns.urls and ns.file:
parser.error("cannot combine positional url arguments with -f/--file")
if not ns.urls and not ns.file:
parser.error("no URLs given (pass as arguments or with -f/--file)")
except SystemExit as exc:
return EXIT_OK if not exc.code else EXIT_USAGE
try:
timeout_secs = parse_duration(ns.timeout)
except ValueError:
stderr.write(f"hxprobe: error: invalid timeout: {ns.timeout!r}\n")
return EXIT_USAGE
if ns.file:
try:
urls = _load_urls(ns.file)
except OSError as exc:
stderr.write(f"hxprobe: error: cannot read {ns.file!r}: {exc}\n")
return EXIT_USAGE
if not urls:
stderr.write(f"hxprobe: error: no URLs found in {ns.file!r}\n")
return EXIT_USAGE
else:
urls = ns.urls
opts = Options(
timeout=timeout_secs,
verbose=ns.verbose,
follow_redirects=ns.follow_redirects,
http2=ns.http2,
)
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 = []
url_codes: list[int] = []
for i, (url, (succeeded, failed)) in enumerate(zip(urls, all_results)):
code = EXIT_OK
for r in failed:
code = max(code, _phase_code(r.fail_phase))
if ns.fail:
for r in succeeded:
if r.status_code >= 400:
code = max(code, EXIT_HTTP)
url_codes.append(code)
worst = max(worst, code)
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")
elif len(urls) > 1:
_print_run_summary(urls, url_codes, worst, stdout)
return worst

View File

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

458
hxprobe/hxprobe/probe.py Normal file
View File

@@ -0,0 +1,458 @@
from __future__ import annotations
import datetime
import socket
import ssl
import time
import urllib.parse
from dataclasses import dataclass, field
import httpcore
import httpx
# ── 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
http_version: str = ""
redirect_count: int = 0
# ── core dataclasses ──────────────────────────────────────────────────────────
@dataclass
class Options:
timeout: float = 10.0
verbose: bool = False
follow_redirects: bool = True
http2: bool = True
@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
# ── cert parsing helpers ──────────────────────────────────────────────────────
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,
)
# ── per-call trace ────────────────────────────────────────────────────────────
#
# httpx has no equivalent of Go's net/http/httptrace, so per-phase timing is
# recovered by instrumenting httpcore's NetworkBackend/NetworkStream directly
# (see _TimingBackend/_TimingStream below). One _Trace instance is created per
# measure() call and threaded through the custom backend.
#
# Semantics when redirects are followed:
# - dns / connect / tls / resolved_ip / tls_* / cert — reported from the
# FIRST connection only (first-hop-wins), mirroring the "cost of reaching
# the origin server" that Go's connectStart.IsZero() guard preserves.
# - wrote_request / first_byte (and therefore ttfb / transfer) — reflect the
# LAST hop, since each write()/read() call overwrites them. This matches
# Go's own httptrace.ClientTrace behavior: WroteRequest/GotFirstResponseByte
# fire on every redirect hop and the last one wins, because Go's hooks
# aren't guarded either.
class _Trace:
def __init__(self) -> None:
self.dns = Phase()
self.connect = Phase()
self.tls = Phase()
self.resolved_ip = ""
self.tls_version = ""
self.tls_cipher = ""
self.tls_bits = 0
self.cert_peer: dict | None = None
self.wrote_request: float | None = None
self.first_byte: float | None = None
self.fail_phase = ""
self._tls_t0 = 0.0
def mark_dns(self, start: float, end: float, *, fail: bool = False) -> None:
# On failure, no phase is "present" for a lookup that never resolved —
# only total gets set (dns.present stays False).
if fail:
self.fail_phase = "dns"
return
if not self.dns.present:
self.dns = Phase(ms=(end - start) * 1000, present=True)
def set_resolved_ip(self, ip: str) -> None:
if not self.resolved_ip:
self.resolved_ip = ip
def mark_connect(self, start: float, end: float, *, fail: str | None = None) -> None:
if not self.connect.present:
self.connect = Phase(ms=(end - start) * 1000, present=True)
if fail:
self.fail_phase = fail
def mark_tls_start(self) -> None:
self._tls_t0 = time.perf_counter()
def finish_tls(self, ssl_sock: ssl.SSLSocket | None, *, fail: str | None = None) -> None:
if not self.tls.present:
self.tls = Phase(ms=(time.perf_counter() - self._tls_t0) * 1000, present=True)
if ssl_sock is not None:
self.tls_version = ssl_sock.version() or ""
cipher = ssl_sock.cipher()
if cipher:
self.tls_cipher, _, self.tls_bits = cipher
try:
peer = ssl_sock.getpeercert()
except Exception:
peer = None
if peer:
self.cert_peer = peer
if fail:
self.fail_phase = fail
def mark_wrote_request(self) -> None:
self.wrote_request = time.perf_counter()
def mark_first_byte(self) -> None:
self.first_byte = time.perf_counter()
# ── instrumented httpcore network backend ────────────────────────────────────
class _TimingStream(httpcore.NetworkStream):
"""Wraps a raw (or TLS) socket, stamping the trace on write/read/start_tls.
httpcore's own h11/h2 connection objects call read()/write()/start_tls()
regardless of HTTP version, so this wrapper is protocol-agnostic — HTTP/2
framing, redirects, and keep-alive are still entirely owned by httpx.
"""
def __init__(self, sock: socket.socket, trace: _Trace) -> None:
self._sock = sock
self._trace = trace
self._awaiting_first_byte = False
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
self._sock.settimeout(timeout)
try:
data = self._sock.recv(max_bytes)
except socket.timeout as exc:
raise httpcore.ReadTimeout(str(exc)) from exc
except OSError as exc:
raise httpcore.ReadError(str(exc)) from exc
if self._awaiting_first_byte:
self._awaiting_first_byte = False
self._trace.mark_first_byte()
return data
def write(self, buffer: bytes, timeout: float | None = None) -> None:
if not buffer:
return
self._sock.settimeout(timeout)
try:
self._sock.sendall(buffer)
except socket.timeout as exc:
raise httpcore.WriteTimeout(str(exc)) from exc
except OSError as exc:
raise httpcore.WriteError(str(exc)) from exc
self._trace.mark_wrote_request()
self._awaiting_first_byte = True
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
def start_tls(
self,
ssl_context: ssl.SSLContext,
server_hostname: str | None = None,
timeout: float | None = None,
) -> httpcore.NetworkStream:
# httpcore already set ALPN protocols (["http/1.1", "h2"] or just
# ["http/1.1"]) on ssl_context before calling start_tls — we just wrap.
self._trace.mark_tls_start()
self._sock.settimeout(timeout)
try:
tls_sock = ssl_context.wrap_socket(self._sock, server_hostname=server_hostname)
except socket.timeout as exc:
self._trace.finish_tls(None, fail="timeout")
raise httpcore.ConnectTimeout(str(exc)) from exc
except (ssl.SSLError, OSError) as exc:
self._trace.finish_tls(None, fail="tls")
raise httpcore.ConnectError(str(exc)) from exc
self._trace.finish_tls(tls_sock)
return _TimingStream(tls_sock, self._trace)
def get_extra_info(self, info: str):
if info == "ssl_object" and isinstance(self._sock, ssl.SSLSocket):
return self._sock
return None
class _TimingBackend(httpcore.NetworkBackend):
"""Splits DNS and TCP connect into two timed steps: httpcore's own
SyncBackend fuses them by calling socket.create_connection(), which does
getaddrinfo() and connect() internally with no way to time them apart."""
def __init__(self, trace: _Trace) -> None:
self._trace = trace
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
) -> httpcore.NetworkStream:
trace = self._trace
t0 = time.perf_counter()
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
trace.mark_dns(t0, time.perf_counter(), fail=True)
raise httpcore.ConnectError(str(exc)) from exc
trace.mark_dns(t0, time.perf_counter())
addr = infos[0][4]
family = infos[0][0]
trace.set_resolved_ip(str(addr[0]))
sock = socket.socket(family, socket.SOCK_STREAM)
if local_address is not None:
sock.bind((local_address, 0))
sock.settimeout(timeout)
t1 = time.perf_counter()
try:
sock.connect(addr)
except socket.timeout as exc:
sock.close()
trace.mark_connect(t1, time.perf_counter(), fail="timeout")
raise httpcore.ConnectTimeout(str(exc)) from exc
except OSError as exc:
sock.close()
trace.mark_connect(t1, time.perf_counter(), fail="connect")
raise httpcore.ConnectError(str(exc)) from exc
trace.mark_connect(t1, time.perf_counter())
# TCP_NODELAY (matches httpcore's own SyncBackend and Go's net.Dialer
# default): disables Nagle's algorithm. Verified experimentally (by
# comparing against a plain socket that omits this) that skipping it
# costs a real ~40-50ms on TTFB against a live server — the classic
# Nagle/delayed-ACK interaction — not just noise. See docs/usage/hxprobe.md.
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
for option in socket_options or ():
sock.setsockopt(*option)
return _TimingStream(sock, trace)
def connect_unix_socket(self, path, timeout=None, socket_options=None):
raise NotImplementedError("hxprobe does not support UNIX sockets")
class _TimingTransport(httpx.HTTPTransport):
"""Subclasses httpx.HTTPTransport but replaces its connection pool's
network_backend, so handle_request()/close() (inherited, unchanged) still
get httpx's own httpcore-exception -> httpx-exception mapping for free."""
def __init__(
self, backend: httpcore.NetworkBackend, *, ssl_context: ssl.SSLContext, http2: bool
) -> None:
self._pool = httpcore.ConnectionPool(
ssl_context=ssl_context,
http1=True,
http2=http2,
network_backend=backend,
)
# ── measure ───────────────────────────────────────────────────────────────────
def _classify(exc: httpx.HTTPError) -> str:
"""Fallback classification for errors not raised by our own backend (e.g.
HTTP/2 stream resets, malformed responses) — "transfer" is the catch-all
bucket for post-connect failures that aren't a timeout or connect error."""
if isinstance(exc, httpx.TimeoutException):
return "timeout"
if isinstance(exc, httpx.ConnectError):
return "connect"
return "transfer"
def _unwrap(exc: BaseException) -> BaseException:
return exc.__cause__ if exc.__cause__ is not None else exc
def _fill_phases(r: Result, trace: _Trace, t_start: float, t_end: float) -> None:
r.dns = trace.dns
r.connect = trace.connect
r.tls = trace.tls
if trace.wrote_request is not None and trace.first_byte is not None:
r.ttfb = Phase(ms=(trace.first_byte - trace.wrote_request) * 1000, present=True)
if trace.first_byte is not None:
r.transfer = Phase(ms=(t_end - trace.first_byte) * 1000, present=True)
r.total = Phase(ms=(t_end - t_start) * 1000, present=True)
def _fill_verbose(
r: Result,
trace: _Trace,
*,
http_version: str = "",
redirect_count: int = 0,
headers: dict[str, str] | None = None,
) -> None:
"""Populate r.detail from whatever the trace captured. Called on both the
success and failure paths — e.g. resolved_ip/TLS info are known even when
a later phase (TTFB, transfer) is what actually failed."""
if r.detail is None:
return
d = r.detail
d.resolved_ip = trace.resolved_ip
d.http_version = http_version
d.redirect_count = redirect_count
if trace.tls.present:
d.tls_version = trace.tls_version
d.tls_cipher = trace.tls_cipher
d.tls_bits = trace.tls_bits
if trace.cert_peer:
d.cert = _parse_cert(trace.cert_peer, verified=True)
if headers:
d.headers = headers
def measure(raw_url: str, opts: Options | None = None) -> Result:
"""Probe raw_url via httpx and return a Result with per-phase timings.
Partial phases are preserved on failure; fail_phase is one of
dns/connect/timeout/tls/transfer/request. The underlying client
negotiates HTTP/2 (opts.http2, default True) and follows redirects
(opts.follow_redirects, default True) — matching Go's http.DefaultClient.
"""
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
trace = _Trace()
ssl_context = ssl.create_default_context()
transport = _TimingTransport(_TimingBackend(trace), ssl_context=ssl_context, http2=opts.http2)
status_code = 0
redirect_count = 0
http_version = ""
headers: dict[str, str] = {}
t_start = time.perf_counter()
try:
with httpx.Client(
transport=transport,
timeout=opts.timeout,
follow_redirects=opts.follow_redirects,
) as client:
with client.stream("GET", raw_url) as resp:
for _ in resp.iter_raw():
pass
status_code = resp.status_code
redirect_count = len(resp.history)
http_version = resp.http_version
if opts.verbose:
headers = dict(resp.headers)
except httpx.InvalidURL as exc:
r.fail_phase = "request"
r.err = exc
return r
except httpx.HTTPError as exc:
t_end = time.perf_counter()
_fill_phases(r, trace, t_start, t_end)
_fill_verbose(r, trace)
r.fail_phase = trace.fail_phase or _classify(exc)
r.err = _unwrap(exc)
return r
t_end = time.perf_counter()
_fill_phases(r, trace, t_start, t_end)
_fill_verbose(
r, trace, http_version=http_version, redirect_count=redirect_count, headers=headers
)
r.status_code = status_code
return r

31
hxprobe/pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[project]
name = "hxprobe"
version = "0.1.0"
description = "Per-phase HTTP latency probe built on httpx — matches Go's http.DefaultClient (HTTP/2, redirects, connection pooling)"
requires-python = ">=3.11"
dependencies = [
"httpx[http2]>=0.28",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.8",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["hxprobe"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: tests that hit the live internet (excluded from the default run)",
]
[tool.ruff]
target-version = "py311"
line-length = 100

View File

260
hxprobe/tests/test_cli.py Normal file
View File

@@ -0,0 +1,260 @@
"""Hermetic tests for hxprobe.cli.run() — a standalone CLI (its own argparse,
exit codes, and text/JSON rendering, not shared with any other package).
These tests spot-check the core rendering paths (single URL, --fail, JSON,
DNS/connect failures) plus what's specific to hxprobe: the --no-http2/
--no-follow-redirects flags and that redirects/http_version are really
wired through end to end."""
import http.server
import io
import json
import os
import socket
import tempfile
import threading
import unittest
from hxprobe.cli import EXIT_CONNECT, EXIT_DNS, EXIT_HTTP, EXIT_OK, EXIT_USAGE, run
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _OKHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive is in play — always send Content-Length
def do_GET(self):
body = b"hello hxprobe"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
body = b"not found"
self.send_response(404)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class _RedirectHandler(http.server.BaseHTTPRequestHandler):
"""/redirect -> 302 to /landed; /landed -> 200."""
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/landed")
self.send_header("Content-Length", "0")
self.end_headers()
else:
body = b"landed"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
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 _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 TestCLIBasics(unittest.TestCase):
def test_help_shows_hxprobe_prog_name(self):
code, out, err = _invoke(["-h"])
self.assertEqual(code, EXIT_OK)
self.assertIn("hxprobe", out)
def test_help_lists_protocol_flags(self):
code, out, err = _invoke(["-h"])
self.assertIn("--no-http2", out)
self.assertIn("--no-follow-redirects", out)
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)
cls.redirect_server, cls.redirect_port = _start_server(_RedirectHandler)
@classmethod
def tearDownClass(cls):
cls.ok_server.shutdown()
cls.nf_server.shutdown()
cls.redirect_server.shutdown()
def _url(self, port=None, path=""):
return f"http://127.0.0.1:{port or self.ok_port}{path}"
def test_single_url_success(self):
code, out, _ = _invoke([self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("DNS lookup", 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)
def test_redirect_followed_by_default(self):
code, out, _ = _invoke(["-v", self._url(self.redirect_port, "/redirect")])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("1 redirect", out)
def test_no_follow_redirects_flag(self):
code, out, _ = _invoke(
["--no-follow-redirects", self._url(self.redirect_port, "/redirect")]
)
self.assertEqual(code, EXIT_OK)
self.assertIn("302", out)
def test_verbose_shows_protocol_row(self):
code, out, _ = _invoke(["-v", self._url()])
self.assertIn("Protocol", out)
self.assertIn("HTTP/1.1", out)
def test_json_includes_http_version_and_redirect_count(self):
code, out, _ = _invoke(["--json", "-v", self._url(self.redirect_port, "/redirect")])
data = json.loads(out)
verbose = data[0]["verbose"]
self.assertEqual(verbose["redirect_count"], 1)
self.assertIn("http_version", verbose)
def test_no_http2_flag_forces_http1(self):
code, out, _ = _invoke(["--no-http2", "-v", self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("HTTP/1.1", out)
class TestCLIFileInput(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_reads_urls_from_file(self):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
f.write(f"# comment\n\n{self._url()}\n{self._url()}\n")
path = f.name
try:
code, out, _ = _invoke(["-f", path])
finally:
os.unlink(path)
self.assertEqual(code, EXIT_OK)
self.assertEqual(out.count("200"), 2)
def test_missing_file_is_usage_error(self):
code, out, err = _invoke(["-f", "/no/such/file/hxprobe-test"])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("cannot read", err)
def test_empty_file_is_usage_error(self):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
path = f.name
try:
code, out, err = _invoke(["-f", path])
finally:
os.unlink(path)
self.assertEqual(code, EXIT_USAGE)
self.assertIn("no URLs found", err)
def test_both_sources_given_is_usage_error(self):
code, out, err = _invoke(["-f", "/tmp/whatever", self._url()])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("cannot combine", err)
def test_no_sources_given_is_usage_error(self):
code, out, err = _invoke([])
self.assertEqual(code, EXIT_USAGE)
self.assertIn("no URLs given", err)
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)
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)
class TestCLIRunSummary(unittest.TestCase):
"""The end-of-run footer: only shown for multi-URL runs, since the single
worst-code exit can't show the mix of failure classes behind it."""
@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_multi_url_mixed_shows_summary(self):
port = _free_port()
code, out, _ = _invoke([self._url(), f"http://127.0.0.1:{port}"])
self.assertEqual(code, EXIT_CONNECT)
self.assertIn("Summary: 2 URLs", out)
self.assertIn("1 ok", out)
self.assertIn("1 failed", out)
self.assertIn("connect : 1", out)
self.assertIn("→ exit 3", out)
def test_multi_url_all_ok_summary(self):
code, out, _ = _invoke([self._url(), self._url()])
self.assertEqual(code, EXIT_OK)
self.assertIn("Summary: 2 URLs — 2 ok", out)
self.assertNotIn("", out)
def test_single_url_has_no_summary(self):
code, out, _ = _invoke([self._url()])
self.assertEqual(code, EXIT_OK)
self.assertNotIn("Summary:", out)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,122 @@
"""Integration tests against live internet services for hxprobe.
Run with:
make hx-test-integration
cd hxprobe && uv run pytest tests -m integration -v
Marked with `pytest.mark.integration` (see module-level `pytestmark` below)
so it's excluded from the default `hx-test`/`make test` gate, which runs
`pytest -m "not integration"` — these hit the real internet.
The point of this file is specifically hxprobe's headline capabilities: real
HTTP/2 negotiation and redirect-following.
"""
import io
import socket
import unittest
import pytest
from hxprobe.cli import EXIT_DNS, EXIT_OK, run
from hxprobe.probe import Options, measure
pytestmark = pytest.mark.integration
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")
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()
@_NEEDS_NET
class TestHTTP2Negotiation(unittest.TestCase):
"""The headline Go-parity feature: real ALPN HTTP/2, not forced HTTP/1.1."""
def test_negotiates_h2_against_cloudflare(self):
r = measure("https://example.com", Options(verbose=True))
self.assertIsNone(r.err, r.err)
self.assertEqual(r.detail.http_version, "HTTP/2")
def test_no_http2_flag_forces_http1(self):
code, out, _ = _run(["--no-http2", "-v", "https://example.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("HTTP/1.1", out)
self.assertNotIn("HTTP/2", out)
@_NEEDS_NET
class TestRedirectsLive(unittest.TestCase):
"""The other headline Go-parity feature: redirects followed by default."""
def test_follows_http_to_https_redirect(self):
code, out, _ = _run(["-v", "http://github.com"])
self.assertEqual(code, EXIT_OK)
self.assertIn("200", out)
self.assertIn("redirect", out)
def test_no_follow_redirects_reports_redirect_status(self):
code, out, _ = _run(["--no-follow-redirects", "http://github.com"])
self.assertEqual(code, EXIT_OK)
self.assertNotIn("redirect_count", out) # text mode never shows the raw key
self.assertTrue(any(code_str in out for code_str in ("301", "302", "307", "308")))
@_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_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")
@_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_cli_dns_failure_exit_code(self):
code, out, _ = _run(["http://no.such.host.invalid"])
self.assertEqual(code, EXIT_DNS)
if __name__ == "__main__":
unittest.main()

256
hxprobe/tests/test_probe.py Normal file
View File

@@ -0,0 +1,256 @@
"""Hermetic tests for hxprobe.probe.measure(), including a redirect test
covering the Go-http.DefaultClient-parity feature (redirects followed by
default) that a plain raw-socket HTTP/1.1 client would not have."""
import http.server
import socket
import threading
import unittest
from hxprobe.probe import Options, VerboseDetail, measure
class _OKHandler(http.server.BaseHTTPRequestHandler):
# HTTP/1.1 (BaseHTTPRequestHandler defaults to 1.0) so hxprobe negotiates
# http_version="HTTP/1.1" instead of "HTTP/1.0". Keep-alive is then in
# play, so every response below sends an explicit Content-Length —
# without it, h11 has no way to detect body-end short of connection
# close, and the client hangs until it hits the request timeout.
protocol_version = "HTTP/1.1"
def _send_body(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._send_body(200, b"hello hxprobe")
def log_message(self, *args):
pass
class _NotFoundHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
body = b"not found"
self.send_response(404)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class _RedirectHandler(http.server.BaseHTTPRequestHandler):
"""/redirect -> 302 to /landed; /landed -> 200."""
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/landed")
self.send_header("Content-Length", "0")
self.end_headers()
else:
body = b"landed"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
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 a read 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_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_follows_redirects_and_http2(self):
opts = Options()
self.assertTrue(opts.follow_redirects)
self.assertTrue(opts.http2)
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)
class TestRedirects(unittest.TestCase):
"""The Go-http.DefaultClient-parity feature: redirects followed by
default, with dns/connect/tls timed from the first hop only."""
@classmethod
def setUpClass(cls):
cls.server, cls.port = _start_server(_RedirectHandler)
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def _url(self, path: str) -> str:
return f"http://127.0.0.1:{self.port}{path}"
def test_follows_redirect_by_default(self):
r = measure(self._url("/redirect"), Options(verbose=True))
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.detail.redirect_count, 1)
def test_no_follow_redirects_reports_302(self):
r = measure(self._url("/redirect"), Options(follow_redirects=False, verbose=True))
self.assertIsNone(r.err)
self.assertEqual(r.status_code, 302)
self.assertEqual(r.detail.redirect_count, 0)
def test_dns_and_connect_present_across_redirect(self):
r = measure(self._url("/redirect"))
self.assertTrue(r.dns.present)
self.assertTrue(r.connect.present)
self.assertTrue(r.ttfb.present)
self.assertTrue(r.total.present)
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_http_version_set_on_success(self):
r = measure(self._url(), Options(verbose=True))
self.assertIn(r.detail.http_version, ("HTTP/1.1", "HTTP/2"))
def test_no_tls_fields_for_http(self):
r = measure(self._url(), Options(verbose=True))
self.assertEqual(r.detail.tls_version, "")
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)
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")
if __name__ == "__main__":
unittest.main()

225
hxprobe/uv.lock generated Normal file
View File

@@ -0,0 +1,225 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hxprobe"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx", extra = ["http2"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "httpx", extras = ["http2"], specifier = ">=0.28" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "ruff"
version = "0.15.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

286
python/phases.py Normal file
View File

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

88
python/simple.py Normal file
View File

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

6
python/sites.txt Normal file
View File

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

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

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

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

View File

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

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

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