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:
139
docs/plans/2026-07-01-00-38-error-handling.md
Normal file
139
docs/plans/2026-07-01-00-38-error-handling.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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
|
||||
`✗ <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`.
|
||||
148
docs/usage/step-5-failure-handling.md
Normal file
148
docs/usage/step-5-failure-handling.md
Normal 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`) |
|
||||
Reference in New Issue
Block a user