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