chikit

package module
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 8, 2026 License: MIT Imports: 18 Imported by: 0

README

chikit

Go Version Go Reference Go Report Card Latest Release

Production-grade Chi middleware library for distributed systems. Part of the *kit ecosystem (alongside pgxkit) providing focused, high-quality Go libraries.

Follows 12-factor app principles with all configuration via explicit parameters—no config files, no environment variable access in middleware.

Features

  • Response Wrapper: Context-based response handling with structured JSON errors
  • Request Timeout: Hard-cutoff timeout with 504 response, context cancellation for DB/HTTP calls
  • Flexible Rate Limiting: Multi-dimensional rate limiting with Redis support for distributed deployments
  • Header Management: Extract and validate headers with context injection
  • Request Validation: Body size limits, query parameter validation, header allow/deny lists
  • Request Binding: JSON body and query parameter binding with validation
  • Authentication: API key and bearer token validation with custom validators
  • SLO Tracking: Per-route SLO classification with PASS/FAIL logging via canonlog
  • Zero Config Files: Pure code configuration - no config files or environment variables
  • Distributed-Ready: Redis backend for Kubernetes deployments
  • Fluent API: Chainable, readable middleware configuration

Installation

go get github.com/nhalm/chikit

Quick Start

import (
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/nhalm/chikit"
)

func main() {
    r := chi.NewRouter()

    // chikit middleware (Handler must be outermost)
    r.Use(chikit.Handler())
    r.Use(chikit.Binder())  // Required for JSON/Query binding

    r.Post("/users", func(w http.ResponseWriter, r *http.Request) {
        var req CreateUserRequest
        if !chikit.JSON(r, &req) {
            return  // Validation error already set
        }
        chikit.SetResponse(r, http.StatusCreated, user)
    })

    http.ListenAndServe(":8080", r)
}

Key points:

  • chikit.Handler() must be the outermost middleware - it manages response state
  • chikit.Binder() is required before using chikit.JSON() or chikit.Query()
  • Use chikit.SetResponse() and chikit.SetError() instead of writing directly to http.ResponseWriter
  • All responses are JSON with consistent error formatting

Response Wrapper

The wrapper provides context-based response handling. Handlers and middleware set responses in request context rather than writing directly to ResponseWriter, enabling consistent JSON responses and structured errors.

Basic Usage
import (
    "github.com/go-chi/chi/v5"
    "github.com/nhalm/chikit"
)

func main() {
    r := chi.NewRouter()
    r.Use(chikit.Handler())  // Must be outermost middleware

    r.Post("/users", func(w http.ResponseWriter, r *http.Request) {
        user, err := createUser(r)
        if err != nil {
            chikit.SetError(r, chikit.ErrInternal.With("Failed to create user"))
            return
        }
        chikit.SetResponse(r, http.StatusCreated, user)
    })
}
Structured Errors

Errors follow a structured format:

// Predefined sentinel errors
chikit.ErrBadRequest          // 400
chikit.ErrUnauthorized        // 401
chikit.ErrPaymentRequired     // 402
chikit.ErrForbidden           // 403
chikit.ErrNotFound            // 404
chikit.ErrMethodNotAllowed    // 405
chikit.ErrConflict            // 409
chikit.ErrGone                // 410
chikit.ErrPayloadTooLarge     // 413
chikit.ErrUnprocessableEntity // 422
chikit.ErrRateLimited         // 429
chikit.ErrInternal            // 500
chikit.ErrNotImplemented      // 501
chikit.ErrServiceUnavailable  // 503
chikit.ErrGatewayTimeout      // 504

// Customize message
chikit.SetError(r, chikit.ErrNotFound.With("User not found"))

// Customize message and parameter
chikit.SetError(r, chikit.ErrBadRequest.WithParam("Invalid email format", "email"))

JSON response format:

{
  "error": {
    "type": "not_found",
    "code": "resource_not_found",
    "message": "User not found"
  }
}
Validation Errors

For multiple field errors:

chikit.SetError(r, chikit.NewValidationError([]chikit.FieldError{
    {Param: "email", Code: "required", Message: "Email is required"},
    {Param: "age", Code: "min", Message: "Age must be at least 18"},
}))

JSON response:

{
  "error": {
    "type": "validation_error",
    "code": "invalid_request",
    "message": "Validation failed",
    "errors": [
      {"param": "email", "code": "required", "message": "Email is required"},
      {"param": "age", "code": "min", "message": "Age must be at least 18"}
    ]
  }
}
Setting Headers
chikit.SetHeader(r, "X-Request-ID", requestID)
chikit.SetHeader(r, "X-RateLimit-Remaining", "99")
chikit.AddHeader(r, "X-Custom", "value1")
chikit.AddHeader(r, "X-Custom", "value2")  // Adds second value
Dual-Mode Middleware

Middleware can check if wrapper is present and fall back gracefully:

func MyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if err := validate(r); err != nil {
            if chikit.HasState(r.Context()) {
                chikit.SetError(r, chikit.ErrBadRequest.With(err.Error()))
            } else {
                http.Error(w, err.Error(), http.StatusBadRequest)
            }
            return
        }
        next.ServeHTTP(w, r)
    })
}
Panic Recovery

The Handler middleware automatically recovers from panics and returns a 500 error:

r.Use(chikit.Handler())

r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
    panic("something went wrong")  // Returns {"error": {"type": "internal_error", ...}}
})
Request Timeout

Add hard-cutoff timeouts that guarantee response time:

r.Use(chikit.Handler(
    chikit.WithTimeout(30*time.Second),
    chikit.WithCanonlog(),
))

r.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
    // If this takes longer than 30s, the client gets a 504 immediately.
    // The context is cancelled so DB/HTTP calls can exit early.
    result, err := slowDatabaseQuery(r.Context())
    if err != nil {
        // Handle context.DeadlineExceeded gracefully
        if errors.Is(err, context.DeadlineExceeded) {
            return // Timeout already handled by middleware
        }
        chikit.SetError(r, chikit.ErrInternal.With("Query failed"))
        return
    }
    chikit.SetResponse(r, http.StatusOK, result)
})

How it works:

  1. Handler runs in a goroutine with context.WithTimeout()
  2. If timeout fires, a 504 Gateway Timeout is written immediately
  3. The context is cancelled so DB/HTTP calls see the deadline and can exit early
  4. After the grace timeout (default 5s), the handler is considered abandoned

Timeout options:

Option Description
WithTimeout(d) Maximum handler execution time
WithGracefulShutdown(d) Grace period after 504 is written for handler cleanup (default 5s)
WithAbandonCallback(fn) Called when handler doesn't exit within grace period

Graceful shutdown:

When using timeouts, handlers run in goroutines. For graceful shutdown, wait for all handlers to complete:

func main() {
    r := chi.NewRouter()
    r.Use(chikit.Handler(chikit.WithTimeout(30 * time.Second)))
    // ... routes ...

    srv := &http.Server{Addr: ":8080", Handler: r}
    go srv.ListenAndServe()

    // Wait for shutdown signal
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh

    // Graceful shutdown
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    srv.Shutdown(ctx)           // Wait for in-flight requests
    chikit.WaitForHandlers(ctx) // Wait for handler goroutines
}

Important limitation: Go cannot forcibly terminate goroutines. If your handler ignores context cancellation (CGO calls, tight CPU loops, legacy code without context), the goroutine continues running after the 504 response. Use WithAbandonCallback to track this with metrics. If a handler panics after timeout fires, the panic is caught and logged but the 504 response has already been sent to the client.

