Skip to content

proposal: net/http/httptest: add support for http.ResponseController to ResponseRecorder #60229

Closed
@dunglas

Description

@dunglas

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

Activity

added this to the Proposal milestone on May 16, 2023
added 3 commits that reference this issue on May 16, 2023
9bb7a1d
d0f9c73
42f634a
added a commit that references this issue on May 16, 2023
8e5d8d7
gopherbot

gopherbot commented on May 16, 2023

@gopherbot
Contributor

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

neild

neild commented on May 16, 2023

@neild
Contributor

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 {
  // ...
}
moved this to Incoming in Proposalson May 16, 2023
dunglas

dunglas commented on May 16, 2023

@dunglas
ContributorAuthor

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?

neild

neild commented on May 16, 2023

@neild
Contributor

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.

dunglas

dunglas commented on May 17, 2023

@dunglas
ContributorAuthor

I updated the proposal and the related patch accordingly.

dunglas

dunglas commented on May 18, 2023

@dunglas
ContributorAuthor

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

30 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

      Development

      Participants

      @neild@dunglas@rsc@gopherbot

      Issue actions

        proposal: net/http/httptest: add support for http.ResponseController to ResponseRecorder · Issue #60229 · golang/go