From 404b372070f01788fe3976111fa0cb388079ba15 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 2 Jul 2026 13:22:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(hxprobe):=20httpx-based=20HTTP=20probe=20?= =?UTF-8?q?=E2=80=94=20full=20standalone=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hxprobe/.python-version | 1 + hxprobe/Makefile | 55 ++ hxprobe/README.md | 38 ++ hxprobe/USAGE.md | 703 +++++++++++++++++++++++++ hxprobe/configs/all-ok.txt | 7 + hxprobe/configs/connection-refused.txt | 7 + hxprobe/configs/dns-failure.txt | 9 + hxprobe/configs/http-errors.txt | 22 + hxprobe/configs/large-mixed.txt | 67 +++ hxprobe/configs/mixed.txt | 23 + hxprobe/configs/timeout.txt | 16 + hxprobe/configs/tls-errors.txt | 22 + hxprobe/hxprobe/__init__.py | 7 + hxprobe/hxprobe/__main__.py | 5 + hxprobe/hxprobe/aggregate.py | 53 ++ hxprobe/hxprobe/cli.py | 523 ++++++++++++++++++ hxprobe/hxprobe/duration.py | 14 + hxprobe/hxprobe/probe.py | 458 ++++++++++++++++ hxprobe/pyproject.toml | 31 ++ hxprobe/tests/__init__.py | 0 hxprobe/tests/test_cli.py | 260 +++++++++ hxprobe/tests/test_integration.py | 122 +++++ hxprobe/tests/test_probe.py | 256 +++++++++ hxprobe/uv.lock | 225 ++++++++ 24 files changed, 2924 insertions(+) create mode 100644 hxprobe/.python-version create mode 100644 hxprobe/Makefile create mode 100644 hxprobe/README.md create mode 100644 hxprobe/USAGE.md create mode 100644 hxprobe/configs/all-ok.txt create mode 100644 hxprobe/configs/connection-refused.txt create mode 100644 hxprobe/configs/dns-failure.txt create mode 100644 hxprobe/configs/http-errors.txt create mode 100644 hxprobe/configs/large-mixed.txt create mode 100644 hxprobe/configs/mixed.txt create mode 100644 hxprobe/configs/timeout.txt create mode 100644 hxprobe/configs/tls-errors.txt create mode 100644 hxprobe/hxprobe/__init__.py create mode 100644 hxprobe/hxprobe/__main__.py create mode 100644 hxprobe/hxprobe/aggregate.py create mode 100644 hxprobe/hxprobe/cli.py create mode 100644 hxprobe/hxprobe/duration.py create mode 100644 hxprobe/hxprobe/probe.py create mode 100644 hxprobe/pyproject.toml create mode 100644 hxprobe/tests/__init__.py create mode 100644 hxprobe/tests/test_cli.py create mode 100644 hxprobe/tests/test_integration.py create mode 100644 hxprobe/tests/test_probe.py create mode 100644 hxprobe/uv.lock diff --git a/hxprobe/.python-version b/hxprobe/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/hxprobe/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/hxprobe/Makefile b/hxprobe/Makefile new file mode 100644 index 0000000..60fab40 --- /dev/null +++ b/hxprobe/Makefile @@ -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 diff --git a/hxprobe/README.md b/hxprobe/README.md new file mode 100644 index 0000000..830f861 --- /dev/null +++ b/hxprobe/README.md @@ -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). diff --git a/hxprobe/USAGE.md b/hxprobe/USAGE.md new file mode 100644 index 0000000..384e2d1 --- /dev/null +++ b/hxprobe/USAGE.md @@ -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/`. diff --git a/hxprobe/configs/all-ok.txt b/hxprobe/configs/all-ok.txt new file mode 100644 index 0000000..8ef22c4 --- /dev/null +++ b/hxprobe/configs/all-ok.txt @@ -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 diff --git a/hxprobe/configs/connection-refused.txt b/hxprobe/configs/connection-refused.txt new file mode 100644 index 0000000..e9a2713 --- /dev/null +++ b/hxprobe/configs/connection-refused.txt @@ -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 diff --git a/hxprobe/configs/dns-failure.txt b/hxprobe/configs/dns-failure.txt new file mode 100644 index 0000000..d0667ec --- /dev/null +++ b/hxprobe/configs/dns-failure.txt @@ -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. diff --git a/hxprobe/configs/http-errors.txt b/hxprobe/configs/http-errors.txt new file mode 100644 index 0000000..93b61e4 --- /dev/null +++ b/hxprobe/configs/http-errors.txt @@ -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 diff --git a/hxprobe/configs/large-mixed.txt b/hxprobe/configs/large-mixed.txt new file mode 100644 index 0000000..4e38cae --- /dev/null +++ b/hxprobe/configs/large-mixed.txt @@ -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 diff --git a/hxprobe/configs/mixed.txt b/hxprobe/configs/mixed.txt new file mode 100644 index 0000000..8c8bf5e --- /dev/null +++ b/hxprobe/configs/mixed.txt @@ -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 diff --git a/hxprobe/configs/timeout.txt b/hxprobe/configs/timeout.txt new file mode 100644 index 0000000..455be20 --- /dev/null +++ b/hxprobe/configs/timeout.txt @@ -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/ diff --git a/hxprobe/configs/tls-errors.txt b/hxprobe/configs/tls-errors.txt new file mode 100644 index 0000000..f502d20 --- /dev/null +++ b/hxprobe/configs/tls-errors.txt @@ -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/ diff --git a/hxprobe/hxprobe/__init__.py b/hxprobe/hxprobe/__init__.py new file mode 100644 index 0000000..04b0bb0 --- /dev/null +++ b/hxprobe/hxprobe/__init__.py @@ -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. +""" diff --git a/hxprobe/hxprobe/__main__.py b/hxprobe/hxprobe/__main__.py new file mode 100644 index 0000000..babde6b --- /dev/null +++ b/hxprobe/hxprobe/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .cli import run + +sys.exit(run(sys.argv[1:], sys.stdout, sys.stderr)) diff --git a/hxprobe/hxprobe/aggregate.py b/hxprobe/hxprobe/aggregate.py new file mode 100644 index 0000000..adff4c8 --- /dev/null +++ b/hxprobe/hxprobe/aggregate.py @@ -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 diff --git a/hxprobe/hxprobe/cli.py b/hxprobe/hxprobe/cli.py new file mode 100644 index 0000000..0e9fcc6 --- /dev/null +++ b/hxprobe/hxprobe/cli.py @@ -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 diff --git a/hxprobe/hxprobe/duration.py b/hxprobe/hxprobe/duration.py new file mode 100644 index 0000000..dc8d020 --- /dev/null +++ b/hxprobe/hxprobe/duration.py @@ -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) diff --git a/hxprobe/hxprobe/probe.py b/hxprobe/hxprobe/probe.py new file mode 100644 index 0000000..3ea11ab --- /dev/null +++ b/hxprobe/hxprobe/probe.py @@ -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 diff --git a/hxprobe/pyproject.toml b/hxprobe/pyproject.toml new file mode 100644 index 0000000..1eba89b --- /dev/null +++ b/hxprobe/pyproject.toml @@ -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 diff --git a/hxprobe/tests/__init__.py b/hxprobe/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hxprobe/tests/test_cli.py b/hxprobe/tests/test_cli.py new file mode 100644 index 0000000..8690560 --- /dev/null +++ b/hxprobe/tests/test_cli.py @@ -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() diff --git a/hxprobe/tests/test_integration.py b/hxprobe/tests/test_integration.py new file mode 100644 index 0000000..9bfde01 --- /dev/null +++ b/hxprobe/tests/test_integration.py @@ -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() diff --git a/hxprobe/tests/test_probe.py b/hxprobe/tests/test_probe.py new file mode 100644 index 0000000..a3df257 --- /dev/null +++ b/hxprobe/tests/test_probe.py @@ -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() diff --git a/hxprobe/uv.lock b/hxprobe/uv.lock new file mode 100644 index 0000000..f1f1cb9 --- /dev/null +++ b/hxprobe/uv.lock @@ -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" }, +]