Files
http-latency-prober/docs/plans/2026-07-01-00-38-error-handling.md
Jan Novak c323d879d0 feat(go): step 5 — failure handling with classified exit codes
Classify network failures (dns/connect/timeout/tls) with distinct exit
codes 2-5. Add --timeout (default 10s) via context.WithTimeout. Add
--fail for exit 6 on HTTP status >= 400. Preserve partial phase timing
up to the failure point. -n sampling continues on network failures,
aggregating successes and reporting fail counts. JSON extended with
succeeded/failed/errors fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:45:56 +02:00

140 lines
6.2 KiB
Markdown
Raw Permalink Blame History

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