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

6.2 KiB
Raw Permalink Blame History

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:

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:

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

  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.