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