Canonical Logging

Integrate with canonlog for structured request logging:

import "github.com/nhalm/canonlog"

func main() {
    canonlog.SetupGlobalLogger("info", "json")

    r := chi.NewRouter()
    r.Use(chikit.Handler(
        chikit.WithCanonlog(),
        chikit.WithCanonlogFields(func(r *http.Request) map[string]any {
            return map[string]any{
                "request_id": r.Header.Get("X-Request-ID"),
                "tenant_id":  r.Header.Get("X-Tenant-ID"),
            }
        }),
    ))
}

This automatically logs for each request:

  • method, path, route (Chi route pattern)
  • status, duration_ms
  • errors array (when errors occur)

Output:

{"time":"...","level":"INFO","msg":"","method":"GET","path":"/users/123","route":"/users/{id}","status":200,"duration_ms":45,"request_id":"abc-123"}
SLO Integration

Enable SLO status logging with WithSLOs(). See SLO Tracking for details.

Rate Limiting

Basic Usage
import (
    "github.com/go-chi/chi/v5"
    "github.com/nhalm/chikit"
    "github.com/nhalm/chikit/store"
    "time"
)

func main() {
    r := chi.NewRouter()

    // In-memory store (development)
    st := store.NewMemory()
    defer st.Close()

    // Rate limit by IP: 100 requests per minute
    r.Use(chikit.NewRateLimiter(st, 100, 1*time.Minute, chikit.RateLimitWithIP()).Handler)

    // Rate limit by header (skip if header missing)
    r.Use(chikit.NewRateLimiter(st, 1000, 1*time.Hour, chikit.RateLimitWithHeader("X-API-Key")).Handler)

    // Rate limit by endpoint
    r.Use(chikit.NewRateLimiter(st, 100, 1*time.Minute, chikit.RateLimitWithEndpoint()).Handler)
}
Multi-Dimensional Rate Limiting

Combine multiple key dimensions for fine-grained control:

// Rate limit by IP + endpoint combination
limiter := chikit.NewRateLimiter(st, 100, 1*time.Minute,
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithEndpoint(),
)
r.Use(limiter.Handler)

// Rate limit by IP + tenant header (reject if header missing)
limiter := chikit.NewRateLimiter(st, 1000, 1*time.Hour,
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithHeaderRequired("X-Tenant-ID"),
)
r.Use(limiter.Handler)

// Complex multi-dimensional rate limiting
limiter := chikit.NewRateLimiter(st, 50, 1*time.Minute,
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithEndpoint(),
    chikit.RateLimitWithHeaderRequired("X-API-Key"),
    chikit.RateLimitWithQueryParam("user_id"),
)
r.Use(limiter.Handler)
Key Dimension Options
Option Description
RateLimitWithIP() Client IP from RemoteAddr (direct connections)
RateLimitWithRealIP() Client IP from X-Forwarded-For/X-Real-IP (behind proxy)
RateLimitWithRealIPRequired() Same as RateLimitWithRealIP, but returns 400 if missing
RateLimitWithEndpoint() HTTP method + path (e.g., GET:/api/users)
RateLimitWithHeader(name) Header value (skip if missing)
RateLimitWithHeaderRequired(name) Header value (400 if missing)
RateLimitWithQueryParam(name) Query parameter value (skip if missing)
RateLimitWithQueryParamRequired(name) Query parameter value (400 if missing)
RateLimitWithName(name) Key prefix for collision prevention

The *Required variants return 400 Bad Request if the value is missing. The non-required variants skip rate limiting for that request if the value is missing.

Redis Backend (Production)

For distributed deployments:

import (
    "log"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/nhalm/chikit"
    "github.com/nhalm/chikit/store"
)

func main() {
    st, err := store.NewRedis(store.RedisConfig{
        URL:    "redis:6379",
        Prefix: "rl:",
    })
    if err != nil {
        log.Fatal(err)
    }
    defer st.Close()

    r := chi.NewRouter()
    r.Use(chikit.NewRateLimiter(st, 100, time.Minute, chikit.RateLimitWithIP()).Handler)
}
Rate Limit Headers

All rate limiters set standard headers following the IETF draft-ietf-httpapi-ratelimit-headers specification:

RateLimit-Limit: 100
RateLimit-Remaining: 95
RateLimit-Reset: 1735401600
Retry-After: 60

Header behavior can be configured:

// Always include headers (default)
limiter := chikit.NewRateLimiter(st, 100, 1*time.Minute,
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithHeaderMode(chikit.RateLimitHeadersAlways),
)

// Include headers only on 429 responses
limiter := chikit.NewRateLimiter(st, 100, 1*time.Minute,
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithHeaderMode(chikit.RateLimitHeadersOnLimitExceeded),
)

// Never include headers
limiter := chikit.NewRateLimiter(st, 100, 1*time.Minute,
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithHeaderMode(chikit.RateLimitHeadersNever),
)
Layered Rate Limiting

When applying multiple rate limiters to the same routes, use RateLimitWithName() to prevent key collisions:

st := store.NewMemory()
defer st.Close()

// Global limit: 1000 requests per hour per IP
globalLimiter := chikit.NewRateLimiter(st, 1000, 1*time.Hour,
    chikit.RateLimitWithName("global"),
    chikit.RateLimitWithIP(),
)

// Endpoint-specific limit: 10 requests per minute per IP+endpoint
endpointLimiter := chikit.NewRateLimiter(st, 10, 1*time.Minute,
    chikit.RateLimitWithName("endpoint"),
    chikit.RateLimitWithIP(),
    chikit.RateLimitWithEndpoint(),
)

// Apply both limiters
r.Use(globalLimiter.Handler)
r.Use(endpointLimiter.Handler)

Without RateLimitWithName(), the keys would collide because both limiters use RateLimitWithIP(). The name is prepended to the key:

// Without RateLimitWithName():
192.168.1.1                           // Both limiters use this key - collision!

// With RateLimitWithName():
global:192.168.1.1                    // Global limiter
endpoint:192.168.1.1:GET:/api/users   // Endpoint limiter - independent

This pattern is useful for implementing tiered rate limits:

// Tier 1: Broad protection (DDoS prevention)
ddosLimiter := chikit.NewRateLimiter(st, 10000, 1*time.Hour,
    chikit.RateLimitWithName("ddos"),
    chikit.RateLimitWithIP(),
)
r.Use(ddosLimiter.Handler)

// Tier 2: API endpoint protection
r.Route("/api", func(r chi.Router) {
    apiLimiter := chikit.NewRateLimiter(st, 100, 1*time.Minute,
        chikit.RateLimitWithName("api"),
        chikit.RateLimitWithIP(),
        chikit.RateLimitWithEndpoint(),
    )
    r.Use(apiLimiter.Handler)
})

// Tier 3: Expensive operation protection
r.Route("/api/analytics/run", func(r chi.Router) {
    analyticsLimiter := chikit.NewRateLimiter(st, 5, 1*time.Hour,
        chikit.RateLimitWithName("analytics"),
        chikit.RateLimitWithHeaderRequired("X-User-ID"),
    )
    r.Use(analyticsLimiter.Handler)
    r.Post("/", analyticsHandler)
})

Header Management

Generic Header to Context

Extract any header with validation:

import "github.com/nhalm/chikit"

