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>
6.2 KiB
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:
- DNS failure — non-existent record (NXDOMAIN / no such host)
- Connection failure / unresponsive host — refused, unreachable, reset, or hanging (timeout)
- 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. -nsampling 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).--failflag (curl-style): HTTP status ≥ 400 only affects the exit code when--failis 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 optiongo/main.go— flags, sampling loop, exit codes, text + JSON renderingdocs/usage/step-5-failure-handling.md— new user docCHANGELOG.md,README.md— bookkeepingdocs/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:
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
DNSDonehook, saveinfo.Errinto a localdnsErr(thehttptrace.DNSDoneInfoalready carries it — reuse, don't re-resolve). - Apply timeout: if
opts.Timeout > 0, wrap the context withcontext.WithTimeout(covers connect through body read);defer cancel(). - On
Do()error (or body-read error), classify intoFailPhase:dnsErr != nilorerrors.As(err, *net.DNSError)→"dns"errors.Is(err, context.DeadlineExceeded)or anet.ErrorwithTimeout()==true→"timeout"tlsStartset buttlsDonezero →"tls"- 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 toprobe.Summarizefailuresgrouped byFailPhasewith 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 aFailures: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 theFailures:summary. - Single sample failure →
URL (FAILED), partial phases, then✗ <phase>: <message>.
JSON rendering: extend jsonEntry with:
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
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
- Copy this plan to
docs/plans/2026-07-01-00-38-error-handling.md. - Add
docs/usage/step-5-failure-handling.md. - Append a Step 5 entry to
CHANGELOG.md; add a Step 5 row toREADME.md.