mirror of
https://github.com/ipfs/kubo.git
synced 2026-02-21 10:27:46 +08:00
* feat(config): add Gateway.MaxRequestDuration option exposes the previously hardcoded 1 hour gateway request deadline as a configurable option, allowing operators to adjust it to fit deployment needs. protects gateway from edge cases and slow client attacks. boxo: https://github.com/ipfs/boxo/pull/1079 * test(gateway): add MaxRequestDuration integration test verifies config is wired correctly and 504 is returned when exceeded * docs: add MaxRequestDuration to gateway production guide --------- Co-authored-by: Andrew Gillis <11790789+gammazero@users.noreply.github.com>
176 lines
6.3 KiB
Go
176 lines
6.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ipfs/kubo/config"
|
|
"github.com/ipfs/kubo/test/cli/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// TestGatewayLimits tests the gateway request limiting and timeout features.
|
|
// These are basic integration tests that verify the configuration works.
|
|
// For comprehensive tests, see:
|
|
// - github.com/ipfs/boxo/gateway/middleware_retrieval_timeout_test.go
|
|
// - github.com/ipfs/boxo/gateway/middleware_ratelimit_test.go
|
|
func TestGatewayLimits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RetrievalTimeout", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a node with a short retrieval timeout
|
|
node := harness.NewT(t).NewNode().Init()
|
|
node.UpdateConfig(func(cfg *config.Config) {
|
|
// Set a 1 second timeout for retrieval
|
|
cfg.Gateway.RetrievalTimeout = config.NewOptionalDuration(1 * time.Second)
|
|
})
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
// Add content that can be retrieved quickly
|
|
cid := node.IPFSAddStr("test content")
|
|
|
|
client := node.GatewayClient()
|
|
|
|
// Normal request should succeed (content is local)
|
|
resp := client.Get("/ipfs/" + cid)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "test content", resp.Body)
|
|
|
|
// Request for non-existent content should timeout
|
|
// Using a CID that has no providers (generated with ipfs add -n)
|
|
nonExistentCID := "bafkreif6lrhgz3fpiwypdk65qrqiey7svgpggruhbylrgv32l3izkqpsc4"
|
|
|
|
// Create a client with longer timeout than the gateway's retrieval timeout
|
|
// to ensure we get the gateway's 504 response
|
|
clientWithTimeout := &harness.HTTPClient{
|
|
Client: &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
},
|
|
BaseURL: client.BaseURL,
|
|
}
|
|
|
|
resp = clientWithTimeout.Get("/ipfs/" + nonExistentCID)
|
|
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "Expected 504 Gateway Timeout for stuck retrieval")
|
|
assert.Contains(t, resp.Body, "Unable to retrieve content within timeout period")
|
|
})
|
|
|
|
t.Run("MaxRequestDuration", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a node with a short max request duration
|
|
node := harness.NewT(t).NewNode().Init()
|
|
node.UpdateConfig(func(cfg *config.Config) {
|
|
// Set a short absolute deadline (500ms) for the entire request
|
|
cfg.Gateway.MaxRequestDuration = config.NewOptionalDuration(500 * time.Millisecond)
|
|
// Set retrieval timeout much longer so MaxRequestDuration fires first
|
|
cfg.Gateway.RetrievalTimeout = config.NewOptionalDuration(30 * time.Second)
|
|
})
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
// Add content that can be retrieved quickly
|
|
cid := node.IPFSAddStr("test content for max request duration")
|
|
|
|
client := node.GatewayClient()
|
|
|
|
// Fast request for local content should succeed (well within 500ms)
|
|
resp := client.Get("/ipfs/" + cid)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "test content for max request duration", resp.Body)
|
|
|
|
// Request for non-existent content should timeout due to MaxRequestDuration
|
|
// This CID has no providers and will block during content routing
|
|
nonExistentCID := "bafkreif6lrhgz3fpiwypdk65qrqiey7svgpggruhbylrgv32l3izkqpsc4"
|
|
|
|
// Create a client with a longer timeout than MaxRequestDuration
|
|
// to ensure we receive the gateway's 504 response
|
|
clientWithTimeout := &harness.HTTPClient{
|
|
Client: &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
},
|
|
BaseURL: client.BaseURL,
|
|
}
|
|
|
|
resp = clientWithTimeout.Get("/ipfs/" + nonExistentCID)
|
|
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "Expected 504 when request exceeds MaxRequestDuration")
|
|
})
|
|
|
|
t.Run("MaxConcurrentRequests", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a node with a low concurrent request limit
|
|
node := harness.NewT(t).NewNode().Init()
|
|
node.UpdateConfig(func(cfg *config.Config) {
|
|
// Allow only 1 concurrent request to make test deterministic
|
|
cfg.Gateway.MaxConcurrentRequests = config.NewOptionalInteger(1)
|
|
// Set retrieval timeout so blocking requests don't hang forever
|
|
cfg.Gateway.RetrievalTimeout = config.NewOptionalDuration(2 * time.Second)
|
|
})
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
// Add some content - use a non-existent CID that will block during retrieval
|
|
// to ensure we can control timing
|
|
blockingCID := "bafkreif6lrhgz3fpiwypdk65qrqiey7svgpggruhbylrgv32l3izkqpsc4"
|
|
normalCID := node.IPFSAddStr("test content for concurrent request limiting")
|
|
|
|
client := node.GatewayClient()
|
|
|
|
// First, verify single request succeeds
|
|
resp := client.Get("/ipfs/" + normalCID)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Now test deterministic 429 response:
|
|
// Start a blocking request that will occupy the single slot,
|
|
// then make another request that MUST get 429
|
|
|
|
blockingStarted := make(chan bool)
|
|
blockingDone := make(chan bool)
|
|
|
|
// Start a request that will block (searching for non-existent content)
|
|
go func() {
|
|
blockingStarted <- true
|
|
// This will block until timeout looking for providers
|
|
client.Get("/ipfs/" + blockingCID)
|
|
blockingDone <- true
|
|
}()
|
|
|
|
// Wait for blocking request to start and occupy the slot
|
|
<-blockingStarted
|
|
time.Sleep(1 * time.Second) // Ensure it has acquired the semaphore
|
|
|
|
// This request MUST get 429 because the slot is occupied
|
|
resp = client.Get("/ipfs/" + normalCID + "?must-get-429=true")
|
|
assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode, "Second request must get 429 when slot is occupied")
|
|
|
|
// Verify 429 response headers
|
|
retryAfter := resp.Headers.Get("Retry-After")
|
|
assert.NotEmpty(t, retryAfter, "Retry-After header must be set on 429 response")
|
|
assert.Equal(t, "60", retryAfter, "Retry-After must be 60 seconds")
|
|
|
|
cacheControl := resp.Headers.Get("Cache-Control")
|
|
assert.Equal(t, "no-store", cacheControl, "Cache-Control must be no-store on 429 response")
|
|
|
|
assert.Contains(t, resp.Body, "Too many requests", "429 response must contain error message")
|
|
|
|
// Clean up: wait for blocking request to timeout (it will timeout due to gateway retrieval timeout)
|
|
select {
|
|
case <-blockingDone:
|
|
// Good, it completed
|
|
case <-time.After(10 * time.Second):
|
|
// Give it more time if needed
|
|
}
|
|
|
|
// Wait a bit more to ensure slot is fully released
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// After blocking request completes, new request should succeed
|
|
resp = client.Get("/ipfs/" + normalCID + "?after-limit-cleared=true")
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "Request must succeed after slot is freed")
|
|
})
|
|
}
|