// Simple header extraction
r.Use(chikit.ExtractHeader("X-API-Key", "api_key"))

// With validation
r.Use(chikit.ExtractHeader("X-Correlation-ID", "correlation_id",
    chikit.ExtractRequired(),
    chikit.ExtractWithValidator(func(val string) (any, error) {
        if len(val) < 10 {
            return nil, errors.New("correlation ID too short")
        }
        return val, nil
    }),
))

// With default value
r.Use(chikit.ExtractHeader("X-Environment", "environment",
    chikit.ExtractDefault("production"),
))

// Retrieve in handler
func handler(w http.ResponseWriter, r *http.Request) {
    apiKey, ok := chikit.HeaderFromContext(r.Context(), "api_key")
    if !ok {
        http.Error(w, "No API key", http.StatusUnauthorized)
        return
    }
}
Example: Tenant ID as UUID
import (
    "github.com/google/uuid"
    "github.com/nhalm/chikit"
)

// Extract X-Tenant-ID header as UUID with validation
r.Use(chikit.ExtractHeader("X-Tenant-ID", "tenant_id",
    chikit.ExtractRequired(),
    chikit.ExtractWithValidator(func(val string) (any, error) {
        return uuid.Parse(val)
    }),
))

// Retrieve in handler
func handler(w http.ResponseWriter, r *http.Request) {
    val, ok := chikit.HeaderFromContext(r.Context(), "tenant_id")
    if !ok {
        http.Error(w, "No tenant ID", http.StatusBadRequest)
        return
    }
    tenantID := val.(uuid.UUID)
    // Use tenantID...
}

Request Validation

Body Size Limits

Prevent DoS attacks by limiting request body size:

import "github.com/nhalm/chikit"

// Limit request body to 1MB
r.Use(chikit.MaxBodySize(1024 * 1024))

The middleware provides two-stage protection:

  1. Content-Length check: Requests with Content-Length exceeding the limit are rejected with 413 immediately, before the handler runs
  2. MaxBytesReader wrapper: All request bodies are wrapped with http.MaxBytesReader as defense-in-depth, catching chunked transfers and requests with missing/incorrect Content-Length headers

When using chikit.JSON, the second stage is automatic - if the body exceeds the limit during decoding, chikit.JSON detects the error and returns chikit.ErrPayloadTooLarge (413).

Header Validation

Validate headers with allow/deny lists:

// Required header
r.Use(chikit.ValidateHeaders(
    chikit.ValidateWithHeader("X-API-Key", chikit.ValidateRequired()),
))

// Allow list (only specific values allowed)
r.Use(chikit.ValidateHeaders(
    chikit.ValidateWithHeader("X-Environment",
        chikit.ValidateAllowList("production", "staging", "development"),
    ),
))

// Deny list (block specific values)
r.Use(chikit.ValidateHeaders(
    chikit.ValidateWithHeader("X-Source",
        chikit.ValidateDenyList("blocked-client", "banned-user"),
    ),
))

// Case-sensitive validation (default: case-insensitive)
r.Use(chikit.ValidateHeaders(
    chikit.ValidateWithHeader("X-Auth-Token",
        chikit.ValidateAllowList("Bearer", "Basic"),
        chikit.ValidateCaseSensitive(),
    ),
))

// Multiple header rules
r.Use(chikit.ValidateHeaders(
    chikit.ValidateWithHeader("X-API-Key", chikit.ValidateRequired()),
    chikit.ValidateWithHeader("X-Environment", chikit.ValidateAllowList("production", "staging")),
    chikit.ValidateWithHeader("X-Source", chikit.ValidateDenyList("blocked")),
))

Request Binding

The bind functions provide JSON body and query parameter binding with validation using go-playground/validator/v10.

JSON Binding
import "github.com/nhalm/chikit"

type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Name  string `json:"name" validate:"required,min=2"`
    Age   int    `json:"age" validate:"omitempty,min=18"`
}

func main() {
    r := chi.NewRouter()
    r.Use(chikit.Handler())
    r.Use(chikit.Binder())

    r.Post("/users", func(w http.ResponseWriter, r *http.Request) {
        var req CreateUserRequest
        if !chikit.JSON(r, &req) {
            return  // Validation error already set in wrapper
        }
        // Use req.Email, req.Name, req.Age
        chikit.SetResponse(r, http.StatusCreated, user)
    })
}
Query Parameter Binding
type ListUsersQuery struct {
    Page   int    `query:"page" validate:"omitempty,min=1"`
    Limit  int    `query:"limit" validate:"omitempty,min=1,max=100"`
    Search string `query:"search" validate:"omitempty,min=3"`
}

r.Get("/users", func(w http.ResponseWriter, r *http.Request) {
    var query ListUsersQuery
    if !chikit.Query(r, &query) {
        return  // Validation error already set in wrapper
    }
    // Use query.Page, query.Limit, query.Search
})
Custom Validation Messages
r.Use(chikit.Binder(chikit.BindWithFormatter(func(field, tag, param string) string {
    switch tag {
    case "required":
        return field + " is required"
    case "email":
        return field + " must be a valid email address"
    case "min":
        return field + " must be at least " + param
    default:
        return field + " is invalid"
    }
})))
Custom Validators

Register custom validation tags at startup:

func init() {
    chikit.RegisterValidation("customtag", func(fl validator.FieldLevel) bool {
        return fl.Field().String() == "valid"
    })
}

Authentication

API Key Authentication

Validate API keys with custom validators:

import "github.com/nhalm/chikit"

// Simple validator
validator := func(key string) bool {
    return key == "secret-key"
}

r.Use(chikit.APIKey(validator))

// Custom header
r.Use(chikit.APIKey(validator, chikit.WithAPIKeyHeader("X-Custom-Key")))

// Optional API key
r.Use(chikit.APIKey(validator, chikit.WithOptionalAPIKey()))

// Retrieve in handler
func handler(w http.ResponseWriter, r *http.Request) {
    key, ok := chikit.APIKeyFromContext(r.Context())
    if ok {
        // Use API key
    }
}
Bearer Token Authentication

Validate bearer tokens from Authorization headers:

// JWT validator example
validator := func(token string) bool {
    // Validate JWT, check expiry, etc.
    return validateJWT(token)
}

r.Use(chikit.BearerToken(validator))

// Optional bearer token
r.Use(chikit.BearerToken(validator, chikit.WithOptionalBearerToken()))

// Retrieve in handler
func handler(w http.ResponseWriter, r *http.Request) {
    token, ok := chikit.BearerTokenFromContext(r.Context())
    if ok {
        // Use bearer token
    }
}

SLO Tracking

Track service level objectives with per-route SLO classification. The SLO middleware sets tier and target in request context, and the wrapper middleware logs PASS/FAIL status via canonlog.

Predefined Tiers
Tier Target Use Case
SLOCritical 50ms Essential functions (99.99% availability)
SLOHighFast 100ms User-facing requests requiring quick responses
SLOHighSlow 1000ms Important requests tolerating higher latency
SLOLow 5000ms Background tasks, non-interactive functions
Basic Usage
import (
    "github.com/nhalm/canonlog"
    "github.com/nhalm/chikit"
)

