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>
This commit is contained in:
2026-07-01 00:45:56 +02:00
parent 3affc05996
commit c323d879d0
6 changed files with 611 additions and 88 deletions

View File

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

View File

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