Skip to content

Commit 982cbe3

Browse files
committed
🚀 Feature: Add idempotency middleware (gofiber#2253)
* 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
1 parent ad5250a commit 982cbe3

10 files changed

+898
-0
lines changed

Diff for: helpers.go

+30
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,36 @@ func (app *App) methodInt(s string) int {
369369
return -1
370370
}
371371

372+
// IsMethodSafe reports whether the HTTP method is considered safe.
373+
// See https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.1
374+
func IsMethodSafe(m string) bool {
375+
switch m {
376+
case MethodGet,
377+
MethodHead,
378+
MethodOptions,
379+
MethodTrace:
380+
return true
381+
default:
382+
return false
383+
}
384+
}
385+
386+
// IsMethodIdempotent reports whether the HTTP method is considered idempotent.
387+
// See https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2
388+
func IsMethodIdempotent(m string) bool {
389+
if IsMethodSafe(m) {
390+
return true
391+
}
392+
393+
switch m {
394+
case MethodPut,
395+
MethodDelete:
396+
return true
397+
default:
398+
return false
399+
}
400+
}
401+
372402
// HTTP methods were copied from net/http.
373403
const (
374404
MethodGet = "GET" // RFC 7231, 4.3.1

Diff for: middleware/idempotency/README.md

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Idempotency Middleware
2+
3+
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.
4+
5+
Refer to https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-02 for a better understanding.
6+
7+
## Table of Contents
8+
9+
- [Idempotency Middleware](#idempotency-middleware)
10+
- [Table of Contents](#table-of-contents)
11+
- [Signatures](#signatures)
12+
- [Examples](#examples)
13+
- [Default Config](#default-config)
14+
- [Custom Config](#custom-config)
15+
- [Config](#config)
16+
- [Default Config](#default-config-1)
17+
18+
## Signatures
19+
20+
```go
21+
func New(config ...Config) fiber.Handler
22+
```
23+
24+
## Examples
25+
26+
First import the middleware from Fiber,
27+
28+
```go
29+
import (
30+
"github.com/gofiber/fiber/v3"
31+
"github.com/gofiber/fiber/v3/middleware/idempotency"
32+
)
33+
```
34+
35+
Then create a Fiber app with `app := fiber.New()`.
36+
37+
### Default Config
38+
39+
```go
40+
app.Use(idempotency.New())
41+
```
42+
43+
### Custom Config
44+
45+
```go
46+
app.Use(idempotency.New(idempotency.Config{
47+
Lifetime: 42 * time.Minute,
48+
// ...
49+
}))
50+
```
51+
52+
### Config
53+
54+
```go
55+
type Config struct {
56+
// Next defines a function to skip this middleware when returned true.
57+
//
58+
// Optional. Default: a function which skips the middleware on safe HTTP request method.
59+
Next func(c fiber.Ctx) bool
60+
61+
// Lifetime is the maximum lifetime of an idempotency key.
62+
//
63+
// Optional. Default: 30 * time.Minute
64+
Lifetime time.Duration
65+
66+
// KeyHeader is the name of the header that contains the idempotency key.
67+
//
68+
// Optional. Default: X-Idempotency-Key
69+
KeyHeader string
70+
// KeyHeaderValidate defines a function to validate the syntax of the idempotency header.
71+
//
72+
// Optional. Default: a function which ensures the header is 36 characters long (the size of an UUID).
73+
KeyHeaderValidate func(string) error
74+
75+
// KeepResponseHeaders is a list of headers that should be kept from the original response.
76+
//
77+
// Optional. Default: nil (to keep all headers)
78+
KeepResponseHeaders []string
79+
80+
// Lock locks an idempotency key.
81+
//
82+
// Optional. Default: an in-memory locker for this process only.
83+
Lock Locker
84+
85+
// Storage stores response data by idempotency key.
86+
//
87+
// Optional. Default: an in-memory storage for this process only.
88+
Storage fiber.Storage
89+
}
90+
```
91+
92+
### Default Config
93+
94+
```go
95+
var ConfigDefault = Config{
96+
Next: func(c fiber.Ctx) bool {
97+
// Skip middleware if the request was done using a safe HTTP method
98+
return fiber.IsMethodSafe(c.Method())
99+
},
100+
101+
Lifetime: 30 * time.Minute,
102+
103+
KeyHeader: "X-Idempotency-Key",
104+
KeyHeaderValidate: func(k string) error {
105+
if l, wl := len(k), 36; l != wl { // UUID length is 36 chars
106+
return fmt.Errorf("%w: invalid length: %d != %d", ErrInvalidIdempotencyKey, l, wl)
107+
}
108+
109+
return nil
110+
},
111+
112+
KeepResponseHeaders: nil,
113+
114+
Lock: nil, // Set in configDefault so we don't allocate data here.
115+
116+
Storage: nil, // Set in configDefault so we don't allocate data here.
117+
}
118+
```

Diff for: middleware/idempotency/config.go

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package idempotency
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"time"
7+
8+
"github.com/gofiber/fiber/v3"
9+
"github.com/gofiber/fiber/v3/internal/storage/memory"
10+
)
11+
12+
var (
13+
ErrInvalidIdempotencyKey = errors.New("invalid idempotency key")
14+
)
15+
16+
// Config defines the config for middleware.
17+
type Config struct {
18+
// Next defines a function to skip this middleware when returned true.
19+
//
20+
// Optional. Default: a function which skips the middleware on safe HTTP request method.
21+
Next func(c fiber.Ctx) bool
22+
23+
// Lifetime is the maximum lifetime of an idempotency key.
24+
//
25+
// Optional. Default: 30 * time.Minute
26+
Lifetime time.Duration
27+
28+
// KeyHeader is the name of the header that contains the idempotency key.
29+
//
30+
// Optional. Default: X-Idempotency-Key
31+
KeyHeader string
32+
// KeyHeaderValidate defines a function to validate the syntax of the idempotency header.
33+
//
34+
// Optional. Default: a function which ensures the header is 36 characters long (the size of an UUID).
35+
KeyHeaderValidate func(string) error
36+
37+
// KeepResponseHeaders is a list of headers that should be kept from the original response.
38+
//
39+
// Optional. Default: nil (to keep all headers)
40+
KeepResponseHeaders []string
41+
42+
// Lock locks an idempotency key.
43+
//
44+
// Optional. Default: an in-memory locker for this process only.
45+
Lock Locker
46+
47+
// Storage stores response data by idempotency key.
48+
//
49+
// Optional. Default: an in-memory storage for this process only.
50+
Storage fiber.Storage
51+
}
52+
53+
// ConfigDefault is the default config
54+
var ConfigDefault = Config{
55+
Next: func(c fiber.Ctx) bool {
56+
// Skip middleware if the request was done using a safe HTTP method
57+
return fiber.IsMethodSafe(c.Method())
58+
},
59+
60+
Lifetime: 30 * time.Minute,
61+
62+
KeyHeader: "X-Idempotency-Key",
63+
KeyHeaderValidate: func(k string) error {
64+
if l, wl := len(k), 36; l != wl { // UUID length is 36 chars
65+
return fmt.Errorf("%w: invalid length: %d != %d", ErrInvalidIdempotencyKey, l, wl)
66+
}
67+
68+
return nil
69+
},
70+
71+
KeepResponseHeaders: nil,
72+
73+
Lock: nil, // Set in configDefault so we don't allocate data here.
74+
75+
Storage: nil, // Set in configDefault so we don't allocate data here.
76+
}
77+
78+
// Helper function to set default values
79+
func configDefault(config ...Config) Config {
80+
// Return default config if nothing provided
81+
if len(config) < 1 {
82+
return ConfigDefault
83+
}
84+
85+
// Override default config
86+
cfg := config[0]
87+
88+
// Set default values
89+
90+
if cfg.Next == nil {
91+
cfg.Next = ConfigDefault.Next
92+
}
93+
94+
if cfg.Lifetime.Nanoseconds() == 0 {
95+
cfg.Lifetime = ConfigDefault.Lifetime
96+
}
97+
98+
if cfg.KeyHeader == "" {
99+
cfg.KeyHeader = ConfigDefault.KeyHeader
100+
}
101+
if cfg.KeyHeaderValidate == nil {
102+
cfg.KeyHeaderValidate = ConfigDefault.KeyHeaderValidate
103+
}
104+
105+
if cfg.KeepResponseHeaders != nil && len(cfg.KeepResponseHeaders) == 0 {
106+
cfg.KeepResponseHeaders = ConfigDefault.KeepResponseHeaders
107+
}
108+
109+
if cfg.Lock == nil {
110+
cfg.Lock = NewMemoryLock()
111+
}
112+
113+
if cfg.Storage == nil {
114+
cfg.Storage = memory.New(memory.Config{
115+
GCInterval: cfg.Lifetime / 2,
116+
})
117+
}
118+
119+
return cfg
120+
}

0 commit comments

Comments
 (0)