func main() {
    canonlog.SetupGlobalLogger("info", "json")

    r := chi.NewRouter()

    // Enable canonlog and SLO logging
    r.Use(chikit.Handler(
        chikit.WithCanonlog(),
        chikit.WithSLOs(),
    ))

    // Set SLO tier per route
    r.With(chikit.SLO(chikit.SLOCritical)).Get("/health", healthHandler)
    r.With(chikit.SLO(chikit.SLOHighFast)).Get("/users/{id}", getUser)
    r.With(chikit.SLO(chikit.SLOHighSlow)).Post("/reports", generateReport)
    r.With(chikit.SLO(chikit.SLOLow)).Post("/batch", batchProcess)
}
Custom Targets

For routes that don't fit predefined tiers:

r.With(chikit.SLOWithTarget(200 * time.Millisecond)).Get("/custom", handler)

Custom targets are logged with slo_class: "custom".

Log Output

Success (within target):

{"time":"...","level":"INFO","msg":"","method":"GET","path":"/users/123","route":"/users/{id}","status":200,"duration_ms":45,"slo_class":"high_fast","slo_status":"PASS"}

SLO breach (exceeded target):

{"time":"...","level":"INFO","msg":"","method":"GET","path":"/users/123","route":"/users/{id}","status":200,"duration_ms":150,"slo_class":"high_fast","slo_status":"FAIL"}
Alerting

Use your log aggregator to create alerts based on SLO status:

-- Example: Alert when >1% of high_fast requests fail in 5 minutes
SELECT
  COUNT(*) FILTER (WHERE slo_status = 'FAIL') * 100.0 / COUNT(*) as failure_rate
FROM logs
WHERE slo_class = 'high_fast'
  AND timestamp > NOW() - INTERVAL '5 minutes'
HAVING failure_rate > 1.0
Routes Without SLO

Routes without chikit.SLO() middleware won't have SLO fields in logs:

{"time":"...","level":"INFO","msg":"","method":"GET","path":"/misc","route":"/misc","status":200,"duration_ms":30}

Complete Example

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/google/uuid"
    "github.com/nhalm/canonlog"
    "github.com/nhalm/chikit"
    "github.com/nhalm/chikit/store"
)

type ListUsersQuery struct {
    Page  int `query:"page" validate:"omitempty,min=1"`
    Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
}

type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Name  string `json:"name" validate:"required,min=2"`
}

func main() {
    // Setup canonical logging
    canonlog.SetupGlobalLogger("info", "json")

    r := chi.NewRouter()

    // Standard Chi middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)

    // Wrapper with timeout, canonlog, and SLO logging
    r.Use(chikit.Handler(
        chikit.WithTimeout(30*time.Second),
        chikit.WithCanonlog(),
        chikit.WithCanonlogFields(func(r *http.Request) map[string]any {
            return map[string]any{
                "request_id": middleware.GetReqID(r.Context()),
            }
        }),
        chikit.WithSLOs(),
    ))

    // Bind middleware for request binding/validation
    r.Use(chikit.Binder())

    // Limit request body size to 10MB
    r.Use(chikit.MaxBodySize(10 * 1024 * 1024))

    // Validate environment header
    r.Use(chikit.ValidateHeaders(
        chikit.ValidateWithHeader("X-Environment",
            chikit.ValidateAllowList("production", "staging", "development"),
        ),
    ))

    // Extract tenant ID from header
    r.Use(chikit.ExtractHeader("X-Tenant-ID", "tenant_id",
        chikit.ExtractRequired(),
        chikit.ExtractWithValidator(func(val string) (any, error) {
            return uuid.Parse(val)
        }),
    ))

    // Redis store
    st, err := store.NewRedis(store.RedisConfig{
        URL:      "redis:6379",
        Password: "",
        DB:       0,
        Prefix:   "ratelimit:",
    })
    if err != nil {
        log.Fatal(err)
    }
    defer st.Close()

    // Global rate limit: 1000 requests per hour per IP
    globalLimiter := chikit.NewRateLimiter(st, 1000, 1*time.Hour,
        chikit.RateLimitWithName("global"),
        chikit.RateLimitWithIP(),
    )
    r.Use(globalLimiter.Handler)

    // Health check with strict SLO
    r.With(chikit.SLO(chikit.SLOCritical)).Get("/health", healthHandler)

    // API routes
    r.Route("/api/v1", func(r chi.Router) {
        // API key authentication
        r.Use(chikit.APIKey(func(key string) bool {
            return validateAPIKey(key)
        }))

        // Per-tenant rate limiting: 100 requests per minute
        tenantLimiter := chikit.NewRateLimiter(st, 100, 1*time.Minute,
            chikit.RateLimitWithName("tenant"),
            chikit.RateLimitWithIP(),
            chikit.RateLimitWithHeaderRequired("X-Tenant-ID"),
        )
        r.Use(tenantLimiter.Handler)

        r.With(chikit.SLO(chikit.SLOHighFast)).Get("/users", listUsers)
        r.With(chikit.SLO(chikit.SLOHighFast)).Get("/users/{id}", getUser)
        r.With(chikit.SLO(chikit.SLOHighFast)).Post("/users", createUser)
        r.With(chikit.SLO(chikit.SLOHighSlow)).Post("/reports", generateReport)
    })

    srv := &http.Server{Addr: ":8080", Handler: r}
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    // Wait for shutdown signal
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh

    // Graceful shutdown
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    srv.Shutdown(ctx)
    chikit.WaitForHandlers(ctx) // Wait for handler goroutines when using WithTimeout
}

func validateAPIKey(key string) bool {
    // Implement your API key validation
    return true
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    chikit.SetResponse(r, http.StatusOK, map[string]string{"status": "ok"})
}

func listUsers(w http.ResponseWriter, r *http.Request) {
    val, ok := chikit.HeaderFromContext(r.Context(), "tenant_id")
    if !ok {
        chikit.SetError(r, chikit.ErrBadRequest.With("No tenant ID"))
        return
    }
    tenantID := val.(uuid.UUID)

    var query ListUsersQuery
    if !chikit.Query(r, &query) {
        return
    }

    // Query users for tenant...
    chikit.SetResponse(r, http.StatusOK, map[string]any{
        "tenant": tenantID.String(),
        "page":   query.Page,
        "limit":  query.Limit,
    })
}

func getUser(w http.ResponseWriter, r *http.Request) {
    chikit.SetResponse(r, http.StatusOK, map[string]string{"id": chi.URLParam(r, "id")})
}

func createUser(w http.ResponseWriter, r *http.Request) {
    val, ok := chikit.HeaderFromContext(r.Context(), "tenant_id")
    if !ok {
        chikit.SetError(r, chikit.ErrBadRequest.With("No tenant ID"))
        return
    }
    tenantID := val.(uuid.UUID)

    var req CreateUserRequest
    if !chikit.JSON(r, &req) {
        return // Returns 400 for validation errors, 413 if body exceeds MaxBodySize limit
    }

    // Create user for tenant...
    chikit.SetResponse(r, http.StatusCreated, map[string]any{
        "tenant": tenantID.String(),
        "email":  req.Email,
    })
}

func generateReport(w http.ResponseWriter, r *http.Request) {
    // Long-running report generation...
    chikit.SetResponse(r, http.StatusOK, map[string]string{"status": "complete"})
}

License

MIT

Documentation

Overview

Package chikit provides production-grade middleware components for Chi routers.

This file contains the core error types used throughout chikit for structured API error responses. These types enable consistent, Stripe-style error handling across all middleware and handlers.

