@marcel91200 / caddy Mirror · read-only / commits / 8f549e6d
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 03:30 UTC

timeouts: take precedence over server-wide and enclosing idle timeouts (#8121)

* timeouts: take precedence over server-wide and enclosing idle timeouts The timeouts handler wrapped the request body and response writer again, on top of the server's own idle timeout wrappers. The wrapper closest to the connection sets the deadline last, so the server-wide read_body_idle and write_idle (on by default since 2.11.6) silently overrode the route's settings. The same happened between nested timeouts handlers, where the outer, less specific one won. The server now makes its idle timeout wrappers available through the request context, and the timeouts handler overrides their settings while it runs, restoring them when it returns. It only wraps when there's nothing to override, and then registers its own wrappers so that nested handlers override those. The server-wide read_timeout and write_timeout still cap the whole transfer. A handler that doesn't set max_write_chunk keeps the server-wide one. Written with Claude Code (Claude Opus 5.5); the human submitter is responsible for reviewing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * timeouts: re-arm deadlines on override and suspend outer min-rate window From review: - On HTTP/2, a read or write deadline is a timer that fires even with no read or write in flight. A deadline armed before a timeouts handler started kept the outer timeout and could fail the body (or reset the stream) inside a longer inner scope before the next read reset it. Override and restore now move an armed deadline to the settings that apply. - Time spent under an override counted against the outer MinRate window, so the restored deadline could already have expired. The outer window is now suspended while overridden: its start moves forward by the time spent overridden, and bytes transferred in the meantime don't count towards it. Adds an HTTP/2 read-before-inner-scope regression test and nested minimum-rate coverage. Written with Claude Code (Claude Opus 5.5); the human submitter is responsible for reviewing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Francis Lavoie committed Oct 3, 2026 at 08:18 UTC 8f549e6df151f6a947bc183200c5d26baefa8666
5 files changed +459 -35
modules/caddyhttp/idletimeout.go
+117
@@ -15,6 +15,7 @@
15 package caddyhttp
16
17 import (
18 + "context"
19 "io"
20 "net/http"
21 "sync"
@@ -22,6 +23,8 @@ import (
23
24 "go.uber.org/zap"
25 "go.uber.org/zap/zapcore"
26 +
27 + "github.com/caddyserver/caddy/v2"
28 )
29
30 // DefaultMaxWriteChunk is used by IdleTimeoutWriter when MaxChunk is zero.
@@ -71,6 +74,35 @@ func (d *IdleDeadline) next() (deadline time.Time) {
74 return
75 }
76
77 +// idleOverride holds what restore needs to undo an override.
78 +type idleOverride struct {
79 + prev IdleDeadline
80 + at time.Time
81 +}
82 +
83 +// override replaces the timeout and minimum rate, counting the MinRate
84 +// allowance from now, and returns what restore needs to put the
85 +// previous settings back. HardDeadline is kept.
86 +func (d *IdleDeadline) override(timeout time.Duration, minRate int64) idleOverride {
87 + o := idleOverride{prev: *d, at: time.Now()}
88 + d.Start = o.at
89 + d.Timeout = timeout
90 + d.MinRate = minRate
91 + d.transferred = 0
92 + return o
93 +}
94 +
95 +// restore puts back the settings replaced by override. The previous
96 +// MinRate window is suspended while overridden: its start moves forward
97 +// by the time spent overridden, and bytes transferred in the meantime
98 +// don't count towards it, so the restored deadline can't already have
99 +// expired because of time the override allowed.
100 +func (d *IdleDeadline) restore(o idleOverride) {
101 + prev := o.prev
102 + prev.Start = prev.Start.Add(time.Since(o.at))
103 + *d = prev
104 +}
105 +
106 // IdleTimeoutReader wraps a request body with IdleDeadline, resetting
107 // the read deadline before every Read call instead of bounding the
108 // whole body transfer with a single hard deadline.
@@ -143,6 +175,35 @@ func (r *IdleTimeoutReader) HandlerDone() {
175 r.finished = true
176 }
177
178 +// Override makes the reader use timeout and minRate instead of its own
179 +// settings until the returned function is called, which puts them back.
180 +// The MinRate allowance is counted from the call, and HardDeadline still
181 +// applies. This lets a more specific scope, like the timeouts handler,
182 +// take precedence over the settings the reader was created with. A
183 +// deadline already armed is moved to the new settings both times, since
184 +// on HTTP/2 it is a timer that fails the body when it fires, even with
185 +// no read in flight.
186 +func (r *IdleTimeoutReader) Override(timeout time.Duration, minRate int64) (restore func()) {
187 + r.mu.Lock()
188 + o := r.Deadline.override(timeout, minRate)
189 + r.rearmLocked()
190 + r.mu.Unlock()
191 +
192 + return func() {
193 + r.mu.Lock()
194 + r.Deadline.restore(o)
195 + r.rearmLocked()
196 + r.mu.Unlock()
197 + }
198 +}
199 +
200 +// rearmLocked moves an armed deadline to what the current settings give.
201 +func (r *IdleTimeoutReader) rearmLocked() {
202 + if r.deadlineSet && !r.finished && !r.unsupported {
203 + r.setDeadlineLocked("could not set read deadline")
204 + }
205 +}
206 +
207 func (r *IdleTimeoutReader) setDeadlineLocked(logMessage string) {
208 if err := r.Ctrl.SetReadDeadline(r.Deadline.next()); err != nil {
209 r.unsupported = true
@@ -263,6 +324,36 @@ func (w *IdleTimeoutWriter) HandlerDone() {
324 }
325 }
326
327 +// Override makes the writer use timeout, minRate and, if positive,
328 +// maxChunk instead of its own settings until the returned function is
329 +// called, which puts them back. The MinRate allowance is counted from
330 +// the call, and HardDeadline still applies. This lets a more specific
331 +// scope, like the timeouts handler, take precedence over the settings
332 +// the writer was created with. A deadline already armed is moved to the
333 +// new settings both times, since on HTTP/2 it is a timer that resets
334 +// the stream when it fires.
335 +func (w *IdleTimeoutWriter) Override(timeout time.Duration, minRate int64, maxChunk int) (restore func()) {
336 + o := w.Deadline.override(timeout, minRate)
337 + prevMaxChunk := w.MaxChunk
338 + if maxChunk > 0 {
339 + w.MaxChunk = maxChunk
340 + }
341 + w.rearm()
342 +
343 + return func() {
344 + w.Deadline.restore(o)
345 + w.MaxChunk = prevMaxChunk
346 + w.rearm()
347 + }
348 +}
349 +
350 +// rearm moves an armed deadline to what the current settings give.
351 +func (w *IdleTimeoutWriter) rearm() {
352 + if w.deadlineSet {
353 + w.resetDeadline()
354 + }
355 +}
356 +
357 func (w *IdleTimeoutWriter) maxChunk() int {
358 if w.MaxChunk > 0 {
359 return w.MaxChunk
@@ -332,6 +423,32 @@ func (w *IdleTimeoutWriter) FlushError() error {
423 return nil
424 }
425
426 +// idleTimeouts holds the wrappers applying a request's idle timeouts.
427 +type idleTimeouts struct {
428 + reader *IdleTimeoutReader
429 + writer *IdleTimeoutWriter
430 +}
431 +
432 +// idleTimeoutsCtxKey is the context key for idleTimeouts.
433 +const idleTimeoutsCtxKey caddy.CtxKey = "idle_timeouts"
434 +
435 +// IdleTimeoutsFromContext returns the IdleTimeoutReader and
436 +// IdleTimeoutWriter that apply the idle timeouts of the request with
437 +// context ctx. Either is nil if no idle timeout is applied that way. A
438 +// handler with its own idle timeouts should Override these instead of
439 +// wrapping the body or response writer again, since the wrapper closest
440 +// to the connection sets the deadline last, and so would win.
441 +func IdleTimeoutsFromContext(ctx context.Context) (*IdleTimeoutReader, *IdleTimeoutWriter) {
442 + it, _ := ctx.Value(idleTimeoutsCtxKey).(idleTimeouts)
443 + return it.reader, it.writer
444 +}
445 +
446 +// ContextWithIdleTimeouts returns a copy of ctx in which
447 +// IdleTimeoutsFromContext returns reader and writer.
448 +func ContextWithIdleTimeouts(ctx context.Context, reader *IdleTimeoutReader, writer *IdleTimeoutWriter) context.Context {
449 + return context.WithValue(ctx, idleTimeoutsCtxKey, idleTimeouts{reader: reader, writer: writer})
450 +}
451 +
452 // Interface guards
453 var (
454 _ io.ReadCloser = (*IdleTimeoutReader)(nil)
modules/caddyhttp/idletimeout_test.go
+66
@@ -856,3 +856,69 @@ func TestIdleTimeoutWriter_MaxChunkOverride(t *testing.T) {
856 "a configured MaxChunk should override DefaultMaxWriteChunk")
857 assert.Equal(t, size/maxChunk, counter.writeCalls)
858 }
859 +
860 +func TestIdleDeadlineOverrideRestore(t *testing.T) {
861 + start := time.Now().Add(-time.Minute)
862 + hard := time.Now().Add(time.Hour)
863 + d := IdleDeadline{Start: start, Timeout: time.Second, MinRate: 10, HardDeadline: hard, transferred: 100}
864 +
865 + o := d.override(time.Minute, 0)
866 + assert.Equal(t, time.Minute, d.Timeout)
867 + assert.Zero(t, d.MinRate)
868 + assert.Zero(t, d.transferred)
869 + assert.Equal(t, hard, d.HardDeadline)
870 + assert.True(t, d.Start.After(start))
871 +
872 + // pretend the override lasted 10s and transferred some bytes; the
873 + // previous window is suspended for that long and ignores them
874 + o.at = o.at.Add(-10 * time.Second)
875 + d.transferred += 50
876 + before := time.Now()
877 + d.restore(o)
878 + after := time.Now()
879 +
880 + assert.Equal(t, time.Second, d.Timeout)
881 + assert.EqualValues(t, 10, d.MinRate)
882 + assert.EqualValues(t, 100, d.transferred)
883 + assert.Equal(t, hard, d.HardDeadline)
884 + assert.WithinRange(t, d.Start,
885 + start.Add(10*time.Second).Add(before.Sub(o.at.Add(10*time.Second))),
886 + start.Add(10*time.Second).Add(after.Sub(o.at.Add(10*time.Second))))
887 +}
888 +
889 +// TestIdleTimeoutReaderOverrideSuspendsMinRate checks that time spent
890 +// under nested overrides doesn't count against the minimum-rate windows
891 +// that are restored afterwards.
892 +func TestIdleTimeoutReaderOverrideSuspendsMinRate(t *testing.T) {
893 + const timeout = 200 * time.Millisecond
894 + const overridden = 150 * time.Millisecond
895 +
896 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
897 + r := &IdleTimeoutReader{
898 + ReadCloser: io.NopCloser(strings.NewReader("body")),
899 + Ctrl: http.NewResponseController(w),
900 + Deadline: IdleDeadline{Start: time.Now(), Timeout: timeout, MinRate: 1 << 30},
901 + Logger: zap.NewNop(),
902 + }
903 +
904 + // an outer scope with its own minimum rate, then an inner one; each
905 + // is overridden for longer than the outer timeout in total
906 + restoreOuter := r.Override(timeout, 1<<30)
907 + time.Sleep(overridden)
908 + restoreInner := r.Override(time.Hour, 0)
909 + time.Sleep(2 * overridden)
910 + restoreInner()
911 + _, err := r.Read(make([]byte, 1))
912 + require.NoError(t, err)
913 + deadlines := w.snapshot()
914 + require.NotEmpty(t, deadlines)
915 + assert.True(t, deadlines[len(deadlines)-1].After(time.Now()),
916 + "the restored outer scope's deadline should not have expired while overridden")
917 + restoreOuter()
918 +
919 + _, err = r.Read(make([]byte, 1))
920 + require.NoError(t, err)
921 + deadlines = w.snapshot()
922 + assert.True(t, deadlines[len(deadlines)-1].After(time.Now()),
923 + "the restored server-wide deadline should not have expired while overridden")
924 +}
modules/caddyhttp/server.go
+14 -3
@@ -585,8 +585,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
585 if s.WriteTimeout > 0 {
586 writeHardDeadline = start.Add(time.Duration(s.WriteTimeout))
587 }
588 + var idleReader *IdleTimeoutReader
589 + var idleWriter *IdleTimeoutWriter
590 if s.ReadIdleTimeout > 0 && r.Body != nil {
589 - idleReader := &IdleTimeoutReader{
591 + idleReader = &IdleTimeoutReader{
592 ReadCloser: r.Body,
593 Ctrl: rc,
594 Deadline: IdleDeadline{
@@ -602,7 +604,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
604 r.Body = idleReader
605 }
606 if s.WriteIdleTimeout > 0 {
605 - idleWriter := &IdleTimeoutWriter{
607 + idleWriter = &IdleTimeoutWriter{
608 ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
609 Ctrl: rc,
610 Deadline: IdleDeadline{
@@ -638,7 +640,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
640
641 // prepare internals of the request for the handler pipeline
642 repl := caddy.NewReplacer()
641 - r = PrepareRequest(r, repl, w, s)
643 + r = prepareRequest(r, repl, w, s, idleTimeouts{reader: idleReader, writer: idleWriter})
644
645 // clone the request for logging purposes before it enters any handler chain;
646 // this is necessary to capture the original request in case it gets modified
@@ -1370,8 +1372,17 @@ func (s *Server) Name() string { return s.name }
1372 // PrepareRequest fills the request r for use in a Caddy HTTP handler chain. w and s can
1373 // be nil, but the handlers will lose response placeholders and access to the server.
1374 func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter, s *Server) *http.Request {
1375 + return prepareRequest(r, repl, w, s, idleTimeouts{})
1376 +}
1377 +
1378 +// prepareRequest is PrepareRequest, also making the wrappers applying
1379 +// the server's idle timeouts, if any, available to handlers.
1380 +func prepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter, s *Server, idle idleTimeouts) *http.Request {
1381 // set up the context for the request
1382 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
1383 + if idle.reader != nil || idle.writer != nil {
1384 + ctx = context.WithValue(ctx, idleTimeoutsCtxKey, idle)
1385 + }
1386 ctx = context.WithValue(ctx, ServerCtxKey, s)
1387
1388 trusted, clientIP := determineTrustedProxy(r, s)
modules/caddyhttp/timeouts/timeouts.go
+62 -32
@@ -30,12 +30,15 @@ func init() {
30
31 // Timeouts is a middleware that applies read/write idle timeouts, minimum
32 // transfer rates, and a write chunk size cap to routes matching this
33 -// handler, independent of the server-wide equivalents.
33 +// handler. They take precedence over the server-wide equivalents, and
34 +// over those of any enclosing timeouts handler, for as long as this
35 +// handler is running. The server-wide read_timeout and write_timeout
36 +// still cap the whole transfer.
37 type Timeouts struct {
38 // How long to allow a read from the request body to stall before
39 // aborting the connection, reset on every successful read (like the
40 // server-wide read_idle_timeout, but scoped to routes matching this
38 - // handler). If zero, no idle timeout is applied here.
41 + // handler). If zero, the server-wide setting applies.
42 // EXPERIMENTAL. Subject to change/removal.
43 ReadTimeout time.Duration `json:"read_timeout,omitempty"`
44
@@ -49,7 +52,7 @@ type Timeouts struct {
52 // How long to allow a write to the client to stall before aborting
53 // the connection, reset on every successful write (like the
54 // server-wide write_idle_timeout, but scoped to routes matching this
52 - // handler). If zero, no idle timeout is applied here.
55 + // handler). If zero, the server-wide setting applies.
56 // EXPERIMENTAL. Subject to change/removal.
57 WriteTimeout time.Duration `json:"write_timeout,omitempty"`
58
@@ -61,8 +64,9 @@ type Timeouts struct {
64 // MaxWriteChunk bounds how many bytes a single underlying write
65 // operation is allowed to cover, so WriteTimeout/WriteMinRate can
66 // actually apply between chunks of a large response instead of
64 - // being bounded by one deadline for the whole thing. If zero,
65 - // caddyhttp.DefaultMaxWriteChunk is used.
67 + // being bounded by one deadline for the whole thing. If zero, the
68 + // server-wide setting is used, or caddyhttp.DefaultMaxWriteChunk if
69 + // there is none.
70 // EXPERIMENTAL. Subject to change/removal.
71 MaxWriteChunk int `json:"max_write_chunk,omitempty"`
72
@@ -87,40 +91,66 @@ func (t Timeouts) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
91 return next.ServeHTTP(w, r)
92 }
93
90 - //nolint:bodyclose
91 - rc := http.NewResponseController(w)
94 + // if the request already has idle timeouts, from the server or an
95 + // enclosing route, override them instead of wrapping again: the
96 + // wrapper closest to the connection sets the deadline last, so it
97 + // would take precedence over ours
98 + idleReader, idleWriter := caddyhttp.IdleTimeoutsFromContext(r.Context())
99 + var wrapped bool
100 + var rc *http.ResponseController
101 start := time.Now()
102
103 if t.ReadTimeout > 0 && r.Body != nil {
95 - idleReader := &caddyhttp.IdleTimeoutReader{
96 - ReadCloser: r.Body,
97 - Ctrl: rc,
98 - Deadline: caddyhttp.IdleDeadline{
99 - Start: start,
100 - Timeout: t.ReadTimeout,
101 - MinRate: t.ReadMinRate,
102 - },
103 - Logger: t.logger,
104 - DrainDeadline: r.ProtoMajor == 1 && r.ContentLength != 0,
104 + if idleReader != nil {
105 + defer idleReader.Override(t.ReadTimeout, t.ReadMinRate)()
106 + } else {
107 + //nolint:bodyclose
108 + rc = http.NewResponseController(w)
109 + idleReader = &caddyhttp.IdleTimeoutReader{
110 + ReadCloser: r.Body,
111 + Ctrl: rc,
112 + Deadline: caddyhttp.IdleDeadline{
113 + Start: start,
114 + Timeout: t.ReadTimeout,
115 + MinRate: t.ReadMinRate,
116 + },
117 + Logger: t.logger,
118 + DrainDeadline: r.ProtoMajor == 1 && r.ContentLength != 0,
119 + }
120 + defer idleReader.HandlerDone()
121 + r.Body = idleReader
122 + wrapped = true
123 }
106 - defer idleReader.HandlerDone()
107 - r.Body = idleReader
124 }
125 if t.WriteTimeout > 0 {
110 - idleWriter := &caddyhttp.IdleTimeoutWriter{
111 - ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w},
112 - Ctrl: rc,
113 - Deadline: caddyhttp.IdleDeadline{
114 - Start: start,
115 - Timeout: t.WriteTimeout,
116 - MinRate: t.WriteMinRate,
117 - },
118 - MaxChunk: t.MaxWriteChunk,
119 - Logger: t.logger,
120 - ClearBetweenWrites: r.ProtoMajor == 2,
126 + if idleWriter != nil {
127 + defer idleWriter.Override(t.WriteTimeout, t.WriteMinRate, t.MaxWriteChunk)()
128 + } else {
129 + if rc == nil {
130 + //nolint:bodyclose
131 + rc = http.NewResponseController(w)
132 + }
133 + idleWriter = &caddyhttp.IdleTimeoutWriter{
134 + ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w},
135 + Ctrl: rc,
136 + Deadline: caddyhttp.IdleDeadline{
137 + Start: start,
138 + Timeout: t.WriteTimeout,
139 + MinRate: t.WriteMinRate,
140 + },
141 + MaxChunk: t.MaxWriteChunk,
142 + Logger: t.logger,
143 + ClearBetweenWrites: r.ProtoMajor == 2,
144 + }
145 + defer idleWriter.HandlerDone()
146 + w = idleWriter
147 + wrapped = true
148 }
122 - defer idleWriter.HandlerDone()
123 - w = idleWriter
149 + }
150 +
151 + // let nested timeouts handlers override the wrappers added here
152 + if wrapped {
153 + r = r.WithContext(caddyhttp.ContextWithIdleTimeouts(r.Context(), idleReader, idleWriter))
154 }
155
156 return next.ServeHTTP(w, r)
modules/caddyhttp/timeouts/timeouts_test.go
+200
@@ -148,3 +148,203 @@ func TestTimeouts_WriteMaxChunkOverride(t *testing.T) {
148 require.NoError(t, err)
149 assert.EqualValues(t, size, n)
150 }
151 +
152 +// TestTimeoutsTakePrecedence checks that the read timeout of the most
153 +// specific scope applies: a timeouts handler over the server-wide idle
154 +// timeout, and an inner timeouts handler over an outer one.
155 +func TestTimeoutsTakePrecedence(t *testing.T) {
156 + const short = 100 * time.Millisecond
157 + const long = 2 * time.Second
158 + const pause = 300 * time.Millisecond
159 +
160 + for i, tc := range []struct {
161 + name string
162 + server time.Duration
163 + outer time.Duration
164 + inner time.Duration
165 + expected int
166 + }{
167 + {name: "route extends server-wide", server: short, inner: long, expected: http.StatusOK},
168 + {name: "route shortens server-wide", server: long, inner: short, expected: http.StatusRequestTimeout},
169 + {name: "inner route extends outer", outer: short, inner: long, expected: http.StatusOK},
170 + {name: "inner route shortens outer", outer: long, inner: short, expected: http.StatusRequestTimeout},
171 + {name: "inner route extends outer and server-wide", server: short, outer: short, inner: long, expected: http.StatusOK},
172 + } {
173 + var handler caddyhttp.Handler = caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
174 + if _, err := io.Copy(io.Discard, r.Body); err != nil {
175 + w.WriteHeader(http.StatusRequestTimeout)
176 + return nil
177 + }
178 + w.WriteHeader(http.StatusOK)
179 + return nil
180 + })
181 + for _, timeout := range []time.Duration{tc.inner, tc.outer} {
182 + if timeout == 0 {
183 + continue
184 + }
185 + tm := Timeouts{ReadTimeout: timeout, logger: zap.NewNop()}
186 + next := handler
187 + handler = caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
188 + return tm.ServeHTTP(w, r, next)
189 + })
190 + }
191 +
192 + srv := httptest.NewServer(noError(caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
193 + // stand in for the server-wide idle timeout
194 + if tc.server > 0 {
195 + reader := &caddyhttp.IdleTimeoutReader{
196 + ReadCloser: r.Body,
197 + Ctrl: http.NewResponseController(w),
198 + Deadline: caddyhttp.IdleDeadline{Start: time.Now(), Timeout: tc.server},
199 + Logger: zap.NewNop(),
200 + }
201 + defer reader.HandlerDone()
202 + r.Body = reader
203 + r = r.WithContext(caddyhttp.ContextWithIdleTimeouts(r.Context(), reader, nil))
204 + }
205 + return handler.ServeHTTP(w, r)
206 + })))
207 +
208 + body := &pacedReader{delay: pause, chunkSize: 8, chunkCount: 2}
209 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
210 + if err != nil {
211 + // a timed out read may close the connection before a response
212 + if tc.expected != http.StatusRequestTimeout {
213 + t.Errorf("Test %d (%s): unexpected error: %v", i, tc.name, err)
214 + }
215 + } else {
216 + resp.Body.Close()
217 + assert.Equal(t, tc.expected, resp.StatusCode, "Test %d (%s)", i, tc.name)
218 + }
219 + srv.Close()
220 + }
221 +}
222 +
223 +func TestTimeoutsOverrideServerWide(t *testing.T) {
224 + const serverTimeout = time.Hour
225 + const routeTimeout = time.Minute
226 +
227 + rec := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
228 + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("body"))
229 + reader := &caddyhttp.IdleTimeoutReader{
230 + ReadCloser: req.Body,
231 + Ctrl: http.NewResponseController(rec),
232 + Deadline: caddyhttp.IdleDeadline{Start: time.Now(), Timeout: serverTimeout},
233 + Logger: zap.NewNop(),
234 + }
235 + req.Body = reader
236 + writer := &caddyhttp.IdleTimeoutWriter{
237 + ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: rec},
238 + Ctrl: http.NewResponseController(rec),
239 + Deadline: caddyhttp.IdleDeadline{Start: time.Now(), Timeout: serverTimeout},
240 + MaxChunk: 1000,
241 + Logger: zap.NewNop(),
242 + }
243 + req = req.WithContext(caddyhttp.ContextWithIdleTimeouts(req.Context(), reader, writer))
244 +
245 + tm := Timeouts{ReadTimeout: routeTimeout, WriteTimeout: routeTimeout, MaxWriteChunk: 100, logger: zap.NewNop()}
246 + err := tm.ServeHTTP(writer, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
247 + // overridden in place rather than wrapped again
248 + assert.Same(t, writer, w)
249 + assert.Same(t, reader, r.Body)
250 + assert.Equal(t, routeTimeout, writer.Deadline.Timeout)
251 + assert.Equal(t, 100, writer.MaxChunk)
252 +
253 + before := time.Now()
254 + _, err := r.Body.Read(make([]byte, 1))
255 + require.NoError(t, err)
256 + require.Len(t, rec.deadlines, 1)
257 + assert.WithinRange(t, rec.deadlines[0], before.Add(routeTimeout), time.Now().Add(routeTimeout))
258 + return nil
259 + }))
260 + require.NoError(t, err)
261 +
262 + // the server-wide settings are back once the handler returns, and
263 + // the armed read deadline was moved to them
264 + assert.Equal(t, serverTimeout, writer.Deadline.Timeout)
265 + assert.Equal(t, 1000, writer.MaxChunk)
266 + require.Len(t, rec.deadlines, 2)
267 + assert.WithinRange(t, rec.deadlines[1], time.Now().Add(serverTimeout-time.Second), time.Now().Add(serverTimeout))
268 +
269 + before := time.Now()
270 + _, err = reader.Read(make([]byte, 1))
271 + require.NoError(t, err)
272 + require.Len(t, rec.deadlines, 3)
273 + assert.WithinRange(t, rec.deadlines[2], before.Add(serverTimeout), time.Now().Add(serverTimeout))
274 +}
275 +
276 +func TestTimeoutsKeepServerWideMaxWriteChunk(t *testing.T) {
277 + rec := httptest.NewRecorder()
278 + writer := &caddyhttp.IdleTimeoutWriter{
279 + ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: rec},
280 + Ctrl: http.NewResponseController(rec),
281 + Deadline: caddyhttp.IdleDeadline{Timeout: time.Hour},
282 + MaxChunk: 1000,
283 + Logger: zap.NewNop(),
284 + }
285 + req := httptest.NewRequest(http.MethodGet, "/", nil)
286 + req = req.WithContext(caddyhttp.ContextWithIdleTimeouts(req.Context(), nil, writer))
287 +
288 + tm := Timeouts{WriteTimeout: time.Minute, logger: zap.NewNop()}
289 + err := tm.ServeHTTP(writer, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
290 + assert.Equal(t, 1000, writer.MaxChunk)
291 + return nil
292 + }))
293 + require.NoError(t, err)
294 +}
295 +
296 +// TestTimeoutsRearmReadDeadlineHTTP2 checks that a read deadline armed
297 +// before a timeouts handler starts is moved to its timeout. On HTTP/2
298 +// the deadline is a timer that fails the body when it fires, even with
299 +// no read in flight, so it can't wait for the next read to be reset.
300 +func TestTimeoutsRearmReadDeadlineHTTP2(t *testing.T) {
301 + const serverTimeout = 100 * time.Millisecond
302 + const routeTimeout = 2 * time.Second
303 + const pause = 300 * time.Millisecond
304 +
305 + tm := Timeouts{ReadTimeout: routeTimeout, logger: zap.NewNop()}
306 + srv := httptest.NewUnstartedServer(noError(caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
307 + // stand in for the server-wide idle timeout
308 + reader := &caddyhttp.IdleTimeoutReader{
309 + ReadCloser: r.Body,
310 + Ctrl: http.NewResponseController(w),
311 + Deadline: caddyhttp.IdleDeadline{Start: time.Now(), Timeout: serverTimeout},
312 + Logger: zap.NewNop(),
313 + }
314 + defer reader.HandlerDone()
315 + r.Body = reader
316 + r = r.WithContext(caddyhttp.ContextWithIdleTimeouts(r.Context(), reader, nil))
317 +
318 + // read before the timeouts handler, arming the server-wide deadline
319 + if _, err := io.ReadFull(r.Body, make([]byte, 8)); err != nil {
320 + w.WriteHeader(http.StatusBadRequest)
321 + return nil
322 + }
323 + return tm.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
324 + // pause longer than the server-wide timeout, but not the route's
325 + time.Sleep(pause)
326 + if _, err := io.Copy(io.Discard, r.Body); err != nil {
327 + w.WriteHeader(http.StatusRequestTimeout)
328 + return nil
329 + }
330 + w.WriteHeader(http.StatusOK)
331 + return nil
332 + }))
333 + })))
334 + srv.EnableHTTP2 = true
335 + srv.StartTLS()
336 + defer srv.Close()
337 +
338 + pr, pw := io.Pipe()
339 + go func() {
340 + _, _ = pw.Write(make([]byte, 8))
341 + time.Sleep(pause + 100*time.Millisecond)
342 + _, _ = pw.Write(make([]byte, 8))
343 + _ = pw.Close()
344 + }()
345 + resp, err := srv.Client().Post(srv.URL, "application/octet-stream", pr)
346 + require.NoError(t, err)
347 + defer resp.Body.Close()
348 + require.Equal(t, 2, resp.ProtoMajor)
349 + assert.Equal(t, http.StatusOK, resp.StatusCode)
350 +}