Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions pkg/http/handler/timeout.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,16 @@ func (tw *timeoutWriter) WriteHeader(code int) {
tw.w.WriteHeader(code)
}

// tryTimeoutAndWriteError writes an error to the responsewriter if
// nothing has been written to the writer before. Returns whether
// an error was written or not.
// tryTimeoutAndWriteError writes an error to the responsewriter if it hasn't
// been written to the writer before. Returns whether an error was written or not.
//
// If this writes an error, all subsequent calls to Write will
// result in http.ErrHandlerTimeout.
func (tw *timeoutWriter) tryTimeoutAndWriteError(msg string) bool {
tw.mu.Lock()
defer tw.mu.Unlock()

if tw.lastWriteTime.IsZero() {
if !tw.timedOut {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this always true when nothing has been written?

Looking at the changes to the comment and the removal of lastWriteTime. (Sounded to me the "has not been written" part was added b/c of the lastWriteTime ?

Just wondering 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, there are two situations when we set the timeout flag:
1. When we reach the response start timeout: https://github.com/knative/serving/blob/main/pkg/http/handler/timeout.go#L248
2. When we hit the regular timeout: https://github.com/knative/serving/blob/main/pkg/http/handler/timeout.go#L230

Currently, both functions (tryResponseStartTimeoutAndWriteError and tryTimeoutAndWriteError) are identical and do the following:

Timeout (write an error header and set the timeout flag to true) if nothing has been written to the response.

This means that we never get inside the condition on line 228: either we write something and lastWriteTime.IsZero() is false, or we don’t write anything and fail earlier by response start timeout.

In my vision, tryTimeoutAndWriteError should always write the error, ignoring whether anything was written before or not. Checking the timedOut flag is needed to make this function idempotent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fedosin thanks for the further explanation. It makes sense to me even though I don't a deep knowledge of Serving's timeout handling.

Copy link
Member

@dprotaso dprotaso Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my vision, tryTimeoutAndWriteError should always write the error, ignoring whether anything was written before or not.

That would be unexpected for end-users - so we shouldn't do this.

If something is written on the wire already and a timeout occurs we should just close the connection.

tw.timeoutAndWriteError(msg)
return true
}
Expand Down
105 changes: 102 additions & 3 deletions pkg/http/handler/timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,37 @@ func TestTimeoutWriterAllowsForAdditionalWritesBeforeTimeout(t *testing.T) {
recorder := httptest.NewRecorder()
clock := clock.RealClock{}
handler := &timeoutWriter{w: recorder, clock: clock}

// Write header and some data before any timeout
handler.WriteHeader(http.StatusOK)
handler.tryTimeoutAndWriteError("error")
handler.tryResponseStartTimeoutAndWriteError("error")
handler.tryIdleTimeoutAndWriteError(clock.Now(), 10*time.Second, "error")
if _, err := io.WriteString(handler, "test"); err != nil {
t.Fatalf("handler.Write() = %v, want no error", err)
}

// Verify writes succeeded
if got, want := recorder.Code, http.StatusOK; got != want {
t.Errorf("recorder.Status = %d, want %d", got, want)
}
if got, want := recorder.Body.String(), "test"; got != want {
t.Errorf("recorder.Body = %s, want %s", got, want)
}

// Now verify the try methods don't write errors when response has already started
// tryResponseStartTimeoutAndWriteError should not write error since response already started
if handler.tryResponseStartTimeoutAndWriteError("error") {
t.Error("tryResponseStartTimeoutAndWriteError should return false when response already started")
}

// tryIdleTimeoutAndWriteError should not timeout immediately after a write
timedOut, _ := handler.tryIdleTimeoutAndWriteError(clock.Now(), 10*time.Second, "error")
if timedOut {
t.Error("tryIdleTimeoutAndWriteError should not timeout immediately after a write")
}

// Verify body hasn't changed
if got, want := recorder.Body.String(), "test"; got != want {
t.Errorf("recorder.Body = %s, want %s", got, want)
}
}

func TestTimeoutWriterDoesntFlushAfterTimeout(t *testing.T) {
Expand Down Expand Up @@ -79,6 +96,88 @@ func TestTimeoutWriterErrorsWriteAfterTimeout(t *testing.T) {
}
}

func TestTryTimeoutAndWriteErrorBehavior(t *testing.T) {
tests := []struct {
name string
setup func(*timeoutWriter)
wantWritten bool
wantStatusCode int
wantBody string
}{
{
name: "writes error when nothing written before and not timed out",
setup: func(tw *timeoutWriter) {
// No setup needed - fresh state
},
wantWritten: true,
wantStatusCode: http.StatusGatewayTimeout,
wantBody: "timeout error",
},
{
name: "writes error when response already started but not timed out",
setup: func(tw *timeoutWriter) {
// Write something first
tw.WriteHeader(http.StatusOK)
tw.Write([]byte("partial response"))
},
wantWritten: true,
wantStatusCode: http.StatusOK, // Already set
wantBody: "partial responsetimeout error",
},
{
name: "does not write error when already timed out",
setup: func(tw *timeoutWriter) {
// Simulate a previous timeout
tw.timedOut = true
},
wantWritten: false,
wantStatusCode: http.StatusOK, // Default
wantBody: "",
},
{
name: "does not write error when already timed out even with prior writes",
setup: func(tw *timeoutWriter) {
// Write something first
tw.WriteHeader(http.StatusAccepted)
tw.Write([]byte("some data"))
// Then mark as timed out
tw.timedOut = true
},
wantWritten: false,
wantStatusCode: http.StatusAccepted,
wantBody: "some data",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
tw := &timeoutWriter{w: recorder, clock: clock.RealClock{}}

tt.setup(tw)

written := tw.tryTimeoutAndWriteError("timeout error")

if written != tt.wantWritten {
t.Errorf("tryTimeoutAndWriteError() returned %v, want %v", written, tt.wantWritten)
}

if recorder.Code != tt.wantStatusCode {
t.Errorf("Status code = %d, want %d", recorder.Code, tt.wantStatusCode)
}

if recorder.Body.String() != tt.wantBody {
t.Errorf("Body = %q, want %q", recorder.Body.String(), tt.wantBody)
}

// Verify timedOut flag is set correctly
if tt.wantWritten && !tw.timedOut {
t.Error("timedOut flag should be true after writing timeout error")
}
})
}
}

type timeoutHandlerTestScenario struct {
name string
timeout time.Duration
Expand Down
Loading