Package chikit provides state management for context-based response handling.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrBadRequest          = &APIError{Type: "request_error", Code: "bad_request", Message: "Bad request", Status: http.StatusBadRequest}
	ErrUnauthorized        = &APIError{Type: "auth_error", Code: "unauthorized", Message: "Unauthorized", Status: http.StatusUnauthorized}
	ErrPaymentRequired     = &APIError{Type: "request_error", Code: "payment_required", Message: "Payment required", Status: http.StatusPaymentRequired}
	ErrForbidden           = &APIError{Type: "auth_error", Code: "forbidden", Message: "Forbidden", Status: http.StatusForbidden}
	ErrNotFound            = &APIError{Type: "not_found", Code: "resource_not_found", Message: "Resource not found", Status: http.StatusNotFound}
	ErrMethodNotAllowed    = &APIError{Type: "request_error", Code: "method_not_allowed", Message: "Method not allowed", Status: http.StatusMethodNotAllowed}
	ErrConflict            = &APIError{Type: "request_error", Code: "conflict", Message: "Conflict", Status: http.StatusConflict}
	ErrGone                = &APIError{Type: "request_error", Code: "gone", Message: "Resource gone", Status: http.StatusGone}
	ErrPayloadTooLarge     = &APIError{Type: "request_error", Code: "payload_too_large", Message: "Payload too large", Status: http.StatusRequestEntityTooLarge}
	ErrUnprocessableEntity = &APIError{Type: "validation_error", Code: "unprocessable", Message: "Unprocessable entity", Status: http.StatusUnprocessableEntity}
	ErrRateLimited         = &APIError{Type: "rate_limit_error", Code: "limit_exceeded", Message: "Rate limit exceeded", Status: http.StatusTooManyRequests}
	ErrInternal            = &APIError{Type: "internal_error", Code: "internal", Message: "Internal server error", Status: http.StatusInternalServerError}
	ErrNotImplemented      = &APIError{Type: "request_error", Code: "not_implemented", Message: "Not implemented", Status: http.StatusNotImplemented}
	ErrServiceUnavailable  = &APIError{Type: "request_error", Code: "service_unavailable", Message: "Service unavailable", Status: http.StatusServiceUnavailable}
	ErrGatewayTimeout      = &APIError{Type: "timeout_error", Code: "gateway_timeout", Message: "Request timed out", Status: http.StatusGatewayTimeout}
)

Predefined sentinel errors

Functions

func APIKey

func APIKey(validator APIKeyValidator, opts ...APIKeyOption) func(http.Handler) http.Handler

APIKey returns middleware that validates API keys from a header. Returns 401 (Unauthorized) if the key is missing (when required) or invalid. The validated API key is stored in the request context and can be retrieved using APIKeyFromContext.

Example:

validator := func(key string) bool {
	return key == "secret-key" // In production, check against DB/cache
}
r.Use(chikit.APIKey(validator))

With custom header:

r.Use(chikit.APIKey(validator, chikit.WithAPIKeyHeader("X-Custom-Key")))

Optional authentication:

r.Use(chikit.APIKey(validator, chikit.WithOptionalAPIKey()))
Example
validator := func(key string) bool {
	return key == "valid-api-key"
}

r := chi.NewRouter()
r.Use(chikit.APIKey(validator))

func APIKeyFromContext

func APIKeyFromContext(ctx context.Context) (string, bool)

APIKeyFromContext retrieves the validated API key from the request context. Returns the key and true if present, or empty string and false if not present.

Example:

func handler(w http.ResponseWriter, r *http.Request) {
	if key, ok := chikit.APIKeyFromContext(r.Context()); ok {
		log.Printf("Request authenticated with key: %s", key)
	}
}

func ActiveHandlerCount

func ActiveHandlerCount() int

ActiveHandlerCount returns the number of handler goroutines currently running. This is useful for monitoring during graceful shutdown or for metrics. Only counts handlers started with WithTimeout enabled.

func AddHeader

func AddHeader(r *http.Request, key, value string)

AddHeader adds a response header value in the request context. If wrapper middleware is not present (state is nil), this is a no-op. If state is frozen (response already written), this is a no-op. Use HasState() to check if wrapper middleware is active.

func BearerToken

func BearerToken(validator BearerTokenValidator, opts ...BearerTokenOption) func(http.Handler) http.Handler

BearerToken returns middleware that validates bearer tokens from the Authorization header. Expects the header format "Bearer <token>". Returns 401 (Unauthorized) if the token is missing (when required), malformed, or invalid. The validated token is stored in the request context and can be retrieved using BearerTokenFromContext.

Example:

validator := func(token string) bool {
	return jwt.Validate(token) // Use your JWT library
}
r.Use(chikit.BearerToken(validator))

Optional authentication:

r.Use(chikit.BearerToken(validator, chikit.WithOptionalBearerToken()))

func BearerTokenFromContext

func BearerTokenFromContext(ctx context.Context) (string, bool)

BearerTokenFromContext retrieves the validated bearer token from the request context. Returns the token and true if present, or empty string and false if not present.

Example:

func handler(w http.ResponseWriter, r *http.Request) {
	if token, ok := chikit.BearerTokenFromContext(r.Context()); ok {
		claims := jwt.Parse(token)
		log.Printf("User: %s", claims.Subject)
	}
}

func Binder

func Binder(opts ...BindOption) func(http.Handler) http.Handler

Binder returns middleware with optional configuration.

func ExtractHeader

func ExtractHeader(header, ctxKey string, opts ...HeaderExtractorOption) func(http.Handler) http.Handler

ExtractHeader creates middleware that extracts a header and stores it in context. The header value (or transformed value from validator) is stored in the request context under the specified ctxKey and can be retrieved using HeaderFromContext.

Parameters:

  • header: The HTTP header name to extract (e.g., "X-API-Key")
  • ctxKey: The context key to store the value under (e.g., "api_key")
  • opts: Optional configuration (ExtractRequired, ExtractDefault, ExtractWithValidator)

Returns 400 (Bad Request) if:

  • A required header is missing and no default is provided
  • Validation fails (validator returns an error)

Example:

// Simple extraction
r.Use(chikit.ExtractHeader("X-Request-ID", "request_id"))

// Required with validation
r.Use(chikit.ExtractHeader("X-Tenant-ID", "tenant_id",
	chikit.ExtractRequired(),
	chikit.ExtractWithValidator(validateUUID)))

// Optional with default
r.Use(chikit.ExtractHeader("X-Client-Version", "version",
	chikit.ExtractDefault("1.0.0")))
Example
r := chi.NewRouter()

// Extract optional header with default
r.Use(chikit.ExtractHeader("X-Request-ID", "request_id",
	chikit.ExtractDefault("unknown"),
))

r.Get("/", func(_ http.ResponseWriter, r *http.Request) {
	if val, ok := chikit.HeaderFromContext(r.Context(), "request_id"); ok {
		fmt.Printf("Request ID: %s\n", val)
	}
})

func Handler

func Handler(opts ...HandlerOption) func(http.Handler) http.Handler

Handler returns middleware that manages response state and writes responses.

Example
r := chi.NewRouter()
r.Use(chikit.Handler())

r.Get("/", func(_ http.ResponseWriter, r *http.Request) {
	chikit.SetResponse(r, http.StatusOK, map[string]string{"status": "ok"})
})
Example (Timeout)
r := chi.NewRouter()
r.Use(chikit.Handler(
	chikit.WithTimeout(30*time.Second),
	chikit.WithCanonlog(),
))

