@marcel91200 / caddy Mirror · read-only / commits / ab7e6b6d
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, 04:45 UTC

caddyhttp: end request-body deadline ownership with handler (#8107)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Francis Lavoie <lavofr@gmail.com>

Zen Dodd committed Oct 3, 2026 at 12:10 UTC ab7e6b6d81cfc42eaabd5ffb172fd912bb7624cf
5 files changed +418 -11
modules/caddyhttp/idletimeout.go
+74 -7
@@ -17,6 +17,7 @@ package caddyhttp
17 import (
18 "io"
19 "net/http"
20 + "sync"
21 "time"
22
23 "go.uber.org/zap"
@@ -79,25 +80,91 @@ type IdleTimeoutReader struct {
80 Deadline IdleDeadline
81 Logger *zap.Logger
82
83 + // DrainDeadline makes HandlerDone arm a final idle deadline if
84 + // the body is unfinished, bounding net/http's post-handler drain.
85 + // Only HTTP/1 drains a request body on the connection after the
86 + // handler returns, so it is pointless (and costs a timer on
87 + // HTTP/2) for other protocols or requests without a body.
88 + DrainDeadline bool
89 +
90 + mu sync.Mutex
91 unsupported bool
92 + deadlineSet bool
93 + finished bool
94 + terminalErr error
95 }
96
97 func (r *IdleTimeoutReader) Read(p []byte) (int, error) {
86 - if !r.unsupported {
87 - if err := r.Ctrl.SetReadDeadline(r.Deadline.next()); err != nil {
88 - r.unsupported = true
89 - if c := r.Logger.Check(zapcore.DebugLevel, "could not set read deadline"); c != nil {
90 - c.Write(zap.Error(err))
91 - }
92 - }
98 + r.mu.Lock()
99 + if r.terminalErr != nil {
100 + err := r.terminalErr
101 + r.mu.Unlock()
102 + return 0, err
103 }
104 + if !r.finished && !r.unsupported {
105 + r.setDeadlineLocked("could not set read deadline")
106 + }
107 + r.mu.Unlock()
108
109 n, err := r.ReadCloser.Read(p)
110 +
111 + r.mu.Lock()
112 r.Deadline.transferred += int64(n)
113 + if err != nil {
114 + r.terminalErr = err
115 + if !r.finished {
116 + r.clearDeadlineLocked()
117 + }
118 + }
119 + r.mu.Unlock()
120
121 return n, err
122 }
123
124 +// HandlerDone prevents later body reads from using the response controller.
125 +// If the body is not finished and DrainDeadline is set, it leaves an idle
126 +// deadline armed for net/http's post-handler drain. It must be called before
127 +// the installing handler returns.
128 +func (r *IdleTimeoutReader) HandlerDone() {
129 + r.mu.Lock()
130 + defer r.mu.Unlock()
131 +
132 + if r.finished {
133 + return
134 + }
135 + if r.terminalErr != nil {
136 + r.clearDeadlineLocked()
137 + } else if r.DrainDeadline && !r.deadlineSet && !r.unsupported {
138 + r.setDeadlineLocked("could not set final read deadline")
139 + }
140 + r.finished = true
141 +}
142 +
143 +func (r *IdleTimeoutReader) setDeadlineLocked(logMessage string) {
144 + if err := r.Ctrl.SetReadDeadline(r.Deadline.next()); err != nil {
145 + r.unsupported = true
146 + if c := r.Logger.Check(zapcore.DebugLevel, logMessage); c != nil {
147 + c.Write(zap.Error(err))
148 + }
149 + return
150 + }
151 + r.deadlineSet = true
152 +}
153 +
154 +func (r *IdleTimeoutReader) clearDeadlineLocked() {
155 + if !r.deadlineSet {
156 + return
157 + }
158 + if err := r.Ctrl.SetReadDeadline(time.Time{}); err != nil {
159 + r.unsupported = true
160 + if c := r.Logger.Check(zapcore.DebugLevel, "could not clear read deadline"); c != nil {
161 + c.Write(zap.Error(err))
162 + }
163 + return
164 + }
165 + r.deadlineSet = false
166 +}
167 +
168 // IdleTimeoutWriter wraps a ResponseWriter with IdleDeadline, resetting
169 // the write deadline before every Write call, the same way
170 // IdleTimeoutReader does for reads. A handler that pauses between
modules/caddyhttp/idletimeout_test.go
+292
@@ -16,9 +16,13 @@ package caddyhttp
16
17 import (
18 "bytes"
19 + "errors"
20 + "fmt"
21 "io"
22 "net/http"
23 "net/http/httptest"
24 + "strings"
25 + "sync"
26 "testing"
27 "time"
28
@@ -27,6 +31,43 @@ import (
31 "go.uber.org/zap"
32 )
33
34 +type readDeadlineRecorder struct {
35 + *httptest.ResponseRecorder
36 + mu sync.Mutex
37 + deadlines []time.Time
38 + failAt int
39 +}
40 +
41 +func (w *readDeadlineRecorder) SetReadDeadline(deadline time.Time) error {
42 + w.mu.Lock()
43 + defer w.mu.Unlock()
44 + w.deadlines = append(w.deadlines, deadline)
45 + if len(w.deadlines) == w.failAt {
46 + return errors.New("deadline failure")
47 + }
48 + return nil
49 +}
50 +
51 +func (w *readDeadlineRecorder) snapshot() []time.Time {
52 + w.mu.Lock()
53 + defer w.mu.Unlock()
54 + return append([]time.Time(nil), w.deadlines...)
55 +}
56 +
57 +type blockingReadCloser struct {
58 + started chan struct{}
59 + release chan struct{}
60 + once sync.Once
61 +}
62 +
63 +func (r *blockingReadCloser) Read([]byte) (int, error) {
64 + r.once.Do(func() { close(r.started) })
65 + <-r.release
66 + return 0, io.EOF
67 +}
68 +
69 +func (*blockingReadCloser) Close() error { return nil }
70 +
71 // pacedReader emits chunkCount chunks of chunkSize bytes, sleeping delay
72 // before each one, simulating a client that trickles a request body.
73 type pacedReader struct {
@@ -87,6 +128,257 @@ func TestIdleTimeoutReader(t *testing.T) {
128 })
129 }
130
131 +func TestIdleTimeoutReaderClearsDeadlineAtEOF(t *testing.T) {
132 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
133 + r := &IdleTimeoutReader{
134 + ReadCloser: io.NopCloser(strings.NewReader("body")),
135 + Ctrl: http.NewResponseController(w),
136 + Deadline: IdleDeadline{Timeout: time.Second},
137 + Logger: zap.NewNop(),
138 + }
139 +
140 + _, err := io.Copy(io.Discard, r)
141 + require.NoError(t, err)
142 + deadlines := w.snapshot()
143 + require.NotEmpty(t, deadlines)
144 + assert.True(t, deadlines[len(deadlines)-1].IsZero())
145 +}
146 +
147 +func TestIdleTimeoutReaderHandlerDone(t *testing.T) {
148 + body := &blockingReadCloser{
149 + started: make(chan struct{}),
150 + release: make(chan struct{}),
151 + }
152 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
153 + r := &IdleTimeoutReader{
154 + ReadCloser: body,
155 + Ctrl: http.NewResponseController(w),
156 + Deadline: IdleDeadline{Timeout: time.Second},
157 + Logger: zap.NewNop(),
158 + }
159 +
160 + readDone := make(chan struct{})
161 + go func() {
162 + defer close(readDone)
163 + _, _ = r.Read(make([]byte, 1))
164 + }()
165 + <-body.started
166 +
167 + r.HandlerDone()
168 + deadlines := w.snapshot()
169 + require.Len(t, deadlines, 1)
170 + assert.False(t, deadlines[0].IsZero())
171 +
172 + close(body.release)
173 + <-readDone
174 + _, _ = r.Read(make([]byte, 1))
175 + r.HandlerDone()
176 + assert.Len(t, w.snapshot(), len(deadlines))
177 +}
178 +
179 +func TestIdleTimeoutReaderHandlerDoneAfterResetFailure(t *testing.T) {
180 + w := &readDeadlineRecorder{
181 + ResponseRecorder: httptest.NewRecorder(),
182 + failAt: 2,
183 + }
184 + r := &IdleTimeoutReader{
185 + ReadCloser: io.NopCloser(strings.NewReader("body")),
186 + Ctrl: http.NewResponseController(w),
187 + Deadline: IdleDeadline{Timeout: time.Second},
188 + Logger: zap.NewNop(),
189 + }
190 +
191 + _, _ = r.Read(make([]byte, 1))
192 + _, _ = r.Read(make([]byte, 1))
193 + r.HandlerDone()
194 +
195 + deadlines := w.snapshot()
196 + require.Len(t, deadlines, 2)
197 + assert.False(t, deadlines[0].IsZero())
198 + assert.False(t, deadlines[1].IsZero())
199 +}
200 +
201 +func TestIdleTimeoutReaderHandlerDoneRetriesTerminalClear(t *testing.T) {
202 + w := &readDeadlineRecorder{
203 + ResponseRecorder: httptest.NewRecorder(),
204 + failAt: 3,
205 + }
206 + r := &IdleTimeoutReader{
207 + ReadCloser: io.NopCloser(strings.NewReader("body")),
208 + Ctrl: http.NewResponseController(w),
209 + Deadline: IdleDeadline{Timeout: time.Second},
210 + Logger: zap.NewNop(),
211 + }
212 +
213 + _, err := io.Copy(io.Discard, r)
214 + require.NoError(t, err)
215 + r.HandlerDone()
216 +
217 + deadlines := w.snapshot()
218 + require.Len(t, deadlines, 4)
219 + assert.False(t, deadlines[0].IsZero())
220 + assert.False(t, deadlines[1].IsZero())
221 + assert.True(t, deadlines[2].IsZero())
222 + assert.True(t, deadlines[3].IsZero())
223 +}
224 +
225 +func TestIdleTimeoutReaderHandlerDoneArmsDrain(t *testing.T) {
226 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
227 + r := &IdleTimeoutReader{
228 + ReadCloser: io.NopCloser(strings.NewReader("body")),
229 + Ctrl: http.NewResponseController(w),
230 + Deadline: IdleDeadline{Timeout: time.Second},
231 + Logger: zap.NewNop(),
232 + DrainDeadline: true,
233 + }
234 +
235 + r.HandlerDone()
236 + deadlines := w.snapshot()
237 + require.Len(t, deadlines, 1)
238 + assert.False(t, deadlines[0].IsZero())
239 +}
240 +
241 +func TestIdleTimeoutReaderHandlerDoneWithoutDrainDeadline(t *testing.T) {
242 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
243 + r := &IdleTimeoutReader{
244 + ReadCloser: io.NopCloser(strings.NewReader("body")),
245 + Ctrl: http.NewResponseController(w),
246 + Deadline: IdleDeadline{Timeout: time.Second},
247 + Logger: zap.NewNop(),
248 + }
249 +
250 + r.HandlerDone()
251 + assert.Empty(t, w.snapshot())
252 +}
253 +
254 +func TestIdleTimeoutReaderAfterHandlerReturnsHTTP2(t *testing.T) {
255 + lateBody := make(chan io.ReadCloser, 1)
256 + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257 + wrapped := &IdleTimeoutReader{
258 + ReadCloser: r.Body,
259 + Ctrl: http.NewResponseController(w),
260 + Deadline: IdleDeadline{Timeout: time.Second},
261 + Logger: zap.NewNop(),
262 + }
263 + defer wrapped.HandlerDone()
264 + lateBody <- wrapped
265 + _, _ = io.WriteString(w, "ok")
266 + }))
267 + srv.EnableHTTP2 = true
268 + srv.StartTLS()
269 + defer srv.Close()
270 +
271 + resp, err := srv.Client().Post(srv.URL, "text/plain", strings.NewReader("body"))
272 + require.NoError(t, err)
273 + responseBody, err := io.ReadAll(resp.Body)
274 + require.NoError(t, err)
275 + require.NoError(t, resp.Body.Close())
276 + require.Equal(t, 2, resp.ProtoMajor)
277 + require.Equal(t, "ok", string(responseBody))
278 +
279 + readResult := make(chan error, 1)
280 + go func() {
281 + var result error
282 + defer func() {
283 + if recovered := recover(); recovered != nil {
284 + result = fmt.Errorf("late request-body read panicked: %v", recovered)
285 + }
286 + readResult <- result
287 + }()
288 + _, _ = io.Copy(io.Discard, <-lateBody)
289 + }()
290 + select {
291 + case err := <-readResult:
292 + assert.NoError(t, err)
293 + case <-time.After(time.Second):
294 + t.Fatal("late request-body read did not return")
295 + }
296 +}
297 +
298 +func TestIdleTimeoutReaderHandlerDonePreservesHTTP1Connection(t *testing.T) {
299 + const idle = 100 * time.Millisecond
300 +
301 + remoteAddresses := make(chan string, 2)
302 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303 + wrapped := &IdleTimeoutReader{
304 + ReadCloser: r.Body,
305 + Ctrl: http.NewResponseController(w),
306 + Deadline: IdleDeadline{Timeout: idle},
307 + Logger: zap.NewNop(),
308 + DrainDeadline: r.ProtoMajor == 1 && r.ContentLength != 0,
309 + }
310 + defer wrapped.HandlerDone()
311 + remoteAddresses <- r.RemoteAddr
312 + if r.Method == http.MethodPost {
313 + _, _ = wrapped.Read(make([]byte, 1))
314 + }
315 + w.WriteHeader(http.StatusNoContent)
316 + }))
317 + defer srv.Close()
318 +
319 + transport := &http.Transport{MaxConnsPerHost: 1}
320 + client := &http.Client{Transport: transport}
321 + defer transport.CloseIdleConnections()
322 +
323 + resp, err := client.Post(srv.URL, "text/plain", strings.NewReader("body"))
324 + require.NoError(t, err)
325 + require.NoError(t, resp.Body.Close())
326 + require.Equal(t, http.StatusNoContent, resp.StatusCode)
327 + firstAddress := <-remoteAddresses
328 +
329 + time.Sleep(3 * idle)
330 + resp, err = client.Get(srv.URL)
331 + require.NoError(t, err)
332 + require.NoError(t, resp.Body.Close())
333 + require.Equal(t, http.StatusNoContent, resp.StatusCode)
334 + assert.Equal(t, firstAddress, <-remoteAddresses)
335 +}
336 +
337 +func TestIdleTimeoutReaderDeadlineClearedAfterBodyEOF(t *testing.T) {
338 + const idle = 150 * time.Millisecond
339 +
340 + upstream := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
341 + _, _ = io.Copy(io.Discard, r.Body)
342 + }))
343 + defer upstream.Close()
344 +
345 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346 + wrapped := &IdleTimeoutReader{
347 + ReadCloser: r.Body,
348 + Ctrl: http.NewResponseController(w),
349 + Deadline: IdleDeadline{Timeout: idle},
350 + Logger: zap.NewNop(),
351 + }
352 + defer wrapped.HandlerDone()
353 +
354 + upReq, err := http.NewRequestWithContext(r.Context(), r.Method, upstream.URL, wrapped)
355 + if err != nil {
356 + t.Error(err)
357 + return
358 + }
359 + upReq.ContentLength = r.ContentLength
360 + resp, err := http.DefaultClient.Do(upReq)
361 + if err != nil {
362 + t.Error(err)
363 + return
364 + }
365 + _ = resp.Body.Close()
366 +
367 + select {
368 + case <-r.Context().Done():
369 + w.WriteHeader(http.StatusTeapot)
370 + case <-time.After(3 * idle):
371 + w.WriteHeader(http.StatusOK)
372 + }
373 + }))
374 + defer srv.Close()
375 +
376 + resp, err := http.Post(srv.URL, "application/octet-stream", strings.NewReader("{}"))
377 + require.NoError(t, err)
378 + defer resp.Body.Close()
379 + assert.Equal(t, http.StatusOK, resp.StatusCode)
380 +}
381 +
382 func TestIdleTimeoutReader_HardDeadlineCapsIdleReset(t *testing.T) {
383 const idle = 500 * time.Millisecond
384 const hard = 200 * time.Millisecond
modules/caddyhttp/server.go
+5 -2
@@ -586,7 +586,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
586 writeHardDeadline = start.Add(time.Duration(s.WriteTimeout))
587 }
588 if s.ReadIdleTimeout > 0 && r.Body != nil {
589 - r.Body = &IdleTimeoutReader{
589 + idleReader := &IdleTimeoutReader{
590 ReadCloser: r.Body,
591 Ctrl: rc,
592 Deadline: IdleDeadline{
@@ -595,8 +595,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
595 MinRate: s.ReadMinRate,
596 HardDeadline: readHardDeadline,
597 },
598 - Logger: s.logger,
598 + Logger: s.logger,
599 + DrainDeadline: r.ProtoMajor == 1 && r.ContentLength != 0,
600 }
601 + defer idleReader.HandlerDone()
602 + r.Body = idleReader
603 }
604 if s.WriteIdleTimeout > 0 {
605 w = &IdleTimeoutWriter{
modules/caddyhttp/timeouts/timeouts.go
+5 -2
@@ -92,7 +92,7 @@ func (t Timeouts) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
92 start := time.Now()
93
94 if t.ReadTimeout > 0 && r.Body != nil {
95 - r.Body = &caddyhttp.IdleTimeoutReader{
95 + idleReader := &caddyhttp.IdleTimeoutReader{
96 ReadCloser: r.Body,
97 Ctrl: rc,
98 Deadline: caddyhttp.IdleDeadline{
@@ -100,8 +100,11 @@ func (t Timeouts) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
100 Timeout: t.ReadTimeout,
101 MinRate: t.ReadMinRate,
102 },
103 - Logger: t.logger,
103 + Logger: t.logger,
104 + DrainDeadline: r.ProtoMajor == 1 && r.ContentLength != 0,
105 }
106 + defer idleReader.HandlerDone()
107 + r.Body = idleReader
108 }
109 if t.WriteTimeout > 0 {
110 w = &caddyhttp.IdleTimeoutWriter{
modules/caddyhttp/timeouts/timeouts_test.go
+42
@@ -18,6 +18,7 @@ import (
18 "io"
19 "net/http"
20 "net/http/httptest"
21 + "strings"
22 "testing"
23 "time"
24
@@ -28,6 +29,16 @@ import (
29 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
30 )
31
32 +type readDeadlineRecorder struct {
33 + *httptest.ResponseRecorder
34 + deadlines []time.Time
35 +}
36 +
37 +func (w *readDeadlineRecorder) SetReadDeadline(deadline time.Time) error {
38 + w.deadlines = append(w.deadlines, deadline)
39 + return nil
40 +}
41 +
42 // pacedReader emits chunkCount chunks of chunkSize bytes, sleeping delay
43 // before each one, simulating a client that trickles a request body.
44 type pacedReader struct {
@@ -83,6 +94,37 @@ func TestTimeouts_ReadTimeoutIsIdleReset(t *testing.T) {
94 assert.Equal(t, http.StatusOK, resp.StatusCode)
95 }
96
97 +func TestTimeoutsStopsReadDeadlineBeforeReturning(t *testing.T) {
98 + tm := Timeouts{ReadTimeout: time.Second, logger: zap.NewNop()}
99 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
100 + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("body"))
101 + var body io.Reader
102 +
103 + err := tm.ServeHTTP(w, req, caddyhttp.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) error {
104 + body = r.Body
105 + _, _ = body.Read(make([]byte, 1))
106 + return nil
107 + }))
108 + require.NoError(t, err)
109 + require.Len(t, w.deadlines, 1)
110 + assert.False(t, w.deadlines[0].IsZero())
111 +
112 + _, _ = io.Copy(io.Discard, body)
113 + assert.Len(t, w.deadlines, 1)
114 +}
115 +
116 +func TestTimeoutsSkipsDrainDeadlineWithoutBody(t *testing.T) {
117 + tm := Timeouts{ReadTimeout: time.Second, logger: zap.NewNop()}
118 + w := &readDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
119 + req := httptest.NewRequest(http.MethodGet, "/", nil)
120 +
121 + err := tm.ServeHTTP(w, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
122 + return nil
123 + }))
124 + require.NoError(t, err)
125 + assert.Empty(t, w.deadlines)
126 +}
127 +
128 func TestTimeouts_WriteMaxChunkOverride(t *testing.T) {
129 const size = 10000
130 const maxChunk = 100