Wraps gin-contrib/timeout to enforce per-request deadlines. When a
handler exceeds the configured duration, the client receives a 504
Gateway Timeout with the standard Fail("timeout", ...) error envelope.
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
159 lines
4.2 KiB
Go
159 lines
4.2 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
api "forge.lthn.ai/core/go-api"
|
|
)
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
// slowGroup provides a route that sleeps longer than the test timeout.
|
|
type slowGroup struct{}
|
|
|
|
func (s *slowGroup) Name() string { return "slow" }
|
|
func (s *slowGroup) BasePath() string { return "/v1/slow" }
|
|
func (s *slowGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
rg.GET("/wait", func(c *gin.Context) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
c.JSON(http.StatusOK, api.OK("done"))
|
|
})
|
|
}
|
|
|
|
// ── WithTimeout ─────────────────────────────────────────────────────────
|
|
|
|
func TestWithTimeout_Good_FastRequestSucceeds(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
e, _ := api.New(api.WithTimeout(500 * time.Millisecond))
|
|
e.Register(&stubGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp api.Response[string]
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
if !resp.Success {
|
|
t.Fatal("expected Success=true")
|
|
}
|
|
if resp.Data != "pong" {
|
|
t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
|
|
}
|
|
}
|
|
|
|
func TestWithTimeout_Good_SlowRequestTimesOut(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
e, _ := api.New(api.WithTimeout(50 * time.Millisecond))
|
|
e.Register(&slowGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/v1/slow/wait", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusGatewayTimeout {
|
|
t.Fatalf("expected 504, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestWithTimeout_Good_TimeoutResponseEnvelope(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
e, _ := api.New(api.WithTimeout(50 * time.Millisecond))
|
|
e.Register(&slowGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/v1/slow/wait", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusGatewayTimeout {
|
|
t.Fatalf("expected 504, got %d", w.Code)
|
|
}
|
|
|
|
var resp api.Response[any]
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
if resp.Success {
|
|
t.Fatal("expected Success=false")
|
|
}
|
|
if resp.Error == nil {
|
|
t.Fatal("expected Error to be non-nil")
|
|
}
|
|
if resp.Error.Code != "timeout" {
|
|
t.Fatalf("expected error code=%q, got %q", "timeout", resp.Error.Code)
|
|
}
|
|
if resp.Error.Message != "Request timed out" {
|
|
t.Fatalf("expected error message=%q, got %q", "Request timed out", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
func TestWithTimeout_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
e, _ := api.New(
|
|
api.WithRequestID(),
|
|
api.WithTimeout(500*time.Millisecond),
|
|
)
|
|
e.Register(&stubGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
// WithRequestID should still set the header.
|
|
id := w.Header().Get("X-Request-ID")
|
|
if id == "" {
|
|
t.Fatal("expected X-Request-ID header to be set")
|
|
}
|
|
|
|
var resp api.Response[string]
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
if resp.Data != "pong" {
|
|
t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
|
|
}
|
|
}
|
|
|
|
func TestWithTimeout_Ugly_ZeroDurationDoesNotPanic(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("WithTimeout(0) panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
e, err := api.New(api.WithTimeout(0))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
e.Register(&stubGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
// We only care that it did not panic. Status may vary with zero timeout.
|
|
}
|