refactor: standardize API error handling (#1635)

This commit is contained in:
Elias Schneider
2026-08-02 23:36:06 +02:00
committed by GitHub
parent 281bea54d3
commit 7a4d0dd275
108 changed files with 3163 additions and 1629 deletions
+4 -4
View File
@@ -3,7 +3,7 @@ package middleware
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -39,15 +39,15 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI
user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil {
return "", false, &common.NotSignedInError{}
return "", false, apperror.NotSignedIn()
}
if user.Disabled {
return "", false, &common.UserDisabledError{}
return "", false, apperror.UserDisabled()
}
if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{}
return "", false, apperror.MissingPermission()
}
return user.ID, user.IsAdmin, nil
@@ -1,11 +1,9 @@
package middleware
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -88,8 +86,8 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
return
}
// If JWT auth failed and the error is not a NotSignedInError, abort the request
if !errors.Is(err, &common.NotSignedInError{}) {
// If JWT auth failed for a reason other than missing credentials, abort the request
if !apperror.IsCode(err, apperror.CodeNotSignedIn) {
c.Abort()
_ = c.Error(err)
return
@@ -103,7 +101,7 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
c.Abort()
if c.GetHeader("X-API-Key") != "" {
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
_ = c.Error(apperror.APIKeyAuthNotAllowed())
return
}
_ = c.Error(err)
+244 -91
View File
@@ -1,125 +1,278 @@
package middleware
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"strings"
"time"
"unicode"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/pocket-id/pocket-id/backend/internal/common"
"gorm.io/gorm"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"go.opentelemetry.io/otel/trace"
)
const requestIDHeader = "X-Request-ID"
type requestIDContextKey struct{}
type requestErrorCodeContextKey struct{}
// RequestID returns the identifier assigned to the current request
func RequestID(c *gin.Context) string {
if c == nil {
return ""
}
requestID, exists := c.Get(requestIDContextKey{})
if !exists {
return ""
}
value, ok := requestID.(string)
if !ok {
return ""
}
return value
}
// RequestErrorCode returns the stable code of the error handled for the current request
func RequestErrorCode(c *gin.Context) apperror.Code {
if c == nil {
return ""
}
code, exists := c.Get(requestErrorCodeContextKey{})
if !exists {
return ""
}
value, ok := code.(apperror.Code)
if !ok {
return ""
}
return value
}
type ErrorHandlerMiddleware struct{}
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
return &ErrorHandlerMiddleware{}
}
type classifiedError struct {
code apperror.Code
status int
message string
details map[string]string
fields []apperror.FieldError
retryAfter time.Duration
}
// Add records a request ID before executing the request and serializes the first returned error afterward
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := uuid.NewString()
c.Set(requestIDContextKey{}, requestID)
c.Header(requestIDHeader, requestID)
c.Next()
for _, err := range c.Errors {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
errorResponse(c, http.StatusNotFound, "Record not found")
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
// Check for slice validation errors
svErr, ok := errors.AsType[binding.SliceValidationError](err)
if ok {
if errors.As(svErr[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
// AppError with description
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
if ok {
errorResponseWithDescription(c, appDescErr.HttpStatusCode(), appDescErr.Error(), appDescErr.Description())
return
}
// AppError (without description)
appErr, ok := errors.AsType[common.AppError](err)
if ok {
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
return
}
c.JSON(http.StatusInternalServerError, errorResponseBody{
Error: "Something went wrong",
})
if len(c.Errors) == 0 {
return
}
err := c.Errors[0].Err
classified := classifyError(err)
c.Set(requestErrorCodeContextKey{}, classified.code)
logRequestError(c, err, classified, requestID)
if c.Writer.Written() {
return
}
if classified.retryAfter > 0 {
c.Header("Retry-After", formatRetryAfter(classified.retryAfter))
}
writeErrorResponse(c, classified, requestID)
}
}
type errorResponseBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
Error string `json:"error"`
Code apperror.Code `json:"code"`
Details map[string]any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
func errorResponse(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, errorResponseBody{
Error: message,
})
}
func errorResponseWithDescription(c *gin.Context, statusCode int, message string, description string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, errorResponseBody{
Error: message,
ErrorDescription: description,
})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
var errorMessages []string
for _, ve := range validationErrors {
fieldName := ve.Field()
var errorMessage string
switch ve.Tag() {
case "required":
errorMessage = fmt.Sprintf("%s is required", fieldName)
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "resource_uri":
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
case "min":
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
func classifyError(err error) classifiedError {
var structuredErr *apperror.Error
if errors.As(err, &structuredErr) && structuredErr != nil {
return classifiedError{
code: structuredErr.Code(),
status: normalizeStatus(structuredErr.HTTPStatus()),
message: capitalizeFirst(structuredErr.ClientMessage()),
details: structuredErr.Details(),
fields: structuredErr.Fields(),
retryAfter: structuredErr.RetryAfter(),
}
errorMessages = append(errorMessages, errorMessage)
}
// Join all the error messages into a single string
combinedErrors := strings.Join(errorMessages, ", ")
if errors.Is(err, context.DeadlineExceeded) {
return classifiedError{
code: apperror.CodeRequestTimeout,
status: http.StatusGatewayTimeout,
message: "Request timed out",
}
}
return combinedErrors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) && len(validationErrors) > 0 {
return classifiedValidationError(validationErrors)
}
var sliceValidationErrors binding.SliceValidationError
if errors.As(err, &sliceValidationErrors) && len(sliceValidationErrors) > 0 {
if errors.As(sliceValidationErrors[0], &validationErrors) {
return classifiedValidationError(validationErrors)
}
}
return classifiedError{
code: apperror.CodeInternal,
status: http.StatusInternalServerError,
message: "Something went wrong",
}
}
func classifiedValidationError(validationErrors validator.ValidationErrors) classifiedError {
fields := make([]apperror.FieldError, 0, len(validationErrors))
messages := make([]string, 0, len(validationErrors))
for _, validationError := range validationErrors {
fieldName := validationError.Field()
code, message := validationFieldError(validationError)
fields = append(fields, apperror.FieldError{
Field: fieldName,
Code: code,
Message: message,
})
messages = append(messages, fieldName+" "+message)
}
return classifiedError{
code: apperror.CodeValidationFailed,
status: http.StatusBadRequest,
message: capitalizeFirst(strings.Join(messages, ", ")),
fields: fields,
}
}
func validationFieldError(validationError validator.FieldError) (string, string) {
switch validationError.Tag() {
case "required":
return "required", "is required"
case "email":
return "invalid_format", "must be a valid email address"
case "username":
return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character"
case "url":
return "invalid_format", "must be a valid URL"
case "resource_uri":
return "invalid_format", "must be an absolute URI without whitespace or a fragment"
case "min":
return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param())
case "max":
return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param())
default:
return validationError.Tag(), "is invalid"
}
}
func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) {
details := make(map[string]any, len(classified.details)+1)
for key, value := range classified.details {
details[key] = value
}
if len(classified.fields) > 0 {
details["fields"] = classified.fields
}
if len(details) == 0 {
details = nil
}
response := errorResponseBody{
Error: classified.message,
Code: classified.code,
Details: details,
RequestID: requestID,
}
c.JSON(classified.status, response)
}
func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) {
if classified.status < http.StatusInternalServerError {
return
}
attrs := []any{
slog.String("error_code", string(classified.code)),
slog.String("error_type", errorTypeName(err)),
slog.Int("http_status", classified.status),
slog.String("request_id", requestID),
slog.String("http_method", c.Request.Method),
slog.String("http_path", c.Request.URL.Path),
slog.Any("error", err),
}
if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() {
attrs = append(attrs, slog.String("trace_id", spanContext.TraceID().String()))
}
slog.ErrorContext(c.Request.Context(), "Request failed", attrs...)
}
func errorTypeName(err error) string {
if err == nil {
return "<nil>"
}
return reflect.TypeOf(err).String()
}
func normalizeStatus(status int) int {
if status < http.StatusBadRequest || status > 599 {
return http.StatusInternalServerError
}
return status
}
func formatRetryAfter(retryAfter time.Duration) string {
seconds := int(retryAfter / time.Second)
if retryAfter%time.Second != 0 {
seconds++
}
if seconds < 1 {
seconds = 1
}
return fmt.Sprintf("%d", seconds)
}
func capitalizeFirst(message string) string {
runes := []rune(message)
if len(runes) == 0 {
return message
}
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
@@ -0,0 +1,180 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestErrorHandlerMiddlewareStructuredError(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
var handlerRequestID string
router.GET("/users/1", httpserver.Handle(func(c *gin.Context) error {
handlerRequestID = RequestID(c)
cause := errors.New("database connection details")
return apperror.Wrap(cause, apperror.CodeUserNotFound, http.StatusNotFound, "User not found")
}))
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/1", nil)
router.ServeHTTP(recorder, request)
var body struct {
Error string `json:"error"`
Code apperror.Code `json:"code"`
Details map[string]string `json:"details"`
RequestID string `json:"request_id"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusNotFound, recorder.Code)
require.Equal(t, "User not found", body.Error)
require.Equal(t, apperror.CodeUserNotFound, body.Code)
require.Empty(t, body.Details)
require.NotEmpty(t, body.RequestID)
require.Equal(t, body.RequestID, handlerRequestID)
require.Equal(t, body.RequestID, recorder.Header().Get(requestIDHeader))
require.NotContains(t, recorder.Body.String(), "database connection details")
}
func TestErrorHandlerMiddlewareHidesUnexpectedError(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/failure", func(c *gin.Context) {
_ = c.Error(errors.New("private database details"))
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/failure", nil)
router.ServeHTTP(recorder, request)
var body map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.Equal(t, "Something went wrong", body["error"])
require.Equal(t, string(apperror.CodeInternal), body["code"])
require.NotContains(t, recorder.Body.String(), "private database details")
require.NotEmpty(t, body["request_id"])
}
func TestErrorHandlerMiddlewareHidesRecoveredPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/panic", httpserver.Handle(func(*gin.Context) error {
panic("private panic details")
}))
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/panic", nil)
router.ServeHTTP(recorder, request)
var body map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.Equal(t, "Something went wrong", body["error"])
require.Equal(t, string(apperror.CodeInternal), body["code"])
require.NotContains(t, recorder.Body.String(), "private panic details")
require.NotEmpty(t, body["request_id"])
}
func TestErrorHandlerMiddlewareIncludesSafeDetails(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/users", func(c *gin.Context) {
_ = c.Error(apperror.AlreadyInUse("email"))
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, request)
var body struct {
Details map[string]string `json:"details"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, map[string]string{"property": "email"}, body.Details)
}
func TestErrorHandlerMiddlewareSetsRetryAfter(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/limited", func(c *gin.Context) {
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(1500 * time.Millisecond))
c.Abort()
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/limited", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
require.Equal(t, "2", recorder.Header().Get("Retry-After"))
}
func TestValidationResponseUsesJSONFieldNames(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.POST("/users", httpserver.Handle(func(c *gin.Context) error {
var input dto.UserCreateDto
return httpserver.BindJSON(c, &input)
}))
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/users", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
var body struct {
Details struct {
Fields []apperror.FieldError `json:"fields"`
} `json:"details"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Contains(t, body.Details.Fields, apperror.FieldError{
Field: "username",
Code: "required",
Message: "is required",
})
}
func TestClassifyUnmappedPersistenceErrorAsInternal(t *testing.T) {
classified := classifyError(gorm.ErrRecordNotFound)
require.Equal(t, apperror.CodeInternal, classified.code)
require.Equal(t, http.StatusInternalServerError, classified.status)
}
func TestClassifyDeadlineAsRequestTimeout(t *testing.T) {
classified := classifyError(context.DeadlineExceeded)
require.Equal(t, apperror.CodeRequestTimeout, classified.code)
require.Equal(t, http.StatusGatewayTimeout, classified.status)
require.Equal(t, "Request timed out", classified.message)
}
+10 -2
View File
@@ -1,11 +1,13 @@
package middleware
import (
"errors"
"fmt"
"mime/multipart"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
)
type FileSizeLimitMiddleware struct{}
@@ -18,7 +20,13 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
// Classify only size-limit failures as file_too_large so malformed multipart bodies remain invalid requests
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) || errors.Is(err, multipart.ErrMessageTooLarge) {
err = apperror.FileTooLarge(formatFileSize(maxSize))
} else {
err = apperror.InvalidRequestBody(err)
}
_ = c.Error(err)
c.Abort()
return
@@ -0,0 +1,66 @@
package middleware
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/stretchr/testify/require"
)
func TestFileSizeLimitMiddlewareClassifiesMultipartErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("oversized body", func(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "large.png")
require.NoError(t, err)
_, err = part.Write(bytes.Repeat([]byte("x"), 128))
require.NoError(t, err)
require.NoError(t, writer.Close())
recorder := serveMultipartRequest(t, body.Bytes(), writer.FormDataContentType(), 64)
require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
require.Equal(t, apperror.CodeFileTooLarge, responseCode(t, recorder))
})
t.Run("malformed body", func(t *testing.T) {
recorder := serveMultipartRequest(t, []byte("not multipart"), "multipart/form-data; boundary=missing", 1024)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Equal(t, apperror.CodeInvalidRequestBody, responseCode(t, recorder))
})
}
func serveMultipartRequest(t *testing.T, body []byte, contentType string, maxSize int64) *httptest.ResponseRecorder {
t.Helper()
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.POST("/", NewFileSizeLimitMiddleware().Add(maxSize), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", bytes.NewReader(body))
request.Header.Set("Content-Type", contentType)
router.ServeHTTP(recorder, request)
return recorder
}
func responseCode(t *testing.T, recorder *httptest.ResponseRecorder) apperror.Code {
t.Helper()
var body struct {
Code apperror.Code `json:"code"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
return body.Code
}
+9 -9
View File
@@ -5,7 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -44,37 +44,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token)
if err != nil {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
authenticationTime, _ = token.IssuedAt()
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return "", false, "", time.Time{}, &common.TokenInvalidError{}
_ = c.Error(apperror.TokenInvalid())
return "", false, "", time.Time{}, apperror.TokenInvalid()
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
return "", false, "", time.Time{}, &common.NotSignedInError{}
return "", false, "", time.Time{}, apperror.NotSignedIn()
}
if user.Disabled {
return "", false, "", time.Time{}, &common.UserDisabledError{}
return "", false, "", time.Time{}, apperror.UserDisabled()
}
if adminRequired && !user.IsAdmin {
return "", false, "", time.Time{}, &common.MissingPermissionError{}
return "", false, "", time.Time{}, apperror.MissingPermission()
}
return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil
+7 -10
View File
@@ -3,16 +3,15 @@ package middleware
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"net"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
@@ -82,11 +81,12 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
}
}
// A missing service means the policy was never registered on the actor host, which is a development-time errror
// A missing service means the policy was never registered on the actor host, which is a development-time error
svc := m.services[policy]
if svc == nil {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusInternalServerError)
_ = c.Error(apperror.Internal(fmt.Errorf("rate limiter service is not configured for policy %q", policy)))
c.Abort()
}
}
@@ -113,11 +113,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
}
if !allowed {
// Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
}
_ = c.Error(&common.TooManyRequestsError{})
// Advertise when the caller may retry, mapping the limiter's delay onto a structured application error
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(retryAfter))
c.Abort()
return
}
@@ -144,6 +144,9 @@ func TestRateLimitMiddleware(t *testing.T) {
t.Run("fails with 500 when the policy is not registered", func(t *testing.T) {
// An unknown policy has no bound service, which is a configuration error surfaced as a 500
r := newRateLimitRouter(t, services, "does-not-exist")
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
w := doRateLimitRequest(t.Context(), r, "203.0.113.6")
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), `"code":"internal_error"`)
assert.NotContains(t, w.Body.String(), "rate limiter service is not configured")
})
}