Skip to content

feat(gateway): add configurable response write timeout #812

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions examples/gateway/common/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package common

import (
"net/http"
"time"

"github.com/ipfs/boxo/gateway"
"github.com/ipfs/boxo/gateway/assets"
Expand Down Expand Up @@ -52,6 +53,8 @@ func NewHandler(gwAPI gateway.IPFSBackend) http.Handler {
Title: "Boxo",
},
},

ResponseWriteTimeout: 30 * time.Second, // Set default timeout to 30 seconds
}

// Creates a mux to serve the gateway paths. This is not strictly necessary
Expand Down
5 changes: 5 additions & 0 deletions gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ type Config struct {
// directory listings, DAG previews and errors. These will be displayed to the
// right of "About IPFS" and "Install IPFS".
Menu []assets.MenuItem

// ResponseWriteTimeout is the maximum duration the gateway will wait for a response
// to be written before timing out and returning a 504 Gateway Timeout error.
// If not set, a default timeout will be used.
Comment on lines +52 to +54
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// ResponseWriteTimeout is the maximum duration the gateway will wait for a response
// to be written before timing out and returning a 504 Gateway Timeout error.
// If not set, a default timeout will be used.
// ResponseWriteTimeout is the maximum duration the gateway will wait for
// new bytes to be retrieved from [gateway.IPFSBackend]. This timeout is
// reset on every [http.ResponseWriter] Write, which is protecting both
// client and server from wasting resources when parts of requested file or
// DAG have no providers and would hang forever.
// Setting to 0 disables this timeout.

ResponseWriteTimeout time.Duration
}

// PublicGateway is the specification of an IPFS Public Gateway.
Expand Down
79 changes: 78 additions & 1 deletion gateway/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"regexp"
"runtime/debug"
"strings"
"sync"
"time"

"github.com/ipfs/boxo/gateway/assets"
Expand Down Expand Up @@ -68,7 +69,83 @@
//
// [IPFS HTTP Gateway]: https://specs.ipfs.tech/http-gateways/
func NewHandler(c Config, backend IPFSBackend) http.Handler {
return newHandlerWithMetrics(&c, backend)
handler := newHandlerWithMetrics(&c, backend)

// Use the configured timeout or fall back to a default value
timeout := c.ResponseWriteTimeout
if timeout == 0 {
timeout = 30 * time.Second // Default timeout of 30 seconds
}

// Apply the timeout middleware
return WithResponseWriteTimeout(handler, timeout)
Comment on lines +74 to +81
Copy link
Member

Choose a reason for hiding this comment

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

Hm.. this makes disabling timeout impossible.

Let's change this to no timeout by default (Kubo and Rainbow will set the default themselves), and skip entire things if no timeout.

Suggested change
// Use the configured timeout or fall back to a default value
timeout := c.ResponseWriteTimeout
if timeout == 0 {
timeout = 30 * time.Second // Default timeout of 30 seconds
}
// Apply the timeout middleware
return WithResponseWriteTimeout(handler, timeout)
if c.ResponseWriteTimeout != 0 {
handler = WithResponseWriteTimeout(handler, timeout)
}
// Apply the timeout middleware
return handler

}

// timeoutResponseWriter implements http.ResponseWriter with timeout control
type timeoutResponseWriter struct {
http.ResponseWriter // Embedded standard response writer
timer *time.Timer // Timeout tracking mechanism
timeout time.Duration // Configured timeout duration
mu sync.Mutex // Mutex for concurrent access protection
done bool // Completion state flag
requestCtx context.Context // Original request context
cancel context.CancelFunc // Context cancellation handler
}

// Write implements io.Writer interface with timeout reset functionality
func (w *timeoutResponseWriter) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()

if w.done {
return 0, http.ErrHandlerTimeout
}

Check warning on line 102 in gateway/handler.go

View check run for this annotation

Codecov / codecov/patch

gateway/handler.go#L101-L102

Added lines #L101 - L102 were not covered by tests

// Reset timer on successful write attempt
w.timer.Reset(w.timeout)
return w.ResponseWriter.Write(data)
}

// cancelRequest handles timeout termination sequence
func (w *timeoutResponseWriter) cancelRequest() {
w.mu.Lock()
defer w.mu.Unlock()

if !w.done {
w.done = true
w.cancel() // Propagate context cancellation
w.ResponseWriter.WriteHeader(http.StatusGatewayTimeout)
}
}

