Skip to content

Commit 40b921a

Browse files
authored
fix(compat): /range supports duration parameter (#203)
I started to take a run at the `/range` incompatibilities identified in #172 but in the course of working on this I have decided, for now, that perfect compatibility with the original httpbin implementation is not worth the effort. So here we do (kinda) support the `?duration` parameter, but it effectively acts more as a delay before the entire response is written. We also add drive-by tests to ensure that we're properly support requests for multiple ranges (which the original httpbin _does not_ correctly support). Properly supporting both `?chunk_size` and `?duration` parameters would require fully re-implementing the stdlib's built-in support for range requests, which would get complicated where splitting the response into chunks spread over a given duration intersects with constructing proper multipart responses for multiple range requests. For now, I'm happy to leave the implementation _mostly_ as-is.
1 parent 51902cc commit 40b921a

File tree

4 files changed

+179
-28
lines changed

4 files changed

+179
-28
lines changed

httpbin/handlers.go

+25-3
Original file line numberDiff line numberDiff line change
@@ -743,15 +743,37 @@ func (h *HTTPBin) Drip(w http.ResponseWriter, r *http.Request) {
743743

744744
// Range returns up to N bytes, with support for HTTP Range requests.
745745
//
746-
// This departs from httpbin by not supporting the chunk_size or duration
747-
// parameters.
746+
// This departs from original httpbin in a few ways:
747+
//
748+
// - param `chunk_size` IS NOT supported
749+
//
750+
// - param `duration` IS supported, but functions more as a delay before the
751+
// whole response is written
752+
//
753+
// - multiple ranges ARE correctly supported (i.e. `Range: bytes=0-1,2-3`
754+
// will return a multipart/byteranges response)
755+
//
756+
// Most of the heavy lifting is done by the stdlib's http.ServeContent, which
757+
// handles range requests automatically. Supporting chunk sizes would require
758+
// an extensive reimplementation, especially to support multiple ranges for
759+
// correctness. For now, we choose not to take that work on.
748760
func (h *HTTPBin) Range(w http.ResponseWriter, r *http.Request) {
749761
numBytes, err := strconv.ParseInt(r.PathValue("numBytes"), 10, 64)
750762
if err != nil {
751763
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid count: %w", err))
752764
return
753765
}
754766

767+
var duration time.Duration
768+
if durationVal := r.URL.Query().Get("duration"); durationVal != "" {
769+
var err error
770+
duration, err = parseBoundedDuration(r.URL.Query().Get("duration"), 0, h.MaxDuration)
771+
if err != nil {
772+
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid duration: %w", err))
773+
return
774+
}
775+
}
776+
755777
w.Header().Add("ETag", fmt.Sprintf("range%d", numBytes))
756778
w.Header().Add("Accept-Ranges", "bytes")
757779

@@ -760,7 +782,7 @@ func (h *HTTPBin) Range(w http.ResponseWriter, r *http.Request) {
760782
return
761783
}
762784

763-
content := newSyntheticByteStream(numBytes, func(offset int64) byte {
785+
content := newSyntheticByteStream(numBytes, duration, func(offset int64) byte {
764786
return byte(97 + (offset % 26))
765787
})
766788
var modtime time.Time

httpbin/handlers_test.go

+70
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"io"
1313
"log/slog"
14+
"mime"
1415
"mime/multipart"
1516
"net"
1617
"net/http"
@@ -2303,6 +2304,7 @@ func TestRange(t *testing.T) {
23032304
assert.Header(t, resp, "Accept-Ranges", "bytes")
23042305
assert.Header(t, resp, "Content-Length", "15")
23052306
assert.Header(t, resp, "Content-Range", "bytes 10-24/100")
2307+
assert.Header(t, resp, "Content-Type", textContentType)
23062308
assert.BodyEquals(t, resp, "klmnopqrstuvwxy")
23072309
})
23082310

@@ -2353,6 +2355,69 @@ func TestRange(t *testing.T) {
23532355
assert.BodyEquals(t, resp, "vwxyz")
23542356
})
23552357

2358+
t.Run("ok_range_with_duration", func(t *testing.T) {
2359+
t.Parallel()
2360+
2361+
url := "/range/100?duration=100ms"
2362+
req := newTestRequest(t, "GET", url)
2363+
req.Header.Add("Range", "bytes=10-24")
2364+
2365+
start := time.Now()
2366+
resp := must.DoReq(t, client, req)
2367+
elapsed := time.Since(start)
2368+
2369+
assert.StatusCode(t, resp, http.StatusPartialContent)
2370+
assert.Header(t, resp, "ETag", "range100")
2371+
assert.Header(t, resp, "Accept-Ranges", "bytes")
2372+
assert.Header(t, resp, "Content-Length", "15")
2373+
assert.Header(t, resp, "Content-Range", "bytes 10-24/100")
2374+
assert.Header(t, resp, "Content-Type", textContentType)
2375+
assert.BodyEquals(t, resp, "klmnopqrstuvwxy")
2376+
assert.DurationRange(t, elapsed, 100*time.Millisecond, 150*time.Millisecond)
2377+
})
2378+
2379+
t.Run("ok_multiple_ranges", func(t *testing.T) {
2380+
t.Parallel()
2381+
2382+
url := "/range/100"
2383+
req := newTestRequest(t, "GET", url)
2384+
req.Header.Add("Range", "bytes=10-24, 50-64")
2385+
2386+
resp := must.DoReq(t, client, req)
2387+
assert.StatusCode(t, resp, http.StatusPartialContent)
2388+
assert.Header(t, resp, "ETag", "range100")
2389+
assert.Header(t, resp, "Accept-Ranges", "bytes")
2390+
2391+
mediatype, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
2392+
assert.NilError(t, err)
2393+
assert.Equal(t, mediatype, "multipart/byteranges", "incorrect content type")
2394+
2395+
expectRanges := []struct {
2396+
contentRange string
2397+
body string
2398+
}{
2399+
{"bytes 10-24/100", "klmnopqrstuvwxy"},
2400+
{"bytes 50-64/100", "yzabcdefghijklm"},
2401+
}
2402+
mpr := multipart.NewReader(resp.Body, params["boundary"])
2403+
for i := 0; ; i++ {
2404+
p, err := mpr.NextPart()
2405+
if err == io.EOF {
2406+
break
2407+
}
2408+
assert.NilError(t, err)
2409+
2410+
ct := p.Header.Get("Content-Type")
2411+
assert.Equal(t, ct, textContentType, "incorrect content type")
2412+
2413+
cr := p.Header.Get("Content-Range")
2414+
assert.Equal(t, cr, expectRanges[i].contentRange, "incorrect Content-Range header")
2415+
2416+
part := must.ReadAll(t, p)
2417+
assert.Equal(t, string(part), expectRanges[i].body, "incorrect range part")
2418+
}
2419+
})
2420+
23562421
t.Run("err_range_out_of_bounds", func(t *testing.T) {
23572422
t.Parallel()
23582423

@@ -2400,6 +2465,11 @@ func TestRange(t *testing.T) {
24002465
{"/range/foo", http.StatusBadRequest},
24012466
{"/range/1.5", http.StatusBadRequest},
24022467
{"/range/-1", http.StatusBadRequest},
2468+
2469+
// invalid durations
2470+
{"/range/100?duration=-1", http.StatusBadRequest},
2471+
{"/range/100?duration=XYZ", http.StatusBadRequest},
2472+
{"/range/100?duration=2h", http.StatusBadRequest},
24032473
}
24042474

24052475
for _, test := range badTests {

httpbin/helpers.go

+14-7
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,21 @@ func parseSeed(rawSeed string) (*rand.Rand, error) {
314314
type syntheticByteStream struct {
315315
mu sync.Mutex
316316

317-
size int64
318-
offset int64
319-
factory func(int64) byte
317+
size int64
318+
factory func(int64) byte
319+
pausePerByte time.Duration
320+
321+
// internal offset for tracking the current position in the stream
322+
offset int64
320323
}
321324

322325
// newSyntheticByteStream returns a new stream of bytes of a specific size,
323326
// given a factory function for generating the byte at a given offset.
324-
func newSyntheticByteStream(size int64, factory func(int64) byte) io.ReadSeeker {
327+
func newSyntheticByteStream(size int64, duration time.Duration, factory func(int64) byte) io.ReadSeeker {
325328
return &syntheticByteStream{
326-
size: size,
327-
factory: factory,
329+
size: size,
330+
pausePerByte: duration / time.Duration(size),
331+
factory: factory,
328332
}
329333
}
330334

@@ -344,9 +348,12 @@ func (s *syntheticByteStream) Read(p []byte) (int, error) {
344348
for idx := start; idx < end; idx++ {
345349
p[idx-start] = s.factory(idx)
346350
}
347-
348351
s.offset = end
349352

353+
if s.pausePerByte > 0 {
354+
time.Sleep(s.pausePerByte * time.Duration(end-start))
355+
}
356+
350357
return int(end - start), err
351358
}
352359

httpbin/helpers_test.go

+70-18
Original file line numberDiff line numberDiff line change
@@ -148,33 +148,39 @@ func TestSyntheticByteStream(t *testing.T) {
148148

149149
t.Run("read", func(t *testing.T) {
150150
t.Parallel()
151-
s := newSyntheticByteStream(10, factory)
151+
s := newSyntheticByteStream(10, 0, factory)
152152

153153
// read first half
154-
p := make([]byte, 5)
155-
count, err := s.Read(p)
156-
assert.NilError(t, err)
157-
assert.Equal(t, count, 5, "incorrect number of bytes read")
158-
assert.DeepEqual(t, p, []byte{0, 1, 2, 3, 4}, "incorrect bytes read")
154+
{
155+
p := make([]byte, 5)
156+
count, err := s.Read(p)
157+
assert.NilError(t, err)
158+
assert.Equal(t, count, 5, "incorrect number of bytes read")
159+
assert.DeepEqual(t, p, []byte{0, 1, 2, 3, 4}, "incorrect bytes read")
160+
}
159161

160162
// read second half
161-
p = make([]byte, 5)
162-
count, err = s.Read(p)
163-
assert.Error(t, err, io.EOF)
164-
assert.Equal(t, count, 5, "incorrect number of bytes read")
165-
assert.DeepEqual(t, p, []byte{5, 6, 7, 8, 9}, "incorrect bytes read")
163+
{
164+
p := make([]byte, 5)
165+
count, err := s.Read(p)
166+
assert.Error(t, err, io.EOF)
167+
assert.Equal(t, count, 5, "incorrect number of bytes read")
168+
assert.DeepEqual(t, p, []byte{5, 6, 7, 8, 9}, "incorrect bytes read")
169+
}
166170

167171
// can't read any more
168-
p = make([]byte, 5)
169-
count, err = s.Read(p)
170-
assert.Error(t, err, io.EOF)
171-
assert.Equal(t, count, 0, "incorrect number of bytes read")
172-
assert.DeepEqual(t, p, []byte{0, 0, 0, 0, 0}, "incorrect bytes read")
172+
{
173+
p := make([]byte, 5)
174+
count, err := s.Read(p)
175+
assert.Error(t, err, io.EOF)
176+
assert.Equal(t, count, 0, "incorrect number of bytes read")
177+
assert.DeepEqual(t, p, []byte{0, 0, 0, 0, 0}, "incorrect bytes read")
178+
}
173179
})
174180

175181
t.Run("read into too-large buffer", func(t *testing.T) {
176182
t.Parallel()
177-
s := newSyntheticByteStream(5, factory)
183+
s := newSyntheticByteStream(5, 0, factory)
178184
p := make([]byte, 10)
179185
count, err := s.Read(p)
180186
assert.Error(t, err, io.EOF)
@@ -184,7 +190,7 @@ func TestSyntheticByteStream(t *testing.T) {
184190

185191
t.Run("seek", func(t *testing.T) {
186192
t.Parallel()
187-
s := newSyntheticByteStream(100, factory)
193+
s := newSyntheticByteStream(100, 0, factory)
188194

189195
p := make([]byte, 5)
190196
s.Seek(10, io.SeekStart)
@@ -211,6 +217,52 @@ func TestSyntheticByteStream(t *testing.T) {
211217
_, err = s.Seek(-10, io.SeekStart)
212218
assert.Equal(t, err.Error(), "Seek: invalid offset", "incorrect error for invalid offset")
213219
})
220+
221+
t.Run("read over duration", func(t *testing.T) {
222+
t.Parallel()
223+
s := newSyntheticByteStream(10, 200*time.Millisecond, factory)
224+
225+
// read first half
226+
{
227+
p := make([]byte, 5)
228+
start := time.Now()
229+
count, err := s.Read(p)
230+
elapsed := time.Since(start)
231+
232+
assert.NilError(t, err)
233+
assert.Equal(t, count, 5, "incorrect number of bytes read")
234+
assert.DeepEqual(t, p, []byte{0, 1, 2, 3, 4}, "incorrect bytes read")
235+
assert.DurationRange(t, elapsed, 100*time.Millisecond, 175*time.Millisecond)
236+
}
237+
238+
// read second half
239+
{
240+
p := make([]byte, 5)
241+
start := time.Now()
242+
count, err := s.Read(p)
243+
elapsed := time.Since(start)
244+
245+
assert.Error(t, err, io.EOF)
246+
assert.Equal(t, count, 5, "incorrect number of bytes read")
247+
assert.DeepEqual(t, p, []byte{5, 6, 7, 8, 9}, "incorrect bytes read")
248+
assert.DurationRange(t, elapsed, 100*time.Millisecond, 175*time.Millisecond)
249+
}
250+
251+
// can't read any more
252+
{
253+
p := make([]byte, 5)
254+
start := time.Now()
255+
count, err := s.Read(p)
256+
elapsed := time.Since(start)
257+
258+
assert.Error(t, err, io.EOF)
259+
assert.Equal(t, count, 0, "incorrect number of bytes read")
260+
assert.DeepEqual(t, p, []byte{0, 0, 0, 0, 0}, "incorrect bytes read")
261+
262+
// read should fail w/ EOF ~immediately
263+
assert.DurationRange(t, elapsed, 0, 25*time.Millisecond)
264+
}
265+
})
214266
}
215267

216268
func TestGetClientIP(t *testing.T) {

0 commit comments

Comments
 (0)