Skip to content
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

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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.
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
gitsrc marked this conversation as resolved.
Show resolved Hide resolved
}

// Apply the timeout middleware
return WithResponseWriteTimeout(handler, timeout)
gitsrc marked this conversation as resolved.
Show resolved Hide resolved
}

// 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
gitsrc marked this conversation as resolved.
Show resolved Hide resolved
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)
gitsrc marked this conversation as resolved.
Show resolved Hide resolved
}
}

// WithResponseWriteTimeout creates middleware for response write timeout handling
func WithResponseWriteTimeout(next http.Handler, timeout time.Duration) http.Handler {
gitsrc marked this conversation as resolved.
Show resolved Hide resolved
return http.HandlerFunc(func(origWriter http.ResponseWriter, r *http.Request) {
// Create derived context with cancellation capability
ctx, cancel := context.WithCancel(r.Context())

gitsrc marked this conversation as resolved.
Show resolved Hide resolved
// Initialize enhanced response writer
tw := &timeoutResponseWriter{
ResponseWriter: origWriter,
timeout: timeout,
timer: time.NewTimer(timeout),
requestCtx: ctx,
gitsrc marked this conversation as resolved.
Show resolved Hide resolved
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
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,
},
}

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