Skip to content

Commit

Permalink
🚀 Feature: Add idempotency middleware (#2253)
Browse files Browse the repository at this point in the history
* middleware: add idempotency middleware

* middleware/idempotency: use fiber.Storage instead of custom storage

* middleware/idempotency: only allocate data if really required

* middleware/idempotency: marshal response using msgp

* middleware/idempotency: add msgp tests

* middleware/idempotency: do not export response

* middleware/idempotency: disable msgp's -io option to disable generating unused methods

* middleware/idempotency: switch to time.Duration based app.Test

* middleware/idempotency: only create closure once

* middleware/idempotency: add benchmarks

* middleware/idempotency: optimize strings.ToLower when making comparison

The real "strings.ToLower" still needs to be used when storing the data.

* middleware/idempotency: safe-copy body
  • Loading branch information
leonklingele committed Jan 6, 2023
1 parent 6334f2c commit 0b5a7d0
Show file tree
Hide file tree
Showing 10 changed files with 898 additions and 0 deletions.
30 changes: 30 additions & 0 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,36 @@ func (app *App) methodInt(s string) int {
return -1
}

// IsMethodSafe reports whether the HTTP method is considered safe.
// See https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.1
func IsMethodSafe(m string) bool {
switch m {
case MethodGet,
MethodHead,
MethodOptions,
MethodTrace:
return true
default:
return false
}
}

// IsMethodIdempotent reports whether the HTTP method is considered idempotent.
// See https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2
func IsMethodIdempotent(m string) bool {
if IsMethodSafe(m) {
return true
}

switch m {
case MethodPut,
MethodDelete:
return true
default:
return false
}
}

// HTTP methods were copied from net/http.
const (
MethodGet = "GET" // RFC 7231, 4.3.1
Expand Down
118 changes: 118 additions & 0 deletions middleware/idempotency/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Idempotency Middleware

Idempotency middleware for [Fiber](https://github.com/gofiber/fiber) allows for fault-tolerant APIs where duplicate requests — for example due to networking issues on the client-side — do not erroneously cause the same action performed multiple times on the server-side.

Refer to https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-02 for a better understanding.

## Table of Contents

- [Idempotency Middleware](#idempotency-middleware)
- [Table of Contents](#table-of-contents)
- [Signatures](#signatures)
- [Examples](#examples)
- [Default Config](#default-config)
- [Custom Config](#custom-config)
- [Config](#config)
- [Default Config](#default-config-1)

## Signatures

```go
func New(config ...Config) fiber.Handler
```

## Examples

First import the middleware from Fiber,

```go
import (
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/idempotency"
)
```

Then create a Fiber app with `app := fiber.New()`.

### Default Config

```go
app.Use(idempotency.New())
```

### Custom Config

```go
app.Use(idempotency.New(idempotency.Config{
Lifetime: 42 * time.Minute,
// ...
}))
```

### Config

```go
type Config struct {
// Next defines a function to skip this middleware when returned true.
//
// Optional. Default: a function which skips the middleware on safe HTTP request method.
Next func(c fiber.Ctx) bool

// Lifetime is the maximum lifetime of an idempotency key.
//
// Optional. Default: 30 * time.Minute
Lifetime time.Duration

// KeyHeader is the name of the header that contains the idempotency key.
//
// Optional. Default: X-Idempotency-Key
KeyHeader string
// KeyHeaderValidate defines a function to validate the syntax of the idempotency header.
//
// Optional. Default: a function which ensures the header is 36 characters long (the size of an UUID).
KeyHeaderValidate func(string) error

// KeepResponseHeaders is a list of headers that should be kept from the original response.
//
// Optional. Default: nil (to keep all headers)
KeepResponseHeaders []string

// Lock locks an idempotency key.
//
// Optional. Default: an in-memory locker for this process only.
Lock Locker

// Storage stores response data by idempotency key.
//
// Optional. Default: an in-memory storage for this process only.
Storage fiber.Storage
}
```

### Default Config

```go
var ConfigDefault = Config{
Next: func(c fiber.Ctx) bool {
// Skip middleware if the request was done using a safe HTTP method
return fiber.IsMethodSafe(c.Method())
},

Lifetime: 30 * time.Minute,

KeyHeader: "X-Idempotency-Key",
KeyHeaderValidate: func(k string) error {
if l, wl := len(k), 36; l != wl { // UUID length is 36 chars
return fmt.Errorf("%w: invalid length: %d != %d", ErrInvalidIdempotencyKey, l, wl)
}

return nil
},

KeepResponseHeaders: nil,

Lock: nil, // Set in configDefault so we don't allocate data here.

Storage: nil, // Set in configDefault so we don't allocate data here.
}
```
120 changes: 120 additions & 0 deletions middleware/idempotency/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package idempotency

import (
"errors"
"fmt"
"time"

"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/internal/storage/memory"
)

var (
ErrInvalidIdempotencyKey = errors.New("invalid idempotency key")
)

// Config defines the config for middleware.
type Config struct {
// Next defines a function to skip this middleware when returned true.
//
// Optional. Default: a function which skips the middleware on safe HTTP request method.
Next func(c fiber.Ctx) bool

// Lifetime is the maximum lifetime of an idempotency key.
//
// Optional. Default: 30 * time.Minute
Lifetime time.Duration

// KeyHeader is the name of the header that contains the idempotency key.
//
// Optional. Default: X-Idempotency-Key
KeyHeader string
// KeyHeaderValidate defines a function to validate the syntax of the idempotency header.
//
// Optional. Default: a function which ensures the header is 36 characters long (the size of an UUID).
KeyHeaderValidate func(string) error

// KeepResponseHeaders is a list of headers that should be kept from the original response.
//
// Optional. Default: nil (to keep all headers)
KeepResponseHeaders []string

// Lock locks an idempotency key.
//
// Optional. Default: an in-memory locker for this process only.
Lock Locker

// Storage stores response data by idempotency key.
//
// Optional. Default: an in-memory storage for this process only.
Storage fiber.Storage
}

// ConfigDefault is the default config
var ConfigDefault = Config{
Next: func(c fiber.Ctx) bool {
// Skip middleware if the request was done using a safe HTTP method
return fiber.IsMethodSafe(c.Method())
},

Lifetime: 30 * time.Minute,

KeyHeader: "X-Idempotency-Key",
KeyHeaderValidate: func(k string) error {
if l, wl := len(k), 36; l != wl { // UUID length is 36 chars
return fmt.Errorf("%w: invalid length: %d != %d", ErrInvalidIdempotencyKey, l, wl)
}

return nil
},

KeepResponseHeaders: nil,

Lock: nil, // Set in configDefault so we don't allocate data here.

Storage: nil, // Set in configDefault so we don't allocate data here.
}

// Helper function to set default values
func configDefault(config ...Config) Config {
// Return default config if nothing provided
if len(config) < 1 {
return ConfigDefault
}

// Override default config
cfg := config[0]

// Set default values

if cfg.Next == nil {
cfg.Next = ConfigDefault.Next
}

if cfg.Lifetime.Nanoseconds() == 0 {
cfg.Lifetime = ConfigDefault.Lifetime
}

if cfg.KeyHeader == "" {
cfg.KeyHeader = ConfigDefault.KeyHeader
}
if cfg.KeyHeaderValidate == nil {
cfg.KeyHeaderValidate = ConfigDefault.KeyHeaderValidate
}

if cfg.KeepResponseHeaders != nil && len(cfg.KeepResponseHeaders) == 0 {
cfg.KeepResponseHeaders = ConfigDefault.KeepResponseHeaders
}

if cfg.Lock == nil {
cfg.Lock = NewMemoryLock()
}

if cfg.Storage == nil {
cfg.Storage = memory.New(memory.Config{
GCInterval: cfg.Lifetime / 2,
})
}

return cfg
}
Loading

1 comment on commit 0b5a7d0

@ReneWerner87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 2.

Benchmark suite Current: 0b5a7d0 Previous: ad5250a Ratio
Benchmark_AcquireCtx 1686 ns/op 1568 B/op 5 allocs/op 633.9 ns/op 1568 B/op 5 allocs/op 2.66

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.