Skip to main content

Overview

The idempotency middleware ensures that repeated requests with the same idempotency key return the same response, preventing duplicate operations like double charges or duplicate records. Use it when you need:
  • Safe payment processing retries
  • Duplicate request prevention
  • Reliable webhook handling

Installation

import "github.com/go-mizu/mizu/middlewares/idempotency"

Quick Start

app := mizu.New()

// Enable idempotency
app.Use(idempotency.New())

app.Post("/payments", func(c *mizu.Ctx) error {
    // This will only execute once per idempotency key
    result := processPayment()
    return c.JSON(200, result)
})
Client sends:
POST /payments
Idempotency-Key: unique-key-123

Configuration

Options

OptionTypeDefaultDescription
KeyHeaderstring"Idempotency-Key"Header name for key
Methods[]string["POST", "PUT", "PATCH"]Methods to track
TTLtime.Duration24hHow long to store responses
KeyGeneratorfunc(string, *mizu.Ctx) stringIdentityCustom key generation

Examples

Basic Usage

app.Use(idempotency.New())

Custom Header

app.Use(idempotency.WithOptions(idempotency.Options{
    KeyHeader: "X-Request-Id",
}))

Specific Methods Only

app.Use(idempotency.WithOptions(idempotency.Options{
    Methods: []string{http.MethodPost}, // Only POST
}))

User-Scoped Keys

app.Use(idempotency.WithOptions(idempotency.Options{
    KeyGenerator: func(key string, c *mizu.Ctx) string {
        userID := c.Request().Header.Get("X-User-ID")
        return userID + ":" + key
    },
}))

Custom Store

store := idempotency.NewMemoryStore()
defer store.Close()

app.Use(idempotency.WithStore(store, idempotency.Options{}))

How It Works

  1. Client sends request with Idempotency-Key header
  2. Middleware checks if key exists in store
  3. If exists: returns cached response with Idempotent-Replayed: true
  4. If not: executes handler, caches response, returns normally
  5. Subsequent requests with same key get cached response

API Reference

Functions

// New creates idempotency middleware
func New() mizu.Middleware

// WithOptions creates middleware with configuration
func WithOptions(opts Options) mizu.Middleware

// WithStore creates middleware with custom store
func WithStore(store Store, opts Options) mizu.Middleware

// NewMemoryStore creates in-memory response store
func NewMemoryStore() *MemoryStore

Store Interface

type Store interface {
    Get(key string) (*Response, error)
    Set(key string, resp *Response) error
    Delete(key string) error
}

type Response struct {
    StatusCode int
    Headers    http.Header
    Body       []byte
    ExpiresAt  time.Time
}

Response Headers

HeaderDescription
Idempotent-Replayed: trueResponse was replayed from cache

Technical Details

Cache Key Generation

By default, the middleware generates cache keys using SHA-256 hashing of:
  • The idempotency key from the header
  • The HTTP method (POST, PUT, etc.)
  • The request URL path
This ensures that the same idempotency key can be safely reused across different endpoints and methods.

Response Capture

The middleware uses a custom responseCapture wrapper that:
  1. Captures the response status code (defaults to 200 OK)
  2. Clones all response headers
  3. Buffers the response body in memory
  4. Writes everything to both the buffer and the underlying ResponseWriter

Memory Store Implementation

The built-in MemoryStore:
  • Uses sync.RWMutex for thread-safe concurrent access
  • Runs a background cleanup goroutine that removes expired entries every 10 minutes
  • Stores responses with their expiration timestamps
  • Returns nil for expired entries automatically

Request Flow

  1. Method Check: Verifies the HTTP method is in the configured Methods list
  2. Key Extraction: Retrieves the idempotency key from the specified header
  3. Cache Lookup: Checks if a response exists for the generated cache key
  4. Cache Hit: If found and not expired, replays the cached response with Idempotent-Replayed: true header
  5. Cache Miss: Wraps the ResponseWriter, executes the handler, captures the response, and stores it in the cache
  6. Expiration: Responses are stored with a TTL and automatically cleaned up

Best Practices

  • Always use idempotency keys for payment operations
  • Generate unique keys client-side (UUIDs work well)
  • Include user context in key generation for multi-tenant apps
  • Set appropriate TTL based on your use case
  • Use distributed store (Redis) for multi-instance deployments

Testing

Test Coverage

Test CaseDescriptionExpected Behavior
TestNewBasic idempotency with default configurationFirst request executes handler, second request with same key returns cached response with Idempotent-Replayed: true header
TestWithOptions_NoKeyRequests without idempotency keyAll requests execute handler normally (no caching)
TestWithOptions_DifferentKeysMultiple requests with different keysEach unique key executes handler independently
TestWithOptions_CustomHeaderCustom header name configurationMiddleware uses custom header (e.g., X-Request-Id) for idempotency key
TestWithOptions_MethodsMethod filtering (POST only)POST requests are cached, PUT requests are not cached
TestWithOptions_GETGET requests with idempotency keyGET requests bypass middleware (not cached by default)
TestWithOptions_ResponseHeadersCustom response headersCached response includes all custom headers from original response
TestWithStoreCustom store integrationMiddleware works with custom Store implementation
TestMemoryStoreMemory store operationsSet, Get, and Delete operations work correctly
TestMemoryStore_ExpiryExpired entriesStore returns nil for expired entries
TestWithOptions_CustomKeyGeneratorUser-scoped keysSame idempotency key with different user IDs creates separate cache entries
TestWithOptions_ResponseBodyJSON response cachingComplete response body (including JSON) is cached and replayed

Client Example

// JavaScript client
const response = await fetch('/api/payments', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': crypto.randomUUID()
    },
    body: JSON.stringify({ amount: 100 })
});