Files
http-latency-prober/go/internal/probe/probe.go
Jan Novak a9534ec2c1 test(go): step 6 — integration tests covering all exit codes and output
Extract run(args, stdout, stderr) int from main() for in-process
testability. Fix TLS failure classification (tlsErr now captured from
TLSHandshakeDone hook). Add run_test.go with 14 table-driven in-process
tests and cli_test.go with TestMain + 3 subprocess smoke tests. All
servers use httptest; .invalid TLD for deterministic DNS failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 01:00:20 +02:00

171 lines
4.3 KiB
Go

// Package probe measures per-phase HTTP request latency.
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://)
// or did not complete before a failure.
Present bool
}
// Result holds all timing phases for a single HTTP request.
type Result struct {
URL string
DNS Phase
Connect Phase
TLS Phase
TTFB Phase // server processing: WroteRequest → GotFirstResponseByte
Transfer Phase // body read: GotFirstResponseByte → body closed
Total Phase
// 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 completed
// phases populated. Partial phases are preserved when the request fails.
func Measure(url string, opts Options) Result {
r := Result{URL: url}
var (
dnsStart time.Time
dnsDone time.Time
connectStart time.Time
connectDone time.Time
tlsStart time.Time
tlsDone time.Time
wroteRequest time.Time
firstByte time.Time
dnsErr error
tlsErr error
)
trace := &httptrace.ClientTrace{
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() },
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
tlsDone = time.Now()
tlsErr = err
},
WroteRequest: func(_ httptrace.WroteRequestInfo) { wroteRequest = time.Now() },
GotFirstResponseByte: func() { firstByte = time.Now() },
}
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.FailPhase = classifyErr(err, dnsErr, tlsErr, tlsStart)
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()
_, err = io.Copy(io.Discard, resp.Body)
end := time.Now()
r.StatusCode = resp.StatusCode
if err != nil {
r.Err = err
r.FailPhase = "transfer"
}
r.Total = Phase{Duration: end.Sub(start), 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}
}
// classifyErr maps a Do() error to the phase that caused it.
// Order: dns → timeout → tls → connect.
// Timeout during TLS still classifies as timeout, not tls.
func classifyErr(err, dnsErr, tlsErr error, tlsStart 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"
}
if tlsErr != nil {
return "tls"
}
// TLS started but handshake was interrupted (e.g. context cancelled mid-handshake)
if !tlsStart.IsZero() {
return "tls"
}
return "connect"
}