Skip to content

Commit 34a21a3

Browse files
authored
feat: add Server-Timing headers/trailers where relevant (#186)
Here we add [Server-Timing headers/trailers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) to endpoints that let clients control response timing: - `/delay` (header) - `/drip` (header) - `/sse` (trailer)
1 parent dc8fb20 commit 34a21a3

File tree

4 files changed

+147
-0
lines changed

4 files changed

+147
-0
lines changed

httpbin/handlers.go

+19
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,9 @@ func (h *HTTPBin) Delay(w http.ResponseWriter, r *http.Request) {
607607
return
608608
case <-time.After(delay):
609609
}
610+
w.Header().Set("Server-Timing", encodeServerTimings([]serverTiming{
611+
{"initial_delay", delay, "initial delay"},
612+
}))
610613
h.RequestWithBody(w, r)
611614
}
612615

@@ -691,6 +694,12 @@ func (h *HTTPBin) Drip(w http.ResponseWriter, r *http.Request) {
691694

692695
w.Header().Set("Content-Type", textContentType)
693696
w.Header().Set("Content-Length", fmt.Sprintf("%d", numBytes))
697+
w.Header().Set("Server-Timing", encodeServerTimings([]serverTiming{
698+
{"total_duration", delay + duration, "total request duration"},
699+
{"initial_delay", delay, "initial delay"},
700+
{"write_duration", duration, "duration of writes after initial delay"},
701+
{"pause_per_write", pause, "computed pause between writes"},
702+
}))
694703
w.WriteHeader(code)
695704

696705
// what we write with each increment of the ticker
@@ -1110,6 +1119,7 @@ func (h *HTTPBin) Hostname(w http.ResponseWriter, _ *http.Request) {
11101119
// SSE writes a stream of events over a duration after an optional
11111120
// initial delay.
11121121
func (h *HTTPBin) SSE(w http.ResponseWriter, r *http.Request) {
1122+
start := time.Now()
11131123
q := r.URL.Query()
11141124
var (
11151125
count = h.DefaultParams.SSECount
@@ -1169,6 +1179,15 @@ func (h *HTTPBin) SSE(w http.ResponseWriter, r *http.Request) {
11691179
}
11701180
}
11711181

1182+
w.Header().Add("Trailer", "Server-Timing")
1183+
defer func() {
1184+
w.Header().Add("Server-Timing", encodeServerTimings([]serverTiming{
1185+
{"total_duration", time.Since(start), "total request duration"},
1186+
{"initial_delay", delay, "initial delay"},
1187+
{"write_duration", duration, "duration of writes after initial delay"},
1188+
{"pause_per_write", pause, "computed pause between writes"},
1189+
}))
1190+
}()
11721191
w.Header().Set("Content-Type", sseContentType)
11731192
w.WriteHeader(http.StatusOK)
11741193

httpbin/handlers_test.go

+84
Original file line numberDiff line numberDiff line change
@@ -1929,6 +1929,11 @@ func TestDelay(t *testing.T) {
19291929
if elapsed < test.expectedDelay {
19301930
t.Fatalf("expected delay of %s, got %s", test.expectedDelay, elapsed)
19311931
}
1932+
1933+
timings := decodeServerTimings(resp.Header.Get("Server-Timing"))
1934+
assert.DeepEqual(t, timings, map[string]serverTiming{
1935+
"initial_delay": {"initial_delay", test.expectedDelay, "initial delay"},
1936+
}, "incorrect Server-Timing header value")
19321937
})
19331938
}
19341939

@@ -2224,6 +2229,38 @@ func TestDrip(t *testing.T) {
22242229
assert.StatusCode(t, resp, http.StatusOK)
22252230
assert.BodySize(t, resp, 0)
22262231
})
2232+
2233+
t.Run("Server-Timings header", func(t *testing.T) {
2234+
t.Parallel()
2235+
2236+
var (
2237+
duration = 100 * time.Millisecond
2238+
delay = 50 * time.Millisecond
2239+
numBytes = 10
2240+
)
2241+
2242+
url := fmt.Sprintf("/drip?duration=%s&delay=%s&numbytes=%d", duration, delay, numBytes)
2243+
req := newTestRequest(t, "GET", url)
2244+
resp := must.DoReq(t, client, req)
2245+
defer consumeAndCloseBody(resp)
2246+
2247+
assert.StatusCode(t, resp, http.StatusOK)
2248+
2249+
timings := decodeServerTimings(resp.Header.Get("Server-Timing"))
2250+
2251+
// compute expected pause between writes to match server logic and
2252+
// handle lossy floating point truncation in the serialized header
2253+
// value
2254+
computedPause := duration / time.Duration(numBytes-1)
2255+
wantPause, _ := time.ParseDuration(fmt.Sprintf("%.2fms", computedPause.Seconds()*1e3))
2256+
2257+
assert.DeepEqual(t, timings, map[string]serverTiming{
2258+
"total_duration": {"total_duration", delay + duration, "total request duration"},
2259+
"initial_delay": {"initial_delay", delay, "initial delay"},
2260+
"pause_per_write": {"pause_per_write", wantPause, "computed pause between writes"},
2261+
"write_duration": {"write_duration", duration, "duration of writes after initial delay"},
2262+
}, "incorrect Server-Timing header value")
2263+
})
22272264
}
22282265