r.Get("/", func(_ http.ResponseWriter, r *http.Request) {
	// Handler code runs with a 30-second deadline.
	// If the handler doesn't complete in time, a 504 Gateway Timeout
	// is returned to the client immediately.
	chikit.SetResponse(r, http.StatusOK, map[string]string{"status": "ok"})
})
Example (TimeoutWithGrace)
r := chi.NewRouter()
r.Use(chikit.Handler(
	chikit.WithTimeout(30*time.Second),
	chikit.WithGracefulShutdown(10*time.Second),
	chikit.WithAbandonCallback(func(r *http.Request) {
		// Handler didn't exit within grace period after timeout.
		// Log this for investigation - may indicate a stuck handler.
		fmt.Printf("handler abandoned: %s %s\n", r.Method, r.URL.Path)
	}),
))

func HasState

func HasState(ctx context.Context) bool

HasState returns true if wrapper state exists in the context.

func HeaderFromContext

func HeaderFromContext(ctx context.Context, key string) (any, bool)

HeaderFromContext retrieves a value from the request context by key. Returns the value and true if present, or nil and false if not present. The returned value has type 'any' and should be type-asserted to the expected type.

Example:

func handler(w http.ResponseWriter, r *http.Request) {
	if val, ok := chikit.HeaderFromContext(r.Context(), "api_key"); ok {
		apiKey := val.(string)
		log.Printf("API Key: %s", apiKey)
	}

	// With type assertion for transformed values
	if val, ok := chikit.HeaderFromContext(r.Context(), "tenant_id"); ok {
		tenantID := val.(uuid.UUID)
		// Use typed value
	}
}

func JSON

func JSON(r *http.Request, dest any) bool

JSON decodes request body into dest and validates it. Returns true if binding and validation succeeded, false otherwise. When validation fails, an error is set in the wrapper context (if available).

Body size limits: If validate.MaxBodySize middleware is active, requests exceeding the limit during decode return ErrPayloadTooLarge (413). This handles chunked transfers and requests with missing/incorrect Content-Length headers.

Example
package main

import (
	"net/http"

	"github.com/nhalm/chikit"
)

func main() {
	type Request struct {
		Email string `json:"email" validate:"required,email"`
	}

	handler := func(_ http.ResponseWriter, r *http.Request) {
		var req Request
		if !chikit.JSON(r, &req) {
			return // Validation error already set
		}
		chikit.SetResponse(r, http.StatusOK, req)
	}
	_ = handler
}

func MaxBodySize

func MaxBodySize(maxBytes int64, opts ...BodySizeOption) func(http.Handler) http.Handler

MaxBodySize returns middleware that limits request body size.

The middleware provides two-stage protection:

  1. Content-Length check: Requests with Content-Length exceeding the limit are rejected with 413 immediately, before the handler runs
  2. MaxBytesReader wrapper: All request bodies are wrapped with http.MaxBytesReader as defense-in-depth, catching chunked transfers and missing/incorrect Content-Length

When used with bind.JSON, the second stage is automatic:

r.Use(chikit.MaxBodySize(1024 * 1024))
r.Post("/users", func(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if !bind.JSON(r, &req) {
        return // Returns 413 if body exceeds limit during decode
    }
})

Returns 413 (Request Entity Too Large) when the limit is exceeded.

Basic usage:

r.Use(chikit.MaxBodySize(10 * 1024 * 1024)) // 10MB limit
Example
r := chi.NewRouter()
r.Use(chikit.Handler())
r.Use(chikit.MaxBodySize(1024 * 1024)) // 1MB limit

func Query

func Query(r *http.Request, dest any) bool

Query decodes query parameters into dest and validates it. Returns true if binding and validation succeeded, false otherwise. When validation fails, an error is set in the wrapper context (if available).

func RegisterValidation

func RegisterValidation(tag string, fn validator.Func) error

RegisterValidation registers a custom validation function. Must be called at startup before handling requests.

func SLO

func SLO(tier SLOTier) func(http.Handler) http.Handler

SLO sets a predefined SLO tier in context. The tier determines the latency target:

  • SLOCritical: 50ms
  • SLOHighFast: 100ms
  • SLOHighSlow: 1000ms
  • SLOLow: 5000ms

func SLOWithTarget

func SLOWithTarget(target time.Duration) func(http.Handler) http.Handler

SLOWithTarget sets a custom SLO target in context. The tier is logged as "custom".

func SetError

func SetError(r *http.Request, err *APIError)

SetError sets an error response in the request context. If wrapper middleware is not present (state is nil), this is a no-op. If state is frozen (response already written), this is a no-op. Use HasState() to check if wrapper middleware is active.

Example
package main

import (
	"net/http"

	"github.com/nhalm/chikit"
)

func main() {
	handler := func(_ http.ResponseWriter, r *http.Request) {
		// Return a 404 with custom message
		chikit.SetError(r, chikit.ErrNotFound.With("User not found"))
	}
	_ = handler
}

func SetHeader

func SetHeader(r *http.Request, key, value string)

SetHeader sets a response header in the request context. If wrapper middleware is not present (state is nil), this is a no-op. If state is frozen (response already written), this is a no-op. Use HasState() to check if wrapper middleware is active.

func SetResponse

func SetResponse(r *http.Request, status int, body any)

SetResponse sets a success response in the request context. If wrapper middleware is not present (state is nil), this is a no-op. If state is frozen (response already written), this is a no-op. Use HasState() to check if wrapper middleware is active.

func ValidateHeaders

func ValidateHeaders(opts ...ValidateHeadersOption) func(http.Handler) http.Handler

ValidateHeaders returns middleware that validates request headers according to the given rules. For each rule, checks if the header is present (when required), validates against allow/deny lists, and enforces case sensitivity settings. Returns 400 (Bad Request) for all validation failures.

Example:

r.Use(chikit.ValidateHeaders(
	chikit.ValidateWithHeader("Content-Type",
		chikit.ValidateRequired(),
		chikit.ValidateAllowList("application/json", "application/xml")),
	chikit.ValidateWithHeader("X-Custom-Header",
		chikit.ValidateDenyList("forbidden-value")),
))
Example
r := chi.NewRouter()
r.Use(chikit.ValidateHeaders(
	chikit.ValidateWithHeader("Content-Type",
		chikit.ValidateRequired(),
		chikit.ValidateAllowList("application/json"),
	),
))

func WaitForHandlers

func WaitForHandlers(ctx context.Context) error

WaitForHandlers waits for all spawned handler goroutines to complete. Call this during graceful shutdown after http.Server.Shutdown(). Returns nil if all handlers complete, or ctx.Err() if the context deadline is exceeded.

Example graceful shutdown pattern:

srv := &http.Server{Addr: ":8080", Handler: r}
go srv.ListenAndServe()
<-shutdownSignal
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)           // Wait for in-flight requests
chikit.WaitForHandlers(ctx) // Wait for handler goroutines

Note: If the context deadline is exceeded, WaitForHandlers returns immediately. Use ActiveHandlerCount to monitor how many handlers are still running.

Types

type APIError

type APIError struct {
	Type    string       `json:"type"`
	Code    string       `json:"code,omitempty"`
	Message string       `json:"message"`
	Param   string       `json:"param,omitempty"`
	Errors  []FieldError `json:"errors,omitempty"`
	Status  int          `json:"-"`
}

APIError represents a structured API error response.

func NewValidationError

