Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/cloudflare-contract.yml
Original file line number Diff line number Diff line change
@@ -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$'
85 changes: 85 additions & 0 deletions internal/cloudflare/contract_live_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
37 changes: 25 additions & 12 deletions internal/cloudflare/measure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,52 +50,58 @@ 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.
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,
}
}

Expand Down Expand Up @@ -157,19 +165,22 @@ 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 {
select {
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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion internal/cloudflare/upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading