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:
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user