func NewValidationError(errors []FieldError) *APIError

NewValidationError creates a validation error with multiple field errors.

func (*APIError) Error

func (e *APIError) Error() string

Error implements the error interface.

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is implements errors.Is for comparing error types.

func (*APIError) With

func (e *APIError) With(message string) *APIError

With returns a copy of the error with a custom message.

func (*APIError) WithParam

func (e *APIError) WithParam(message, param string) *APIError

WithParam returns a copy of the error with a custom message and parameter.

type APIKeyOption

type APIKeyOption func(*apiKeyConfig)

APIKeyOption configures APIKey middleware.

func WithAPIKeyHeader

func WithAPIKeyHeader(header string) APIKeyOption

WithAPIKeyHeader sets the header to read the API key from. Default is "X-API-Key".

func WithOptionalAPIKey

func WithOptionalAPIKey() APIKeyOption

WithOptionalAPIKey makes the API key optional. When set, requests without an API key are allowed through without validation. The API key will not be present in the context for these requests.

type APIKeyValidator

type APIKeyValidator func(key string) bool

APIKeyValidator validates an API key and returns true if valid. The validator function is provided by the application and can check against a database, cache, or any other validation mechanism.

Thread safety: Validators are called concurrently from multiple goroutines and must be safe for concurrent use. Avoid shared mutable state.

type BearerTokenOption

type BearerTokenOption func(*bearerTokenConfig)

BearerTokenOption configures BearerToken middleware.

func WithOptionalBearerToken

func WithOptionalBearerToken() BearerTokenOption

WithOptionalBearerToken makes the bearer token optional. When set, requests without a bearer token are allowed through without validation. The token will not be present in the context for these requests.

type BearerTokenValidator

type BearerTokenValidator func(token string) bool

BearerTokenValidator validates a bearer token and returns true if valid. The validator function is provided by the application and can perform JWT validation, token lookup, or any other validation mechanism.

Thread safety: Validators are called concurrently from multiple goroutines and must be safe for concurrent use. Avoid shared mutable state.

type BindOption

type BindOption func(*bindConfig)

BindOption configures the bind middleware.

func BindWithFormatter

func BindWithFormatter(fn MessageFormatter) BindOption

BindWithFormatter sets a custom message formatter for validation errors.

type BodySizeOption

type BodySizeOption func(*validateBodySizeConfig)

BodySizeOption configures MaxBodySize middleware.

type FieldError

type FieldError struct {
	Param   string `json:"param"`
	Code    string `json:"code"`
	Message string `json:"message"`
}

FieldError represents a validation error for a specific field.

type HandlerOption

type HandlerOption func(*config)

HandlerOption configures the Handler middleware.

func WithAbandonCallback

func WithAbandonCallback(fn func(*http.Request)) HandlerOption

WithAbandonCallback sets a function to call when a handler doesn't exit within the grace timeout. Use this for metrics or alerting.

func WithCanonlog

func WithCanonlog() HandlerOption

WithCanonlog enables canonical logging for requests. Creates a logger at request start and flushes it after response. Logs method, path, route, status, and duration_ms for each request. Errors set via SetError are automatically logged.

func WithCanonlogFields

func WithCanonlogFields(fn func(*http.Request) map[string]any) HandlerOption

WithCanonlogFields adds custom fields to each log entry. The function receives the request and returns fields to add. Called at request start, before the handler executes.

func WithGracefulShutdown

func WithGracefulShutdown(d time.Duration) HandlerOption

WithGracefulShutdown sets how long to wait for a handler goroutine to exit after timeout fires. This grace period allows handlers to complete cleanup (e.g., database rollbacks) after the 504 response is sent to the client.

After the grace period, the handler is considered abandoned. If canonlog is enabled, an error is logged. Use WithAbandonCallback for metrics/alerting.

Default is 5 seconds. Can be specified before or after WithTimeout.

func WithSLOs

func WithSLOs() HandlerOption

WithSLOs enables SLO status logging. Requires WithCanonlog() to be enabled. Reads SLO tier and target from context (set via SLO or SLOWithTarget) and logs slo_class and slo_status (PASS or FAIL) based on request duration.

func WithTimeout

func WithTimeout(d time.Duration) HandlerOption

WithTimeout sets a maximum duration for handler execution. If the handler doesn't complete within the timeout, a 504 Gateway Timeout response is returned immediately. The context is cancelled so DB/HTTP calls can exit early. The handler goroutine continues running but its response is discarded.

When timeout is enabled, handlers run in a separate goroutine. You MUST call WaitForHandlers during graceful shutdown to wait for handler goroutines to complete before process exit. See WaitForHandlers for the shutdown pattern.

Default graceful shutdown timeout is 5 seconds. Use WithGracefulShutdown to change this value. Options can be specified in any order.

Note: Go cannot forcibly terminate goroutines. If handlers ignore context cancellation (CGO calls, tight CPU loops), they continue running after the 504 response. Use WithAbandonCallback to track this with metrics.

type HeaderExtractor

type HeaderExtractor struct {
	// contains filtered or unexported fields
}

HeaderExtractor extracts a header value and stores it in the request context. Supports optional headers, default values, and custom validation/transformation.

type HeaderExtractorOption

type HeaderExtractorOption func(*HeaderExtractor)

HeaderExtractorOption configures a HeaderExtractor middleware.

func ExtractDefault

func ExtractDefault(val string) HeaderExtractorOption

ExtractDefault provides a default value if the header is missing. The default takes precedence over the Required setting - if a default is provided, the header becomes effectively optional.

func ExtractRequired

func ExtractRequired() HeaderExtractorOption

ExtractRequired marks the header as required. Returns 400 (Bad Request) if the header is missing and no default is provided.

func ExtractWithValidator

func ExtractWithValidator(fn func(string) (any, error)) HeaderExtractorOption

ExtractWithValidator provides a custom validator that can transform the header value. The validator receives the header value (string) and returns:

  • The transformed value (any type) to store in context
  • An error if validation fails (returns 400 to client)

Use this to parse and validate headers like UUIDs, timestamps, enums, etc.

Example:

validator := func(val string) (any, error) {
	if val == "admin" || val == "user" {
		return val, nil
	}
	return nil, fmt.Errorf("must be 'admin' or 'user'")
}

type MessageFormatter

type MessageFormatter func(field, tag, param string) string

MessageFormatter generates human-readable message from validation error. Parameters: field name, validation tag, tag parameter (e.g., "10" from "min=10")

type RateLimitHeaderMode

type RateLimitHeaderMode int

RateLimitHeaderMode controls when rate limit headers are included in responses.

const (
	// RateLimitHeadersAlways includes rate limit headers on all responses (default).
	// Headers: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
	// On 429: Also includes Retry-After
	RateLimitHeadersAlways RateLimitHeaderMode = iota

	// RateLimitHeadersOnLimitExceeded includes rate limit headers only on 429 responses.
	// Headers on 429: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After
	RateLimitHeadersOnLimitExceeded

	// RateLimitHeadersNever never includes rate limit headers in any response.
	// Use this when you want rate limiting without exposing limits to clients.
	RateLimitHeadersNever
)

type RateLimitOption

type RateLimitOption func(*RateLimiter)

RateLimitOption configures a RateLimiter.

func RateLimitWithEndpoint

func RateLimitWithEndpoint() RateLimitOption

RateLimitWithEndpoint adds the HTTP method and path to the rate limiting key. Key component format: "<method>:<path>". Method and path are always present.

