diff --git a/.github/workflows/cloudflare-contract.yml b/.github/workflows/cloudflare-contract.yml new file mode 100644 index 0000000..e682be6 --- /dev/null +++ b/.github/workflows/cloudflare-contract.yml @@ -0,0 +1,28 @@ +name: Cloudflare contract + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + cloudflare-contract: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run live Cloudflare contract test + run: go test -count=1 -tags=cloudflare_live ./internal/cloudflare -run '^TestCloudflareLiveContract$' diff --git a/internal/cloudflare/contract_live_test.go b/internal/cloudflare/contract_live_test.go new file mode 100644 index 0000000..3f9d107 --- /dev/null +++ b/internal/cloudflare/contract_live_test.go @@ -0,0 +1,85 @@ +//go:build cloudflare_live + +package cloudflare + +import ( + "bytes" + "io" + "net/http" + "strconv" + "testing" + "time" +) + +func TestCloudflareLiveContract(t *testing.T) { + client := &http.Client{Timeout: 30 * time.Second} + + t.Run("download", func(t *testing.T) { + const wantBytes = 1024 + resp, err := client.Get(baseURL + "/__down?bytes=" + strconv.Itoa(wantBytes)) + if err != nil { + t.Fatalf("GET /__down: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /__down status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read /__down body: %v", err) + } + if len(body) != wantBytes { + t.Fatalf("GET /__down body length = %d, want %d", len(body), wantBytes) + } + if resp.Header.Get("Server-Timing") == "" { + t.Error("GET /__down missing Server-Timing header") + } + }) + + t.Run("upload", func(t *testing.T) { + const wantBytes = 1024 + payload := bytes.Repeat([]byte("0"), wantBytes) + req, err := http.NewRequest(http.MethodPost, baseURL+"/__up", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("create POST /__up request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("POST /__up: %v", err) + } + defer resp.Body.Close() + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + t.Fatalf("read /__up body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST /__up status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + gotBytes, err := strconv.Atoi(resp.Header.Get("cf-meta-upload-bytes")) + if err != nil { + t.Fatalf("cf-meta-upload-bytes = %q: %v", resp.Header.Get("cf-meta-upload-bytes"), err) + } + if gotBytes != wantBytes { + t.Fatalf("cf-meta-upload-bytes = %d, want %d", gotBytes, wantBytes) + } + if resp.Header.Get("Server-Timing") == "" { + t.Error("POST /__up missing Server-Timing header") + } + }) + + t.Run("metadata", func(t *testing.T) { + meta, err := FetchMeta() + if err != nil { + t.Fatalf("GET /meta: %v", err) + } + if meta.ASN <= 0 || meta.ClientIP == "" || meta.Colo.City == "" { + t.Fatalf("/meta missing required fields: asn=%d ip=%t city=%q", meta.ASN, meta.ClientIP != "", meta.Colo.City) + } + }) + + t.Logf("validated Cloudflare contract at %s", baseURL) +} diff --git a/internal/cloudflare/measure.go b/internal/cloudflare/measure.go index 74c9a53..68327df 100644 --- a/internal/cloudflare/measure.go +++ b/internal/cloudflare/measure.go @@ -16,6 +16,8 @@ import ( const unloadedLatencySampleCount = 20 +var errClientTimingFallback = errors.New("server timing unavailable; used client timing") + type sampleFailureTracker struct { count int first error @@ -48,31 +50,31 @@ func MeasureSpeed(byteCount int, durationMs float64) float64 { return (float64(byteCount*8) / (durationMs / 1000)) / 1e6 } -func latencySample() (float64, error) { +func latencySample() (float64, bool, error) { pd, err := MakeRequest("GET", "/__down?bytes=0", nil) if err != nil { - return 0, err + return 0, false, err } if pd.TTFB.IsZero() { - return 0, errors.New("missing first response byte timing") + return 0, false, errors.New("missing first response byte timing") } clientDuration := pd.TTFB.Sub(pd.Started).Seconds() * 1000 if clientDuration <= 0 { - return 0, fmt.Errorf("computed non-positive client latency: %.2f ms", clientDuration) + return 0, false, fmt.Errorf("computed non-positive client latency: %.2f ms", clientDuration) } serverTiming, err := ParseServerTiming(pd.ServerTimingHeader) if err == nil && !math.IsNaN(serverTiming) && !math.IsInf(serverTiming, 0) && serverTiming >= 0 { dur := clientDuration - serverTiming if dur <= 0 { - return 0, fmt.Errorf("computed non-positive latency: %.2f ms", dur) + return 0, false, fmt.Errorf("computed non-positive latency: %.2f ms", dur) } - return dur, nil + return dur, false, nil } - return clientDuration, nil + return clientDuration, true, nil } // MeasureLatency sends lightweight requests and returns the unloaded latency samples in ms. @@ -80,20 +82,26 @@ func MeasureLatency() LatencyMeasurement { var measurements []float64 var failures sampleFailureTracker + var fallbacks sampleFailureTracker for i := 0; i < unloadedLatencySampleCount; i++ { - sample, err := latencySample() + sample, usedFallback, err := latencySample() if err != nil { failures.Record(err) continue } + if usedFallback { + fallbacks.Record(errClientTimingFallback) + } measurements = append(measurements, sample) } + warnings := failures.Message("unloaded latency measurement failed %d time(s); first error: %v") + warnings = append(warnings, fallbacks.Message("unloaded latency measurement used client timing fallback %d time(s); first reason: %v")...) return LatencyMeasurement{ Samples: measurements, FailedSamples: failures.count, - Warnings: failures.Message("unloaded latency measurement failed %d time(s); first error: %v"), + Warnings: warnings, } } @@ -157,7 +165,7 @@ func validatePhaseSpecs(phaseType string, specs []PhaseSpec) error { } // backgroundLatencyMonitor periodically samples latency while other measurements run. -func backgroundLatencyMonitor(ctx context.Context, results *[]float64, failures *sampleFailureTracker) { +func backgroundLatencyMonitor(ctx context.Context, results *[]float64, failures, fallbacks *sampleFailureTracker) { ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { @@ -165,11 +173,14 @@ func backgroundLatencyMonitor(ctx context.Context, results *[]float64, failures case <-ctx.Done(): return case <-ticker.C: - sample, err := latencySample() + sample, usedFallback, err := latencySample() if err != nil { failures.Record(err) continue } + if usedFallback { + fallbacks.Record(errClientTimingFallback) + } *results = append(*results, sample) } @@ -188,13 +199,14 @@ func MeasurePhase(phaseType string, specs []PhaseSpec, w io.Writer) (PhaseMeasur var throughputFailures sampleFailureTracker var throughputWarnings sampleFailureTracker var loadedLatencyFailures sampleFailureTracker + var loadedLatencyFallbacks sampleFailureTracker ctx, cancel := context.WithCancel(context.Background()) var monitorWG sync.WaitGroup monitorWG.Add(1) go func() { defer monitorWG.Done() - backgroundLatencyMonitor(ctx, &loadedLatencies, &loadedLatencyFailures) + backgroundLatencyMonitor(ctx, &loadedLatencies, &loadedLatencyFailures, &loadedLatencyFallbacks) }() for _, spec := range specs { @@ -243,6 +255,7 @@ func MeasurePhase(phaseType string, specs []PhaseSpec, w io.Writer) (PhaseMeasur warnings := append([]string{}, throughputFailures.Message(fmt.Sprintf("%s throughput measurement failed %%d time(s); first error: %%v", phaseType))...) warnings = append(warnings, throughputWarnings.Message(fmt.Sprintf("%s throughput measurement used fallback timing %%d time(s); first reason: %%v", phaseType))...) warnings = append(warnings, loadedLatencyFailures.Message(fmt.Sprintf("%s loaded latency monitor failed %%d time(s); first error: %%v", phaseType))...) + warnings = append(warnings, loadedLatencyFallbacks.Message(fmt.Sprintf("%s loaded latency monitor used client timing fallback %%d time(s); first reason: %%v", phaseType))...) return PhaseMeasurement{ Speeds: allSpeeds, diff --git a/internal/cloudflare/upload_test.go b/internal/cloudflare/upload_test.go index 5b676cb..476edd6 100644 --- a/internal/cloudflare/upload_test.go +++ b/internal/cloudflare/upload_test.go @@ -71,10 +71,13 @@ func TestLatencySampleFallsBackToClientTiming(t *testing.T) { }, nil })) - latency, err := latencySample() + latency, usedFallback, err := latencySample() if err != nil { t.Fatalf("latencySample() error = %v", err) } + if !usedFallback { + t.Fatal("latencySample() used server timing, want client timing fallback") + } if math.IsNaN(latency) || math.IsInf(latency, 0) || latency <= 0 || latency >= 60_000 { t.Fatalf("latencySample() = %v, want a positive finite client-timed latency", latency) }