22292266
func TestRange(t *testing.T) {
@@ -3299,6 +3336,53 @@ func TestSSE(t *testing.T) {
32993336
assert.StatusCode(t, resp, http.StatusOK)
33003337
assert.BodySize(t, resp, 0)
33013338
})
3339+
3340+
t.Run("Server-Timings trailers", func(t *testing.T) {
3341+
t.Parallel()
3342+
3343+
var (
3344+
duration = 250 * time.Millisecond
3345+
delay = 100 * time.Millisecond
3346+
count = 10
3347+
params = url.Values{
3348+
"duration": {duration.String()},
3349+
"delay": {delay.String()},
3350+
"count": {strconv.Itoa(count)},
3351+
}
3352+
)
3353+
3354+
req := newTestRequest(t, "GET", "/sse?"+params.Encode())
3355+
resp := must.DoReq(t, client, req)
3356+
3357+
// need to fully consume body for Server-Timing trailers to arrive
3358+
must.ReadAll(t, resp.Body)
3359+
3360+
rawTimings := resp.Trailer.Get("Server-Timing")
3361+
t.Logf("raw Server-Timing header value: %q", rawTimings)
3362+
3363+
timings := decodeServerTimings(rawTimings)
3364+
3365+
// Ensure total server time makes sense based on duration and delay
3366+
total := timings["total_duration"]
3367+
assert.DurationRange(t, total.dur, duration+delay, duration+delay+25*time.Millisecond)
3368+
3369+
// Ensure computed pause time makes sense based on duration, delay, and
3370+
// numbytes (should be exact, but we're re-parsing a truncated float in
3371+
// the header value)
3372+
pause := timings["pause_per_write"]
3373+
assert.RoughlyEqual(t, pause.dur, duration/time.Duration(count-1), 1*time.Millisecond)
3374+
3375+
// remaining timings should exactly match request parameters, no need
3376+
// to adjust for per-run variations
3377+
wantTimings := map[string]serverTiming{
3378+
"write_duration": {"write_duration", duration, "duration of writes after initial delay"},
3379+
"initial_delay": {"initial_delay", delay, "initial delay"},
3380+
}
3381+
for k, want := range wantTimings {
3382+
got := timings[k]
3383+
assert.DeepEqual(t, got, want, "incorrect timing for key %q", k)
3384+
}
3385+
})
33023386
}
33033387

33043388
func TestWebSocketEcho(t *testing.T) {

httpbin/helpers.go

+17
Original file line numberDiff line numberDiff line change
@@ -563,3 +563,20 @@ func weightedRandomChoice[T any](choices []weightedChoice[T]) T {
563563
}
564564
panic("failed to select a weighted random choice")
565565
}
566+
567+
// Server-Timing header/trailer helpers. See MDN docs for reference:
568+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing
569+
type serverTiming struct {
570+
name string
571+
dur time.Duration
572+
desc string
573+
}
574+
575+
func encodeServerTimings(timings []serverTiming) string {
576+
entries := make([]string, len(timings))
577+
for i, t := range timings {
578+
ms := t.dur.Seconds() * 1e3
579+
entries[i] = fmt.Sprintf("%s;dur=%0.2f;desc=\"%s\"", t.name, ms, t.desc)
580+
}
581+
return strings.Join(entries, ", ")
582+
}

httpbin/helpers_test.go

+27
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/url"
1212
"regexp"
1313
"strconv"
14+
"strings"
1415
"testing"
1516
"time"
1617

@@ -545,3 +546,29 @@ func normalizeChoices[T any](choices []weightedChoice[T]) []weightedChoice[T] {
545546
}
546547
return normalized
547548
}
549+
550+
func decodeServerTimings(headerVal string) map[string]serverTiming {
551+
if headerVal == "" {
552+
return nil
553+
}
554+
timings := map[string]serverTiming{}
555+
for _, entry := range strings.Split(headerVal, ",") {
556+
var t serverTiming
557+
for _, kv := range strings.Split(entry, ";") {
558+
kv = strings.TrimSpace(kv)
559+
key, val, _ := strings.Cut(kv, "=")
560+
switch key {
561+
case "dur":
562+
t.dur, _ = time.ParseDuration(val + "ms")
563+
case "desc":
564+
t.desc = strings.Trim(val, "\"")
565+
default:
566+
t.name = key
567+
}
568+
}
569+
if t.name != "" {
570+
timings[t.name] = t
571+
}
572+
}
573+
return timings
574+
}

0 commit comments

Comments
 (0)