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>
101 lines
5.6 KiB
Markdown
101 lines
5.6 KiB
Markdown
# 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.
|