Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 27835a703f rproxy: give its own answers at least 10 seconds to be sent (closes #295)
check / check (push) Successful in 3m52s
The write deadline of an answer rproxy gives itself was
SWRP_MAX_RESPONSE_TIME_MS from when the answer started. With a limit
under a second, a host too busy to run rproxy for that long passes the
deadline before the first byte is written. Go then closes the
connection and the visitor gets no answer.

Model: opus-5-5
2026-11-10 17:11:13 +00:00
5 changed files with 16 additions and 109 deletions

No files matched your search

+1 -2
View File
@@ -2246,8 +2246,7 @@ These limits apply per request, in both directions:
of the answer reaching the visitor. The request ends when the app has been
sent all of it, or when the app's answer arrives if that is sooner. An answer
`rproxy` gives itself has the whole time from when it starts, and never less
than 10 seconds. The status and headers of the app's answer also have at least
10 seconds to be sent.
than 10 seconds.
The largest request line and headers is 32 KiB. A kept-open connection is closed
after 120 seconds idle. Neither has a setting. traefik, by default, closes an
+2 -2
View File
@@ -437,8 +437,8 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
`off`.
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
from the end of the request to its last byte reaching the visitor. An answer
`rproxy` gives itself always has at least 10 seconds to be sent, and so do the
status and headers of the app's answer. A time or `off`.
`rproxy` gives itself always has at least 10 seconds to be sent. A time or
`off`.
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
`https` URL with a host and an optional port, and nothing more.
-6
View File
@@ -163,8 +163,6 @@ type responseWriter struct {
// status is the final status sent, or zero before one is.
status int
bytes int64
// afterStatus, if set, is called once the final status has been written.
afterStatus func()
}
// WriteHeader sends the status and headers. An informational 1xx status is
@@ -175,10 +173,6 @@ func (w *responseWriter) WriteHeader(status int) {
}
w.ResponseWriter.WriteHeader(status)
if status >= http.StatusOK && w.afterStatus != nil {
w.afterStatus()
}
}
// Write sends part of the body.
-62
View File
@@ -625,68 +625,6 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
}
}
func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) {
t.Parallel()
// Four visitors at a time, each with an app and rproxy of its own, send more
// requests in the same time.
for visitor := range 4 {
t.Run(strconv.Itoa(visitor), func(t *testing.T) {
t.Parallel()
visitAsTheTimeRunsOut(t)
})
}
}
// visitAsTheTimeRunsOut sends requests to an app that answers about as
// SWRP_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
// app's 200 or a 504.
func visitAsTheTimeRunsOut(t *testing.T) {
t.Helper()
const limit = 50 * time.Millisecond
// answerAfter is how long the app waits before it answers.
var answerAfter atomic.Int64
backend := startDiscriminatorAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(time.Duration(answerAfter.Load()))
_, _ = io.WriteString(w, greeting)
})
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
})
// After each 200 the app answers a little later, and after each 504 a little
// sooner, so that most of its answers arrive as the time runs out.
delay, step := limit/2, limit/4
for range 60 {
answerAfter.Store(int64(delay))
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
switch status := readStatus(t, conn); status {
case http.StatusOK:
delay += step
case http.StatusGatewayTimeout:
delay -= step
default:
t.Fatalf("status %d, want the app's 200 or 504", status)
}
_ = conn.Close()
step = max(step/2, 20*time.Microsecond)
}
}
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Parallel()
+13 -37
View File
@@ -32,16 +32,10 @@ const reportStatusMax = 599
// plainText is the Content-Type of the answers rproxy gives itself.
const plainText = "text/plain; charset=utf-8"
// minSendTime is the least time to send an answer rproxy gives itself, or the
// status and headers of the app's answer. A shorter time can run out before the
// first byte is written, on a busy host or as SWRP_MAX_RESPONSE_TIME_MS runs out,
// and the visitor then gets a closed connection and no answer.
const minSendTime = 10 * time.Second
// errResponseTooLate refuses an app's answer that arrives once
// SWRP_MAX_RESPONSE_TIME_MS has run out.
var errResponseTooLate = errors.New(
"the answer arrived after SWRP_MAX_RESPONSE_TIME_MS ran out")
// ownAnswerMinSendTime is the least time an answer rproxy gives itself has to be
// sent. A shorter time can run out on a busy host before the first byte is
// written, and the visitor then gets a closed connection and no answer.
const ownAnswerMinSendTime = 10 * time.Second
// request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report.
@@ -248,17 +242,6 @@ func (rq *request) modifyResponse(res *http.Response) error {
return nil
}
requestEnd := rq.endRequest()
// An answer that arrives once SWRP_MAX_RESPONSE_TIME_MS has run out is not
// passed on: the visitor is answered 504.
limit := rq.h.config.MaxResponseTime
if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) {
rq.refuse(http.StatusGatewayTimeout)
return errResponseTooLate
}
maxBytes := rq.h.config.MaxResponseSize
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
rq.refuse(http.StatusBadGateway)
@@ -274,7 +257,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
}
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.out.afterStatus = rq.setResponseDeadline
rq.setResponseDeadline()
return nil
}
@@ -348,14 +331,14 @@ func (rq *request) send(status int, body []byte) {
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
// reading of a body that has not arrived whole ends at deadline, and the answer
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// used that time up first, and at least minSendTime.
// used that time up first, and at least ownAnswerMinSendTime.
func (rq *request) prepareOwnAnswer(deadline time.Time) {
rq.stopReadingBody(deadline)
rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(time.Now().Add(max(limit, minSendTime)))
_ = rq.rc.SetWriteDeadline(time.Now().Add(max(limit, ownAnswerMinSendTime)))
}
}
@@ -572,22 +555,15 @@ func (rq *request) responseReceived() {
}
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
// visitor once ReverseProxy has written the status of the app's answer: the
// answer must reach the visitor within it of the end of the request. First it
// sends the status and headers, with minSendTime at least, before ReverseProxy
// reads the body: the time running out can still cut the answer off, but the
// visitor never gets a closed connection with no answer at all.
// visitor: the app's answer must reach the visitor within it of the end of the
// request.
func (rq *request) setResponseDeadline() {
requestEnd := rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit == 0 {
return
if limit > 0 {
_ = rq.rc.SetWriteDeadline(requestEnd.Add(limit))
}
deadline := rq.endRequest().Add(limit)
_ = rq.rc.SetWriteDeadline(time.Now().Add(max(time.Until(deadline), minSendTime)))
_ = rq.rc.Flush()
_ = rq.rc.SetWriteDeadline(deadline)
}
// endRequest notes now as the end of the request, unless an end is noted, and