Files
Francis LavoieandClaude Opus 5.5 30d3bd2a89 caddyhttp: only bound HTTP/2 body reads in flight with the idle timeout (#8125)
* caddyhttp: only bound HTTP/2 body reads in flight with the idle timeout

On HTTP/2, the read deadline is a timer that fails the request body
when it fires, even with no read in flight. IdleTimeoutReader left the
idle deadline armed between reads, so a handler that stopped reading
for longer than read_body_idle (1 minute by default) lost the body,
although the client was only held back by flow control. For example,
reverse_proxy uploading to an upstream that's slow to accept the body
failed with a 502.

This is the read side of #8118. Like IdleTimeoutWriter does since
read on HTTP/2, so the idle deadline only bounds reads in flight. A
client that stops sending is still cut off, since the deadline is set
before each read. HTTP/1 and HTTP/3 only check the deadline during a
read, so they're unchanged.

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>

* caddyhttp: track a released hard read deadline as armed

From review: with HardDeadline set, releasing the idle deadline between
reads left an HTTP/2 timer armed (at HardDeadline) while deadlineSet
recorded that none was, so clearDeadlineLocked would skip clearing it.

Track which deadline is armed (none, idle, or only the hard one) instead
of a bool, and have each check ask the question it means: re-arming and
releasing only apply to an armed idle deadline, while clearing applies
to any.

The writer's deadlineSet already only means "idle deadline armed",
since nothing clears the hard deadline it leaves; document that.

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>

* caddyhttp: clear released hard deadline at handler end, skip no-op sets

From review:

- After a partial read releases the idle deadline to HardDeadline, a
  handler that returns without reading again left that timer armed past
  HandlerDone, which only cleared after a terminal error. HandlerDone
  now also clears a released hard deadline.
- When the idle deadline is capped to HardDeadline, the reader and
  writer set the same deadline again when releasing it, and the writer
  once more in HandlerDone. On HTTP/2 each is a message to the serve
  loop. Both now remember the deadline they armed and skip setting it
  again. Within a request, only these wrappers set the client's
  deadlines.

Replaces the earlier test that faked a terminal error with the partial
read followed directly by HandlerDone.

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>

* caddyhttp: only skip re-setting an armed hard deadline

Skipping any deadline equal to the one already armed also skipped idle
deadlines on Windows, where two calls to time.Now in the same clock tick
return the same time, so successive idle deadlines can be equal. That
broke tests counting deadline calls there. Only skip HardDeadline, the
case from review where the idle deadline is capped to it; other
deadlines are always set.

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>

* caddyhttp: restore deadlines without trusting cached transport state

Always set the requested read and write deadlines because another handler can change them through its own response controller. Remove the armedAt shortcut and cover intervening deadline changes for reads, writes, ReadFrom, flushes, and HandlerDone.

AI-assisted with OpenAI Codex (GPT-6); tested under human direction.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:47:14 +10:00

165 lines
5.4 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package timeouts
import (
"net/http"
"time"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func init() {
caddy.RegisterModule(Timeouts{})
}
// Timeouts is a middleware that applies read/write idle timeouts, minimum
// transfer rates, and a write chunk size cap to routes matching this
// handler. They take precedence over the server-wide equivalents, and
// over those of any enclosing timeouts handler, for as long as this
// handler is running. The server-wide read_timeout and write_timeout
// still cap the whole transfer.
type Timeouts struct {
// How long to allow a read from the request body to stall before
// aborting the connection, reset on every successful read (like the
// server-wide read_idle_timeout, but scoped to routes matching this
// handler). If zero, the server-wide setting applies.
// EXPERIMENTAL. Subject to change/removal.
ReadTimeout time.Duration `json:"read_timeout,omitempty"`
// ReadMinRate requires the client to sustain at least this many
// bytes/second, averaged from the start of the read, or the
// connection is aborted (Apache mod_reqtimeout's MinRate). Only
// takes effect if ReadTimeout is also set.
// EXPERIMENTAL. Subject to change/removal.
ReadMinRate int64 `json:"read_min_rate,omitempty"`
// How long to allow a write to the client to stall before aborting
// the connection, reset on every successful write (like the
// server-wide write_idle_timeout, but scoped to routes matching this
// handler). If zero, the server-wide setting applies.
// EXPERIMENTAL. Subject to change/removal.
WriteTimeout time.Duration `json:"write_timeout,omitempty"`
// WriteMinRate is like ReadMinRate, but for writes to the client.
// Only takes effect if WriteTimeout is also set.
// EXPERIMENTAL. Subject to change/removal.
WriteMinRate int64 `json:"write_min_rate,omitempty"`
// MaxWriteChunk bounds how many bytes a single underlying write
// operation is allowed to cover, so WriteTimeout/WriteMinRate can
// actually apply between chunks of a large response instead of
// being bounded by one deadline for the whole thing. If zero, the
// server-wide setting is used, or caddyhttp.DefaultMaxWriteChunk if
// there is none.
// EXPERIMENTAL. Subject to change/removal.
MaxWriteChunk int `json:"max_write_chunk,omitempty"`
logger *zap.Logger
}
// CaddyModule returns the Caddy module information.
func (Timeouts) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.timeouts",
New: func() caddy.Module { return new(Timeouts) },
}
}
func (t *Timeouts) Provision(ctx caddy.Context) error {
t.logger = ctx.Logger()
return nil
}
func (t Timeouts) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
if t.ReadTimeout <= 0 && t.WriteTimeout <= 0 {
return next.ServeHTTP(w, r)
}
// if the request already has idle timeouts, from the server or an
// enclosing route, override them instead of wrapping again: the
// wrapper closest to the connection sets the deadline last, so it
// would take precedence over ours
idleReader, idleWriter := caddyhttp.IdleTimeoutsFromContext(r.Context())
var wrapped bool
var rc *http.ResponseController
start := time.Now()
if t.ReadTimeout > 0 && r.Body != nil {
if idleReader != nil {
defer idleReader.Override(t.ReadTimeout, t.ReadMinRate)()
} else {
//nolint:bodyclose
rc = http.NewResponseController(w)
idleReader = &caddyhttp.IdleTimeoutReader{
ReadCloser: r.Body,
Ctrl: rc,
Deadline: caddyhttp.IdleDeadline{
Start: start,
Timeout: t.ReadTimeout,
MinRate: t.ReadMinRate,
},
Logger: t.logger,
DrainDeadline: r.ProtoMajor == 1 && r.ContentLength != 0,
ClearBetweenReads: r.ProtoMajor == 2,
}
defer idleReader.HandlerDone()
r.Body = idleReader
wrapped = true
}
}
if t.WriteTimeout > 0 {
if idleWriter != nil {
defer idleWriter.Override(t.WriteTimeout, t.WriteMinRate, t.MaxWriteChunk)()
} else {
if rc == nil {
//nolint:bodyclose
rc = http.NewResponseController(w)
}
idleWriter = &caddyhttp.IdleTimeoutWriter{
ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w},
Ctrl: rc,
Deadline: caddyhttp.IdleDeadline{
Start: start,
Timeout: t.WriteTimeout,
MinRate: t.WriteMinRate,
},
MaxChunk: t.MaxWriteChunk,
Logger: t.logger,
ClearBetweenWrites: r.ProtoMajor == 2,
}
defer idleWriter.HandlerDone()
w = idleWriter
wrapped = true
}
}
// let nested timeouts handlers override the wrappers added here
if wrapped {
r = r.WithContext(caddyhttp.ContextWithIdleTimeouts(r.Context(), idleReader, idleWriter))
}
return next.ServeHTTP(w, r)
}
// Interface guards
var (
_ caddy.Provisioner = (*Timeouts)(nil)
_ caddyhttp.MiddlewareHandler = (*Timeouts)(nil)
)