func RateLimitWithHeader

func RateLimitWithHeader(header string) RateLimitOption

RateLimitWithHeader adds a header value to the rate limiting key. If the header is missing, rate limiting is skipped for that request.

func RateLimitWithHeaderMode

func RateLimitWithHeaderMode(mode RateLimitHeaderMode) RateLimitOption

RateLimitWithHeaderMode configures when rate limit headers are included in responses.

func RateLimitWithHeaderRequired

func RateLimitWithHeaderRequired(header string) RateLimitOption

RateLimitWithHeaderRequired adds a header value to the rate limiting key. Returns 400 Bad Request when the header is missing.

func RateLimitWithIP

func RateLimitWithIP() RateLimitOption

RateLimitWithIP adds the client IP address (from RemoteAddr) to the rate limiting key. Use this for direct connections without a proxy. RemoteAddr is always present.

func RateLimitWithName

func RateLimitWithName(name string) RateLimitOption

RateLimitWithName sets a prefix for rate limit keys. Use to prevent key collisions when layering multiple rate limiters.

func RateLimitWithQueryParam

func RateLimitWithQueryParam(param string) RateLimitOption

RateLimitWithQueryParam adds a query parameter value to the rate limiting key. If the parameter is missing, rate limiting is skipped for that request.

func RateLimitWithQueryParamRequired

func RateLimitWithQueryParamRequired(param string) RateLimitOption

RateLimitWithQueryParamRequired adds a query parameter value to the rate limiting key. Returns 400 Bad Request when the parameter is missing.

func RateLimitWithRealIP

func RateLimitWithRealIP() RateLimitOption

RateLimitWithRealIP adds the client IP from X-Forwarded-For or X-Real-IP headers. Use this when behind a proxy/load balancer. If neither header is present, rate limiting is skipped for that request.

SECURITY: Only use this behind a trusted reverse proxy that sets these headers. Without a proxy, clients can spoof X-Forwarded-For to bypass rate limits.

func RateLimitWithRealIPRequired

func RateLimitWithRealIPRequired() RateLimitOption

RateLimitWithRealIPRequired adds the client IP from X-Forwarded-For or X-Real-IP headers. Use this when behind a proxy/load balancer. Returns 400 Bad Request when neither header is present.

SECURITY: Only use this behind a trusted reverse proxy that sets these headers. Without a proxy, clients can spoof X-Forwarded-For to bypass rate limits.

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter implements rate limiting middleware.

func NewRateLimiter

func NewRateLimiter(st store.Store, limit int, window time.Duration, opts ...RateLimitOption) *RateLimiter

NewRateLimiter creates a new rate limiter with the given store, limit, and window. Use RateLimitWith* options to configure key dimensions and behavior. Returns 429 (Too Many Requests) when the limit is exceeded, with standard rate limit headers and a Retry-After header indicating seconds until reset. Returns 400 (Bad Request) if a *Required dimension is missing. Returns 500 (Internal Server Error) if the store operation fails.

At least one key dimension option must be provided. Panics if no key dimensions are configured.

Key dimension options:

  • RateLimitWithIP: Add RemoteAddr IP to key (direct connections)
  • RateLimitWithRealIP / RateLimitWithRealIPRequired: Add X-Forwarded-For/X-Real-IP to key
  • RateLimitWithEndpoint: Add method:path to key
  • RateLimitWithHeader / RateLimitWithHeaderRequired: Add header value to key
  • RateLimitWithQueryParam / RateLimitWithQueryParamRequired: Add query parameter to key

Other options:

  • RateLimitWithName: Set key prefix for collision prevention
  • RateLimitWithHeaderMode: Configure header visibility (default: RateLimitHeadersAlways)
Example
st := store.NewMemory()
defer st.Close()

// Rate limit by IP: 100 requests per minute
limiter := chikit.NewRateLimiter(st, 100, time.Minute,
	chikit.RateLimitWithIP(),
)

r := chi.NewRouter()
r.Use(limiter.Handler)
Example (MultiDimensional)
st := store.NewMemory()
defer st.Close()

// Rate limit by tenant + endpoint: 100 requests per minute
limiter := chikit.NewRateLimiter(st, 100, time.Minute,
	chikit.RateLimitWithHeaderRequired("X-Tenant-ID"),
	chikit.RateLimitWithEndpoint(),
)

r := chi.NewRouter()
r.Use(limiter.Handler)

func (*RateLimiter) Handler

func (l *RateLimiter) Handler(next http.Handler) http.Handler

Handler returns the rate limiting middleware. Sets the following headers based on header mode:

  • RateLimit-Limit: The rate limit ceiling for the current window
  • RateLimit-Remaining: Number of requests remaining in the current window
  • RateLimit-Reset: Unix timestamp when the current window resets
  • Retry-After: (only when limited) Seconds until the window resets

These headers follow the IETF draft-ietf-httpapi-ratelimit-headers specification.

type SLOTier

type SLOTier string

SLOTier represents an SLO classification level.

const (
	// SLOCritical is for essential functions requiring 99.99% availability.
	SLOCritical SLOTier = "critical"

	// SLOHighFast is for user-facing requests requiring quick responses (99.9% availability, 100ms latency).
	SLOHighFast SLOTier = "high_fast"

	// SLOHighSlow is for important requests that can tolerate higher latency (99.9% availability, 1000ms latency).
	SLOHighSlow SLOTier = "high_slow"

	// SLOLow is for background tasks or non-interactive functions (99% availability).
	SLOLow SLOTier = "low"
)

func GetSLO

func GetSLO(ctx context.Context) (SLOTier, time.Duration, bool)

GetSLO retrieves the SLO tier and target from context. Returns the tier, target duration, and true if set; otherwise empty values and false.

type State

type State struct {
	// contains filtered or unexported fields
}

State holds the response state for a request.

type ValidateHeaderConfig

type ValidateHeaderConfig struct {
	Name          string
	Required      bool
	AllowedList   []string
	DeniedList    []string
	CaseSensitive bool
}

ValidateHeaderConfig defines validation rules for a header.

type ValidateHeaderOption

type ValidateHeaderOption func(*ValidateHeaderConfig)

ValidateHeaderOption configures a header validation rule.

func ValidateAllowList

func ValidateAllowList(values ...string) ValidateHeaderOption

ValidateAllowList sets the list of allowed values for a header. If set, only values in this list are permitted. Returns 400 if the value is not in the list.

func ValidateCaseSensitive

func ValidateCaseSensitive() ValidateHeaderOption

ValidateCaseSensitive makes header value comparisons case-sensitive. By default, comparisons are case-insensitive.

func ValidateDenyList

func ValidateDenyList(values ...string) ValidateHeaderOption

ValidateDenyList sets the list of denied values for a header. If set, values in this list are explicitly forbidden. Returns 400 if the value is in the list.

func ValidateRequired

func ValidateRequired() ValidateHeaderOption

ValidateRequired marks a header as required.

type ValidateHeadersOption

type ValidateHeadersOption func(*validateHeadersConfig)

ValidateHeadersOption configures ValidateHeaders middleware.

func ValidateWithHeader

func ValidateWithHeader(name string, opts ...ValidateHeaderOption) ValidateHeadersOption

ValidateWithHeader adds a header validation rule with the given name and options.

Directories

Path Synopsis
Package store provides storage backends for rate limiting.
Package store provides storage backends for rate limiting.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL