Proposal #54136 (implemented in CL 436890 which is part of Go 1.20) added the "http".ResponseController type, which allows manipulating per-request timeouts. This is especially useful for programs managing long-running HTTP connections such as Mercure.

However, testing HTTP handlers leveraging per-request timeouts is currently cumbersome (even if doable) because "net/http/httptest".ResponseRecorder isn't compatible yet with "http".ResponseController.

To make it fully compatible with response controllers and improve the testing experience, I propose the following additions to its public API:

type ResponseRecorder struct {
    // ...

    // ReadDeadline is the last read deadline that has been set using
    // "net/http".ResponseController
    ReadDeadline time.Time

    // WriteDeadline is the last write deadline that has been set using
    // "net/http".ResponseController
    WriteDeadline time.Time
}

// SetReadDeadline allows using "net/http".ResponseController.SetReadDeadline()
// with the recorder.
//
// The deadline is recorded but is not enforced.
// To prevent flaky tests reads made after the deadline will work
// as if no deadline was set.
//
// To retrieve the deadline, use rw.ReadDeadline.
func (rw *ResponseRecorder) SetReadDeadline(deadline time.Time) error

// SetWriteDeadline allows using "net/http".ResponseController.SetWriteDeadline()
// with the recorder.
//
// The deadline is recorded but is not enforced.
// To prevent flaky tests writes made after the deadline will work
// as if no deadline was set.
//
// To retrieve the deadline, use rw.WriteDeadline.
func (rw *ResponseRecorder) SetWriteDeadline(deadline time.Time) error

All new methods are part of the contract that response types must honor to be usable with "HTTP".ResponseController.

As discussed below, deadlines are recorded but not enforced to prevent flaky tests.

Proposal implementation: #60231

Comment From: gopherbot

Change https://go.dev/cl/495295 mentions this issue: net/http/httptest: add support for http.ResponseController to ResponseRecorder

Comment From: neild

I don't think ResponseRecorder should be trying to apply a read or write deadline.

It can't apply a real read deadline, since it has no good way to interrupt reads from the request body. In CL 495295, setting a read deadline causes req.Body.Read calls made after the deadline has passed to return an error, but doesn't do anything to interrupt in-progress calls.

Setting a write deadline can make write calls after the deadline has passed fail. Since ResponseRecorder writes to a bytes.Buffer, interrupting in-progress writes is not a concern.

Making reads and writes fail in this fashion seems out of scope for ResponseRecorder (which just records responses).

This also seems like an encouragement to write flaky tests. For example, in the example:

rc := http.NewResponseController(w)
rc.SetReadDeadline(time.Now().Add(1 * time.Second))
rc.SetWriteDeadline(time.Now().Add(3 * time.Second))

io.WriteString(w, "<html><body>Hello, with deadlines!</body></html>")

This may or may not write the response string to the recorder. If three seconds pass between the call to SetWriteDeadline and the WriteString call, nothing is written. This might seem unlikely, but much test flakiness stems from exactly this sort of case when a slow test machine pauses for an unexpectedly long time between operations.

If you do want to write tests that rely on a record ResponseController which supports deadlines, it's simple to compose ResponseRecorder with a custom controller that implements SetReadDeadline and SetWriteDeadline:

type timeoutResponseWriter struct {
  httptest.ResponseRecorder
}

func (w *timeoutResponseWriter) Unwrap() http.ResponseWriter {
  return &w.ResponseRecorder // for Flush
}

func (w *timeoutResponseWriter) SetReadDeadline(deadline time.Duration) error {
  // ...
}

Comment From: dunglas

Thanks for the quick and detailed feedback @neild.

My main goal was to be able to easily assert that a deadline has been set (or changed) by the handler under test. I thought it was nice to have the full contract needed by "http".ResponseController implemented and to return an error "as in prod" if reached, but indeed this can encourage writing flaky tests.

It's definitely doable to implement a custom type similar to what is in this PR (it's already what we do for Mercure), but the developer experience is usually better when it's not necessary to write custom code to test native features.

Do you think that there is value in providing the methods to set deadlines but not enforce them (to prevent flaky tests), or should I close this proposal and the related CL?

Comment From: neild

Recording deadlines and not enforcing them would fit within ResponseRecorder's remit. I worry a bit that it'd lead to confusion from users who expect the deadline to be enforced, but ResponseRecorder doesn't really lend itself to use outside of tests so that's probably not a significant concern.

So I'd be fine with adding record-only deadline methods.

Comment From: dunglas

I updated the proposal and the related patch accordingly.

Comment From: dunglas

As deadlines can be updated during the HTTP handler execution, would it be interesting to record all calls to Set* methods?

The API would then be:


type ResponseRecorder struct {
    // ...

    // ReadDeadline is the list of read deadlines that have been set using
    // "net/http".ResponseController
    ReadDeadlines []time.Time

    // WriteDeadline is the list of write deadlines that have been set using
    // "net/http".ResponseController
    WriteDeadlines []time.Time
}

// SetReadDeadline allows using "net/http".ResponseController.SetReadDeadline()
// with the recorder.
//
// The deadline is recorded but is not enforced.
// To prevent flaky tests reads made after the deadline will work
// as if no deadline was set.
//
// To retrieve the deadlines, use rw.ReadDeadlines.
func (rw *ResponseRecorder) SetReadDeadline(deadline time.Time) error

// SetWriteDeadline allows using "net/http".ResponseController.SetWriteDeadline()
// with the recorder.
//
// The deadline is recorded but is not enforced.
// To prevent flaky tests writes made after the deadline will work
// as if no deadline was set.
//
// To retrieve the deadlines, use rw.WriteDeadlines.
func (rw *ResponseRecorder) SetWriteDeadline(deadline time.Time) error

Comment From: neild

(Sorry for losing track of this.)

As deadlines can be updated during the HTTP handler execution, would it be interesting to record all calls to Set* methods?

I think that this suggestion has moved me in the direction of thinking that we should not add any support for Set*Deadline to ResponseRecorder.

Recording deadlines has the fundamental problem that we don't know when the deadline was set. For example, whether we record a single SetReadDeadline call or all of them, a test cannot distinguish between the correct handler:

func(w http.ResponseWriter, req *http.Request) {
  http.NewResponseController(w).SetReadDeadline(time.Now().Add(1 * time.Second)
  io.Copy(w, respBody)
)

And the (probably) incorrect handler:

func(w http.ResponseWriter, req *http.Request) {
  io.Copy(w, respBody)
  http.NewResponseController(w).SetReadDeadline(time.Now().Add(1 * time.Second)
)

This gets worse if we have multiple deadlines applied across the course of the handler. I don't see how you can write a good test that asserts something like "the handler set deadlines of 1s, 10s, and 10s" without reference to what portions of the response those deadlines applied to.

As I mentioned in https://github.com/golang/go/issues/60229#issuecomment-1550053778, if you really do want to do that style of recording, it isn't difficult to compose ResponseRecorder with a custom ResponseWriter.

Comment From: rsc

Based on the most recent reply, it sounds like we should not do this?

Comment From: rsc

This proposal has been added to the active column of the proposals project and will now be reviewed at the weekly proposal review meetings. — rsc for the proposal review group

Comment From: rsc

Based on the discussion above, this proposal seems like a likely decline. — rsc for the proposal review group

Comment From: rsc

No change in consensus, so declined. — rsc for the proposal review group