// WithResponseWriteTimeout creates middleware for response write timeout handling
func WithResponseWriteTimeout(next http.Handler, timeout time.Duration) http.Handler {
Comment on lines +121 to +122
Copy link
Contributor

Choose a reason for hiding this comment

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

This does not appear to need to be exported.

Suggested change
// WithResponseWriteTimeout creates middleware for response write timeout handling
func WithResponseWriteTimeout(next http.Handler, timeout time.Duration) http.Handler {
// withResponseWriteTimeout creates middleware for response write timeout handling
func withResponseWriteTimeout(next http.Handler, timeout time.Duration) http.Handler {

return http.HandlerFunc(func(origWriter http.ResponseWriter, r *http.Request) {
// Create derived context with cancellation capability
ctx, cancel := context.WithCancel(r.Context())

// Initialize enhanced response writer
tw := &timeoutResponseWriter{
ResponseWriter: origWriter,
timeout: timeout,
timer: time.NewTimer(timeout),
requestCtx: ctx,
Copy link
Contributor

Choose a reason for hiding this comment

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

not used here

Suggested change
requestCtx: ctx,

Copy link
Member

Choose a reason for hiding this comment

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

+1, why we need requestCtx @gitsrc, does not seem to be used?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are right, this variable is not used and can be deleted.

cancel: cancel,
}
defer tw.timer.Stop() // Ensure timer resource cleanup

// Concurrent timeout monitor
go func() {
select {
case <-tw.timer.C: // Timeout expiration
tw.cancelRequest()
case <-ctx.Done(): // Normal completion
}
}()

// Execute handler chain with wrapped context
next.ServeHTTP(tw, r.WithContext(ctx))
})
}

// serveContent replies to the request using the content in the provided Reader
Expand Down
91 changes: 91 additions & 0 deletions gateway/handler_test.go
Copy link
Member

Choose a reason for hiding this comment

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

nit: run gofmt on this file

Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package gateway

Check failure on line 1 in gateway/handler_test.go

View workflow job for this annotation

GitHub Actions / go-check / All

File is not gofmt-ed.

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand All @@ -28,3 +32,90 @@
assert.Equalf(t, test.expected, result, "etagMatch(%q, %q, %q)", test.header, test.cidEtag, test.dirEtag)
}
}



func TestWithResponseWriteTimeout(t *testing.T) {
tests := []struct {
name string
handler http.HandlerFunc // Test scenario handler implementation
timeout time.Duration // Timeout configuration for middleware
expectStatus int // Anticipated HTTP response code
expectedChunks int // Expected number of completed writes
}{
{
name: "Normal completion - write within timeout",
handler: func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(100 * time.Millisecond):
w.Write([]byte("chunk\n"))
case <-r.Context().Done():
}
},
timeout: 300 * time.Millisecond,
expectStatus: http.StatusOK,
expectedChunks: 1,
},
{
name: "Timeout triggered - write after deadline",
handler: func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(400 * time.Millisecond): // Exceeds 300ms timeout
w.Write([]byte("chunk\n")) // Should be post-timeout
case <-r.Context().Done():
}
},
timeout: 300 * time.Millisecond,
expectStatus: http.StatusGatewayTimeout,
expectedChunks: 0,
},
{
name: "Timer reset with staggered writes",
handler: func(w http.ResponseWriter, r *http.Request) {
for i := 0; i < 3; i++ {
select {
case <-time.After(200 * time.Millisecond): // Each write within timeout window
w.Write([]byte("chunk\n")) // Resets timer on each write
case <-r.Context().Done():
return
}
}
},
timeout: 300 * time.Millisecond,
expectStatus: http.StatusOK,
expectedChunks: 3,
},
Comment on lines +72 to +87
Copy link
Member

@lidel lidel Feb 28, 2025

Choose a reason for hiding this comment

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

Missing test like this, but that writes two chunks and timeouts on third.

We need to see what happens when Headers were already sent with status 200, but payload truncated due to timeout.

Perhaps it would be useful to w.Write([]byte("\n[TRUNCATED DUE TO gateway.IPFSBackend TIMEOUT]")) to make it easier for clients to understand what happened if they debug things?

}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Initialize test components
req := httptest.NewRequest("GET", "http://example.com", nil)
rec := httptest.NewRecorder()

// Configure middleware chain
wrappedHandler := WithResponseWriteTimeout(tt.handler, tt.timeout)
wrappedHandler.ServeHTTP(rec, req)

// Validate HTTP status code
if rec.Code != tt.expectStatus {
t.Errorf("Status code validation failed: expected %d, received %d",
tt.expectStatus, rec.Code)
}

// Verify response body integrity
actualChunks := strings.Count(rec.Body.String(), "chunk")
if actualChunks != tt.expectedChunks {
t.Errorf("Data chunk mismatch: anticipated %d, obtained %d",
tt.expectedChunks, actualChunks)
}

// Ensure timeout responses don't set headers
if tt.expectStatus == http.StatusGatewayTimeout {
if contentType := rec.Header().Get("Content-Type"); contentType != "" {
t.Errorf("Timeout response contains unexpected Content-Type: %s", contentType)
}
}
})
}
}
Loading