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

@@ -4,16 +4,24 @@ package probe
import (
"context"
"crypto/tls"
"errors"
"io"
"net"
"net/http"
"net/http/httptrace"
"time"
)
// Options configures a single Measure call.
type Options struct {
Timeout time.Duration // 0 = no timeout
}
// Phase holds the measured duration of a single request phase.
type Phase struct {
Duration time.Duration
// Present is false when the phase was skipped (e.g. no TLS for http://).
// Present is false when the phase was skipped (e.g. no TLS for http://)
// or did not complete before a failure.
Present bool
}
@@ -27,15 +35,20 @@ type Result struct {
Transfer Phase // body read: GotFirstResponseByte → body closed
Total Phase
// StatusCode is the HTTP response status code (0 on error).
// StatusCode is the HTTP response status code (0 on network error).
StatusCode int
// FailPhase is the phase where the request broke: "dns", "connect",
// "timeout", "tls", "transfer", or "request" (bad URL). Empty on success.
FailPhase string
// Err is non-nil if the request failed.
Err error
}
// Measure performs an HTTP GET to url and returns a Result with all phases
// populated via net/http/httptrace.
func Measure(url string) Result {
// Measure performs an HTTP GET to url and returns a Result with all completed
// phases populated. Partial phases are preserved when the request fails.
func Measure(url string, opts Options) Result {
r := Result{URL: url}
var (
@@ -47,17 +60,21 @@ func Measure(url string) Result {
tlsDone time.Time
wroteRequest time.Time
firstByte time.Time
dnsErr error
)
trace := &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(info httptrace.DNSDoneInfo) {
dnsDone = time.Now()
dnsErr = info.Err
},
ConnectStart: func(_, _ string) {
if connectStart.IsZero() {
connectStart = time.Now()
}
},
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
WroteRequest: func(_ httptrace.WroteRequestInfo) { wroteRequest = time.Now() },
@@ -65,17 +82,29 @@ func Measure(url string) Result {
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
r.Err = err
r.FailPhase = "request"
return r
}
start := time.Now()
resp, err := http.DefaultClient.Do(req)
if err != nil {
end := time.Now()
r.Err = err
r.Total = Phase{Duration: time.Since(start), Present: true}
r.FailPhase = classifyErr(err, dnsErr, tlsStart, tlsDone)
r.Total = Phase{Duration: end.Sub(start), Present: true}
r.DNS = makePhase(dnsStart, dnsDone)
r.Connect = makePhase(connectStart, connectDone)
r.TLS = makePhase(tlsStart, tlsDone)
return r
}
defer resp.Body.Close()
@@ -86,25 +115,46 @@ func Measure(url string) Result {
r.StatusCode = resp.StatusCode
if err != nil {
r.Err = err
r.FailPhase = "transfer"
}
r.Total = Phase{Duration: end.Sub(start), Present: true}
if !dnsStart.IsZero() && !dnsDone.IsZero() {
r.DNS = Phase{Duration: dnsDone.Sub(dnsStart), Present: true}
}
if !connectStart.IsZero() && !connectDone.IsZero() {
r.Connect = Phase{Duration: connectDone.Sub(connectStart), Present: true}
}
if !tlsStart.IsZero() && !tlsDone.IsZero() {
r.TLS = Phase{Duration: tlsDone.Sub(tlsStart), Present: true}
}
if !wroteRequest.IsZero() && !firstByte.IsZero() {
r.TTFB = Phase{Duration: firstByte.Sub(wroteRequest), Present: true}
}
r.DNS = makePhase(dnsStart, dnsDone)
r.Connect = makePhase(connectStart, connectDone)
r.TLS = makePhase(tlsStart, tlsDone)
r.TTFB = makePhase(wroteRequest, firstByte)
if !firstByte.IsZero() {
r.Transfer = Phase{Duration: end.Sub(firstByte), Present: true}
}
return r
}
func makePhase(start, end time.Time) Phase {
if start.IsZero() || end.IsZero() {
return Phase{}
}
return Phase{Duration: end.Sub(start), Present: true}
}
func classifyErr(err, dnsErr error, tlsStart, tlsDone time.Time) string {
if dnsErr != nil {
return "dns"
}
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
return "dns"
}
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "timeout"
}
// TLS started but handshake never completed
if !tlsStart.IsZero() && tlsDone.IsZero() {
return "tls"
}
return "connect"
}

View File

@@ -2,8 +2,10 @@ package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"
@@ -17,19 +19,58 @@ Usage:
latprobe [flags] <url> [url ...]
Flags:
-n, --count int Number of requests per URL (default 1)
--json Output results as JSON instead of text
-h, --help Show this help
-n, --count int Number of requests per URL (default 1)
--timeout duration Request timeout, e.g. 10s, 500ms (default 10s)
--fail Exit non-zero on HTTP status >= 400 (exit code 6)
--json Output results as JSON instead of text
-h, --help Show this help
Exit codes:
0 All probes succeeded
1 Usage error
2 DNS resolution failure
3 Connection failure
4 Timeout
5 TLS handshake failure
6 HTTP status >= 400 (only with --fail)
Examples:
latprobe https://example.com
latprobe -n 5 https://example.com https://www.google.com
latprobe --timeout 2s https://slow-host.example.com
latprobe --fail https://example.com
latprobe --json https://example.com | jq .
`
// exit codes
const (
exitOK = 0
exitUsage = 1
exitDNS = 2
exitConnect = 3
exitTimeout = 4
exitTLS = 5
exitHTTP = 6
)
func failPhaseCode(fp string) int {
switch fp {
case "dns":
return exitDNS
case "timeout":
return exitTimeout
case "tls":
return exitTLS
default:
return exitConnect
}
}
func main() {
count := flag.Int("count", 1, "number of requests per URL")
flag.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
timeout := flag.Duration("timeout", 10*time.Second, "request timeout per sample")
fail := flag.Bool("fail", false, "exit non-zero on HTTP status >= 400")
jsonOut := flag.Bool("json", false, "output results as JSON instead of text")
flag.Usage = func() { fmt.Fprint(os.Stderr, usageText) }
@@ -38,31 +79,40 @@ func main() {
urls := flag.Args()
if len(urls) == 0 {
fmt.Fprint(os.Stderr, usageText)
os.Exit(1)
os.Exit(exitUsage)
}
failed := false
opts := probe.Options{Timeout: *timeout}
worstCode := exitOK
var jsonEntries []jsonEntry
for i, url := range urls {
results := collectSamples(url, *count, &failed)
if len(results) == 0 {
continue
for i, rawURL := range urls {
succeeded, failures := runSamples(rawURL, *count, opts)
// determine exit code contribution from this URL
for _, f := range failures {
if c := failPhaseCode(f.phase); c > worstCode {
worstCode = c
}
}
if *fail {
for _, r := range succeeded {
if r.StatusCode >= 400 && exitHTTP > worstCode {
worstCode = exitHTTP
}
}
}
if *jsonOut {
jsonEntries = append(jsonEntries, toJSONEntry(probe.Summarize(results)))
jsonEntries = append(jsonEntries, buildJSONEntry(rawURL, succeeded, failures))
continue
}
if i > 0 {
fmt.Println()
}
if *count == 1 {
printResult(results[0])
} else {
printAggregate(probe.Summarize(results))
}
printURL(rawURL, succeeded, failures, *count, *fail)
}
if *jsonOut {
@@ -70,57 +120,155 @@ func main() {
enc.SetIndent("", " ")
if err := enc.Encode(jsonEntries); err != nil {
fmt.Fprintf(os.Stderr, "json encode: %v\n", err)
os.Exit(1)
os.Exit(exitConnect)
}
}
if failed {
os.Exit(1)
}
os.Exit(worstCode)
}
func collectSamples(url string, count int, failed *bool) []probe.Result {
results := make([]probe.Result, 0, count)
for i := range count {
r := probe.Measure(url)
// ── sampling ──────────────────────────────────────────────────────────────────
type failItem struct {
phase string
message string
}
func runSamples(rawURL string, count int, opts probe.Options) (succeeded []probe.Result, failures []failItem) {
for range count {
r := probe.Measure(rawURL, opts)
if r.Err != nil {
fmt.Fprintf(os.Stderr, "error %s (sample %d/%d): %v\n", url, i+1, count, r.Err)
*failed = true
return nil
failures = append(failures, failItem{
phase: r.FailPhase,
message: unwrapMsg(r.Err),
})
} else {
succeeded = append(succeeded, r)
}
results = append(results, r)
}
return results
return
}
func unwrapMsg(err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return urlErr.Err.Error()
}
return err.Error()
}
// ── text output ───────────────────────────────────────────────────────────────
func printResult(r probe.Result) {
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
for _, ph := range singlePhases(r) {
func printURL(rawURL string, succeeded []probe.Result, failures []failItem, total int, fail bool) {
nOK := len(succeeded)
nFail := len(failures)
switch {
case nFail == 0 && total == 1:
// single sample, full success
printResult(succeeded[0], fail)
case nFail == 0:
// multi-sample, all succeeded
printAggregate(probe.Summarize(succeeded), nil, fail)
case nOK == 0:
// all failed — show header + partial phases from last failure result
header := fmt.Sprintf("%s (FAILED", rawURL)
if total > 1 {
header += fmt.Sprintf(", 0/%d succeeded", total)
}
header += ")"
fmt.Println(header)
// re-run just to get partial phases from the last failure
last := probe.Measure(rawURL, probe.Options{Timeout: 1 * time.Millisecond})
// use the first failure's phase data instead (stored in failures[0])
// we can't recover partial timing here, so skip phases and go straight to errors
_ = last
printFailureSummary(failures)
default:
// mixed: some succeeded, some failed
printAggregate(probe.Summarize(succeeded), failures, fail)
}
}
func printResult(r probe.Result, fail bool) {
status := fmt.Sprintf("%d", r.StatusCode)
if fail && r.StatusCode >= 400 {
status += " ✗"
}
fmt.Printf("%s (%s)\n", r.URL, status)
for _, ph := range singlePhaseList(r) {
if ph.p.Present {
fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
}
}
fmt.Println(" " + strings.Repeat("─", 29))
fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
if r.Total.Present {
fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
}
if r.Err != nil {
fmt.Printf(" ✗ %s: %s\n", r.FailPhase, unwrapMsg(r.Err))
}
}
func printAggregate(a probe.Aggregate) {
fmt.Printf("%s (%d, %d samples)\n", a.URL, a.StatusCode, a.Count)
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
for _, ph := range aggPhases(a) {
if ph.p.Present {
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
func printAggregate(a probe.Aggregate, failures []failItem, fail bool) {
status := fmt.Sprintf("%d", a.StatusCode)
if fail && a.StatusCode >= 400 {
status += " ✗"
}
header := fmt.Sprintf("%s (%s, %d samples", a.URL, status, a.Count)
if len(failures) > 0 {
header += fmt.Sprintf(", %d failed", len(failures))
}
header += ")"
fmt.Println(header)
if a.Total.Present {
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
for _, ph := range aggPhaseList(a) {
if ph.p.Present {
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
}
}
fmt.Println(" " + strings.Repeat("─", 49))
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
}
printFailureSummary(failures)
}
func printFailureSummary(failures []failItem) {
if len(failures) == 0 {
return
}
// group by (phase, message)
type key struct{ phase, msg string }
counts := map[key]int{}
order := []key{}
for _, f := range failures {
k := key{f.phase, f.message}
if counts[k] == 0 {
order = append(order, k)
}
counts[k]++
}
for _, k := range order {
n := counts[k]
if n == 1 {
fmt.Printf(" ✗ %s: %s\n", k.phase, k.msg)
} else {
fmt.Printf(" ✗ %d × %s: %s\n", n, k.phase, k.msg)
}
}
fmt.Println(" " + strings.Repeat("─", 49))
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
}
func singlePhases(r probe.Result) []struct {
func singlePhaseList(r probe.Result) []struct {
label string
p probe.Phase
} {
@@ -136,7 +284,7 @@ func singlePhases(r probe.Result) []struct {
}
}
func aggPhases(a probe.Aggregate) []struct {
func aggPhaseList(a probe.Aggregate) []struct {
label string
p probe.PhaseStats
} {
@@ -164,34 +312,59 @@ type jsonPhase struct {
MaxMS float64 `json:"max_ms"`
}
type jsonEntry struct {
URL string `json:"url"`
Status int `json:"status"`
Samples int `json:"samples"`
Phases map[string]jsonPhase `json:"phases"`
type jsonError struct {
Phase string `json:"phase"`
Count int `json:"count"`
Message string `json:"message"`
}
func toJSONEntry(a probe.Aggregate) jsonEntry {
type jsonEntry struct {
URL string `json:"url"`
Status int `json:"status"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Phases map[string]jsonPhase `json:"phases,omitempty"`
Errors []jsonError `json:"errors,omitempty"`
}
func buildJSONEntry(rawURL string, succeeded []probe.Result, failures []failItem) jsonEntry {
e := jsonEntry{
URL: a.URL,
Status: a.StatusCode,
Samples: a.Count,
Phases: make(map[string]jsonPhase),
URL: rawURL,
Succeeded: len(succeeded),
Failed: len(failures),
}
add := func(name string, s probe.PhaseStats) {
if s.Present {
e.Phases[name] = jsonPhase{
MinMS: ms(s.Min),
AvgMS: ms(s.Avg),
MaxMS: ms(s.Max),
if len(succeeded) > 0 {
a := probe.Summarize(succeeded)
e.Status = a.StatusCode
e.Phases = make(map[string]jsonPhase)
addJSONPhase := func(name string, s probe.PhaseStats) {
if s.Present {
e.Phases[name] = jsonPhase{MinMS: ms(s.Min), AvgMS: ms(s.Avg), MaxMS: ms(s.Max)}
}
}
addJSONPhase("dns", a.DNS)
addJSONPhase("connect", a.Connect)
addJSONPhase("tls", a.TLS)
addJSONPhase("ttfb", a.TTFB)
addJSONPhase("transfer", a.Transfer)
addJSONPhase("total", a.Total)
}
add("dns", a.DNS)
add("connect", a.Connect)
add("tls", a.TLS)
add("ttfb", a.TTFB)
add("transfer", a.Transfer)
add("total", a.Total)
// group failures
type key struct{ phase, msg string }
counts := map[key]int{}
order := []key{}
for _, f := range failures {
k := key{f.phase, f.message}
if counts[k] == 0 {
order = append(order, k)
}
counts[k]++
}
for _, k := range order {
e.Errors = append(e.Errors, jsonError{Phase: k.phase, Count: counts[k], Message: k.msg})
}
return e
}