feat: add independent platform modules and multi-provider storage
This commit is contained in:
@@ -17,12 +17,13 @@ import (
|
||||
)
|
||||
|
||||
type IdempotencyConfig struct {
|
||||
TTL time.Duration `json:"ttl"`
|
||||
MaxEntries int `json:"maxEntries"`
|
||||
MaxBytes int `json:"maxBytes"`
|
||||
MaxRequestBytes int64 `json:"maxRequestBytes"`
|
||||
MaxResponseBytes int `json:"maxResponseBytes"`
|
||||
MaxInFlight int `json:"maxInFlight"`
|
||||
Store IdempotencyStore `json:"-"` // Optional shared store; nil keeps local behavior.
|
||||
TTL time.Duration `json:"ttl"`
|
||||
MaxEntries int `json:"maxEntries"`
|
||||
MaxBytes int `json:"maxBytes"`
|
||||
MaxRequestBytes int64 `json:"maxRequestBytes"`
|
||||
MaxResponseBytes int `json:"maxResponseBytes"`
|
||||
MaxInFlight int `json:"maxInFlight"`
|
||||
// Scope must return a server-verified tenant/user identity. Register auth
|
||||
// before this middleware. Client-provided identity is not authorization.
|
||||
Scope func(*httpx.Context) (string, error) `json:"-"`
|
||||
@@ -53,6 +54,9 @@ func NewIdempotency(cfg IdempotencyConfig) (*Idempotency, error) {
|
||||
if cfg.TTL == 0 {
|
||||
cfg.TTL = 10 * time.Minute
|
||||
}
|
||||
if cfg.Store != nil && cfg.TTL > 30*24*time.Hour {
|
||||
return nil, errors.New("middleware: shared idempotency TTL exceeds 30d")
|
||||
}
|
||||
if cfg.MaxEntries == 0 {
|
||||
cfg.MaxEntries = 256
|
||||
}
|
||||
@@ -125,6 +129,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
|
||||
copy(fingerprint[:], hash.Sum(nil))
|
||||
namespace, _ := json.Marshal([]string{scope, key})
|
||||
id := sha256.Sum256(namespace)
|
||||
if i.config.Store != nil {
|
||||
return i.shared(c, next, id, fingerprint)
|
||||
}
|
||||
i.mu.Lock()
|
||||
now := i.now()
|
||||
for k, v := range i.items {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Backend RateLimitBackend `json:"-"`
|
||||
Rate float64 `json:"rate"` // Tokens per second, per key.
|
||||
Burst int `json:"burst"`
|
||||
MaxKeys int `json:"maxKeys"`
|
||||
@@ -64,7 +65,17 @@ func (l *RateLimiter) Middleware() httpx.Middleware {
|
||||
if key == "" || len(key) > 1024 {
|
||||
return apierrors.BadRequest("INVALID_RATE_KEY", "invalid rate limit key")
|
||||
}
|
||||
delay, full := l.allow(key)
|
||||
var delay time.Duration
|
||||
var full bool
|
||||
if l.config.Backend != nil {
|
||||
var err error
|
||||
delay, err = l.config.Backend.Take(c.Context(), key)
|
||||
if err != nil {
|
||||
return apierrors.New(503, "RATE_LIMIT_UNAVAILABLE", "rate limiter unavailable").WithCause(err)
|
||||
}
|
||||
} else {
|
||||
delay, full = l.allow(key)
|
||||
}
|
||||
if full {
|
||||
return apierrors.New(503, "RATE_LIMIT_CAPACITY", "rate limiter capacity reached")
|
||||
}
|
||||
|
||||
178
middleware/redis/store.go
Normal file
178
middleware/redis/store.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Package redis supplies atomic shared middleware state in one Redis hash slot.
|
||||
// Use a dedicated namespace and a noeviction Redis deployment for correctness.
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IdempotencyConfig struct {
|
||||
MaxEntries int `json:"maxEntries"`
|
||||
MaxRecordBytes int `json:"maxRecordBytes"`
|
||||
}
|
||||
type IdempotencyStore struct {
|
||||
client goredis.Scripter
|
||||
keys []string
|
||||
cfg IdempotencyConfig
|
||||
signature string
|
||||
}
|
||||
|
||||
func namespace(prefix, kind string) (string, error) {
|
||||
if prefix == "" || len(prefix) > 256 || strings.ContainsAny(prefix, "{}\r\n") {
|
||||
return "", errors.New("middleware/redis: nonempty bounded namespace without braces required")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(prefix))
|
||||
return "goOmni:{" + hex.EncodeToString(sum[:]) + "}:" + kind, nil
|
||||
}
|
||||
func NewIdempotencyStore(client goredis.Scripter, prefix string, cfg IdempotencyConfig) (*IdempotencyStore, error) {
|
||||
if client == nil || cfg.MaxEntries < 0 || cfg.MaxRecordBytes < 0 {
|
||||
return nil, errors.New("middleware/redis: invalid configuration")
|
||||
}
|
||||
if cfg.MaxEntries == 0 {
|
||||
cfg.MaxEntries = 128
|
||||
}
|
||||
if cfg.MaxRecordBytes == 0 {
|
||||
cfg.MaxRecordBytes = 128 << 10
|
||||
}
|
||||
if cfg.MaxEntries > 100000 || cfg.MaxRecordBytes < 512 || cfg.MaxRecordBytes > 1<<20 {
|
||||
return nil, errors.New("middleware/redis: capacity outside supported bounds")
|
||||
}
|
||||
base, err := namespace(prefix, "idempotency")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IdempotencyStore{client: client, keys: []string{base + ":records", base + ":expiry", base + ":config"}, cfg: cfg, signature: fmt.Sprintf("%d:%d", cfg.MaxEntries, cfg.MaxRecordBytes)}, nil
|
||||
}
|
||||
|
||||
var claim = goredis.NewScript(`
|
||||
local config=redis.call('GET',KEYS[3])
|
||||
if config and config~=ARGV[5] then return redis.error_reply('configuration mismatch') end
|
||||
if not config then redis.call('SET',KEYS[3],ARGV[5]) end
|
||||
local clock=redis.call('TIME');local now=clock[1]*1000+math.floor(clock[2]/1000)
|
||||
local expired=redis.call('ZRANGEBYSCORE',KEYS[2],'-inf',now,'LIMIT',0,128)
|
||||
for _,key in ipairs(expired) do redis.call('HDEL',KEYS[1],key);redis.call('ZREM',KEYS[2],key) end
|
||||
local expiry=redis.call('ZSCORE',KEYS[2],ARGV[1]);if expiry and tonumber(expiry)<=now then redis.call('HDEL',KEYS[1],ARGV[1]);redis.call('ZREM',KEYS[2],ARGV[1]) end
|
||||
local value=redis.call('HGET',KEYS[1],ARGV[1]);if value then return {0,value} end
|
||||
if redis.call('HLEN',KEYS[1])>=tonumber(ARGV[4]) then return redis.error_reply('idempotency capacity reached') end
|
||||
value=cjson.encode({fingerprint=ARGV[2],owner=ARGV[3],state='running'})
|
||||
redis.call('HSET',KEYS[1],ARGV[1],value);return {1,value}
|
||||
`)
|
||||
|
||||
func (s *IdempotencyStore) Claim(ctx context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) {
|
||||
if len(key) != 64 || len(fp) != 64 || len(owner) != 32 {
|
||||
return middleware.IdempotencyRecord{}, false, errors.New("middleware/redis: invalid claim")
|
||||
}
|
||||
result, err := claim.Run(ctx, s.client, s.keys, key, fp, owner, s.cfg.MaxEntries, s.signature).Slice()
|
||||
if err != nil {
|
||||
return middleware.IdempotencyRecord{}, false, err
|
||||
}
|
||||
if len(result) != 2 {
|
||||
return middleware.IdempotencyRecord{}, false, errors.New("middleware/redis: invalid claim result")
|
||||
}
|
||||
value, ok := result[1].(string)
|
||||
if !ok || len(value) > s.cfg.MaxRecordBytes {
|
||||
return middleware.IdempotencyRecord{}, false, errors.New("middleware/redis: invalid record")
|
||||
}
|
||||
var record middleware.IdempotencyRecord
|
||||
err = json.Unmarshal([]byte(value), &record)
|
||||
return record, result[0] == int64(1), err
|
||||
}
|
||||
|
||||
var complete = goredis.NewScript(`
|
||||
local current=redis.call('HGET',KEYS[1],ARGV[1]);if not current then return redis.error_reply('claim missing') end
|
||||
local value=cjson.decode(current)
|
||||
if value.owner~=ARGV[2] or value.state~='running' then return redis.error_reply('claim owner/state changed') end
|
||||
local response=ARGV[3]
|
||||
if response~='' then value.state='completed';value.response=cjson.decode(response) else value.state='failed' end
|
||||
local encoded=cjson.encode(value)
|
||||
if string.len(encoded)>tonumber(ARGV[5]) then value.state='failed';value.response=nil;encoded=cjson.encode(value) end
|
||||
local clock=redis.call('TIME');local now=clock[1]*1000+math.floor(clock[2]/1000)
|
||||
redis.call('HSET',KEYS[1],ARGV[1],encoded);redis.call('ZADD',KEYS[2],now+tonumber(ARGV[4]),ARGV[1]);return 1
|
||||
`)
|
||||
|
||||
func (s *IdempotencyStore) Complete(ctx context.Context, key, owner string, response *middleware.StoredResponse, ttl time.Duration) error {
|
||||
if ttl <= 0 || ttl > 30*24*time.Hour {
|
||||
return errors.New("middleware/redis: TTL must be positive and <=30d")
|
||||
}
|
||||
var encoded []byte
|
||||
var err error
|
||||
if response != nil {
|
||||
encoded, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(encoded) > s.cfg.MaxRecordBytes {
|
||||
encoded = nil
|
||||
}
|
||||
}
|
||||
return complete.Run(ctx, s.client, s.keys, key, owner, string(encoded), max(1, ttl.Milliseconds()), s.cfg.MaxRecordBytes).Err()
|
||||
}
|
||||
|
||||
// Resolve removes only a matching non-replayable claim after the operator has
|
||||
// reconciled the business outcome. Never invoke automatically on a timeout.
|
||||
func (s *IdempotencyStore) Resolve(ctx context.Context, key, owner string) error {
|
||||
return goredis.NewScript(`local v=redis.call('HGET',KEYS[1],ARGV[1]);if not v then return 0 end;local r=cjson.decode(v);if r.owner~=ARGV[2] or r.state=='completed' then return redis.error_reply('claim cannot be resolved') end;redis.call('HDEL',KEYS[1],ARGV[1]);redis.call('ZREM',KEYS[2],ARGV[1]);return 1`).Run(ctx, s.client, s.keys, key, owner).Err()
|
||||
}
|
||||
|
||||
type limiter struct {
|
||||
client goredis.Scripter
|
||||
keys []string
|
||||
cfg middleware.RateLimitConfig
|
||||
signature string
|
||||
}
|
||||
|
||||
func NewRateLimiter(client goredis.Scripter, prefix string, cfg middleware.RateLimitConfig) (*middleware.RateLimiter, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("middleware/redis: client required")
|
||||
}
|
||||
if cfg.MaxKeys == 0 {
|
||||
cfg.MaxKeys = 10000
|
||||
}
|
||||
if cfg.IdleTTL == 0 {
|
||||
cfg.IdleTTL = 24 * time.Hour
|
||||
}
|
||||
if _, err := middleware.NewRateLimiter(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := namespace(prefix, "rate")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backend := &limiter{client: client, keys: []string{base + ":buckets", base + ":seen", base + ":config"}, cfg: cfg, signature: fmt.Sprintf("%g:%d:%d:%d", cfg.Rate, cfg.Burst, cfg.MaxKeys, cfg.IdleTTL.Milliseconds())}
|
||||
cfg.Backend = backend
|
||||
return middleware.NewRateLimiter(cfg)
|
||||
}
|
||||
|
||||
var take = goredis.NewScript(`
|
||||
local config=redis.call('GET',KEYS[3]);if config and config~=ARGV[6] then return redis.error_reply('configuration mismatch') end
|
||||
if not config then redis.call('SET',KEYS[3],ARGV[6]) end
|
||||
local clock=redis.call('TIME');local now=clock[1]*1000+math.floor(clock[2]/1000)
|
||||
local expired=redis.call('ZRANGEBYSCORE',KEYS[2],'-inf',now-tonumber(ARGV[5]),'LIMIT',0,128)
|
||||
for _,key in ipairs(expired) do redis.call('HDEL',KEYS[1],key);redis.call('ZREM',KEYS[2],key) end
|
||||
local value=redis.call('HGET',KEYS[1],ARGV[1]);local tokens=tonumber(ARGV[3])
|
||||
if value then local b=cjson.decode(value);tokens=math.min(tokens,b.tokens+math.max(0,now-b.time)*tonumber(ARGV[2])/1000)
|
||||
elseif redis.call('HLEN',KEYS[1])>=tonumber(ARGV[4]) then return -1 end
|
||||
local delay=0;if tokens>=1 then tokens=tokens-1 else delay=math.ceil((1-tokens)*1000/tonumber(ARGV[2])) end
|
||||
redis.call('HSET',KEYS[1],ARGV[1],cjson.encode({tokens=tokens,time=now}));redis.call('ZADD',KEYS[2],now,ARGV[1]);return delay
|
||||
`)
|
||||
|
||||
func (l *limiter) Take(ctx context.Context, key string) (time.Duration, error) {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
delay, err := take.Run(ctx, l.client, l.keys, hex.EncodeToString(sum[:]), l.cfg.Rate, l.cfg.Burst, l.cfg.MaxKeys, l.cfg.IdleTTL.Milliseconds(), l.signature).Int64()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delay < 0 {
|
||||
return 0, middleware.ErrLimitCapacity
|
||||
}
|
||||
return time.Duration(delay) * time.Millisecond, nil
|
||||
}
|
||||
145
middleware/redis/store_test.go
Normal file
145
middleware/redis/store_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSharedRedisIntegration(t *testing.T) {
|
||||
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
|
||||
if address == "" {
|
||||
t.Skip("requires isolated Redis")
|
||||
}
|
||||
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 8, MaxRetries: -1, ContextTimeoutEnabled: true, ReadTimeout: time.Second, WriteTimeout: time.Second})
|
||||
defer client.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
prefix := fmt.Sprint("shared:", time.Now().UnixNano())
|
||||
s, err := NewIdempotencyStore(client, prefix, IdempotencyConfig{MaxEntries: 256})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Del(context.Background(), s.keys...)
|
||||
key, fp := strings.Repeat("a", 64), strings.Repeat("b", 64)
|
||||
var claims atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
owners := make(chan string, 32)
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
owner := fmt.Sprintf("%032x", i)
|
||||
_, ok, err := s.Claim(ctx, key, fp, owner)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if ok {
|
||||
claims.Add(1)
|
||||
owners <- owner
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if claims.Load() != 1 {
|
||||
t.Fatal(claims.Load())
|
||||
}
|
||||
owner := <-owners
|
||||
if err = s.Complete(ctx, key, "wrong", nil, time.Minute); err == nil {
|
||||
t.Fatal("foreign completion accepted")
|
||||
}
|
||||
if err = s.Complete(ctx, key, owner, &middleware.StoredResponse{Status: 201, Headers: http.Header{}, Body: []byte{0, 1, 255}}, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, ok, err := s.Claim(ctx, key, fp, strings.Repeat("c", 32))
|
||||
if err != nil || ok || record.State != "completed" || len(record.Response.Body) != 3 {
|
||||
t.Fatal(record, ok, err)
|
||||
}
|
||||
// Two independently constructed apps use the same backend, so only one runs.
|
||||
var executions atomic.Int32
|
||||
makeApp := func() *omni.App {
|
||||
backend, _ := NewIdempotencyStore(client, prefix, IdempotencyConfig{MaxEntries: 256})
|
||||
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: backend, Scope: func(*httpx.Context) (string, error) { return "verifiedTenant", nil }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := omni.New(omni.WithLogger(false))
|
||||
a.POST("/submit", func(c *httpx.Context) error { executions.Add(1); return c.OK("saved") }, idem.Middleware())
|
||||
return a
|
||||
}
|
||||
a, b := makeApp(), makeApp()
|
||||
request := func(a *omni.App, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/submit", strings.NewReader(body))
|
||||
r.Header.Set("Idempotency-Key", "stableKey")
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
first, second := request(a, `{}`), request(b, `{}`)
|
||||
if first.Code != 200 || second.Code != 200 || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" || executions.Load() != 1 {
|
||||
t.Fatal(first.Code, second.Code, second.Body.String(), executions.Load())
|
||||
}
|
||||
if request(b, `{"changed":true}`).Code != 409 {
|
||||
t.Fatal("conflict accepted")
|
||||
}
|
||||
// Shared limits do not multiply when a second replica is added.
|
||||
limitPrefix := prefix + "limit"
|
||||
cfg := middleware.RateLimitConfig{Rate: .01, Burst: 2, Key: func(*httpx.Context) string { return "tenant" }}
|
||||
r1, _ := NewRateLimiter(client, limitPrefix, cfg)
|
||||
r2, _ := NewRateLimiter(client, limitPrefix, cfg)
|
||||
base, _ := namespace(limitPrefix, "rate")
|
||||
defer client.Del(context.Background(), base+":buckets", base+":seen", base+":config")
|
||||
limited := func(l *middleware.RateLimiter) int {
|
||||
a := omni.New(omni.WithLogger(false))
|
||||
a.GET("/", func(c *httpx.Context) error { return c.OK("ok") }, l.Middleware())
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
return w.Code
|
||||
}
|
||||
if limited(r1) != 200 || limited(r2) != 200 || limited(r1) != 429 {
|
||||
t.Fatal("limits multiplied across instances")
|
||||
}
|
||||
// Redis failure must fail closed before executing a new request.
|
||||
client.Close()
|
||||
if status := limited(r2); status != 503 {
|
||||
t.Fatal(status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredTargetBeyondCleanupBatch(t *testing.T) {
|
||||
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
|
||||
if address == "" {
|
||||
t.Skip("requires isolated Redis")
|
||||
}
|
||||
client := goredis.NewClient(&goredis.Options{Addr: address})
|
||||
defer client.Close()
|
||||
s, _ := NewIdempotencyStore(client, fmt.Sprint("expiry:", time.Now().UnixNano()), IdempotencyConfig{MaxEntries: 256})
|
||||
defer client.Del(context.Background(), s.keys...)
|
||||
ctx := context.Background()
|
||||
owner := strings.Repeat("1", 32)
|
||||
fp := strings.Repeat("2", 64)
|
||||
for i := 0; i < 140; i++ {
|
||||
key := fmt.Sprintf("%064x", i)
|
||||
if _, ok, err := s.Claim(ctx, key, fp, owner); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Complete(ctx, key, owner, nil, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.ZAdd(ctx, s.keys[1], goredis.Z{Score: 0, Member: key})
|
||||
}
|
||||
if _, ok, err := s.Claim(ctx, fmt.Sprintf("%064x", 139), fp, owner); err != nil || !ok {
|
||||
t.Fatal("expired target retained beyond TTL", ok, err)
|
||||
}
|
||||
}
|
||||
102
middleware/sharedIdempotency.go
Normal file
102
middleware/sharedIdempotency.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StoredResponse struct {
|
||||
Status int `json:"status"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
type IdempotencyRecord struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Owner string `json:"owner"`
|
||||
State string `json:"state"`
|
||||
Response *StoredResponse `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
// IdempotencyStore must atomically claim absent keys, compare owners when
|
||||
// finishing and retain in-flight claims until explicit resolution. Only completed
|
||||
// or failed records expire; an uncertain outcome must never become a new claim.
|
||||
type IdempotencyStore interface {
|
||||
Claim(context.Context, string, string, string) (IdempotencyRecord, bool, error)
|
||||
Complete(context.Context, string, string, *StoredResponse, time.Duration) error
|
||||
}
|
||||
|
||||
func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerprint [32]byte) error {
|
||||
key, fp := hex.EncodeToString(id[:]), hex.EncodeToString(fingerprint[:])
|
||||
token := make([]byte, 16)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return err
|
||||
}
|
||||
owner := hex.EncodeToString(token)
|
||||
record, claimed, err := i.config.Store.Claim(c.Context(), key, fp, owner)
|
||||
if err != nil {
|
||||
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "idempotency store unavailable").WithCause(err)
|
||||
}
|
||||
if !claimed {
|
||||
if record.Fingerprint != fp {
|
||||
return apierrors.New(409, "IDEMPOTENCY_CONFLICT", "key was used with a different request")
|
||||
}
|
||||
if record.State != "completed" || record.Response == nil {
|
||||
return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or outcome requires reconciliation")
|
||||
}
|
||||
response := record.Response
|
||||
headers := response.Headers.Clone()
|
||||
headers.Set("Idempotency-Replayed", "true")
|
||||
return c.WriteResponse(response.Status, headers, response.Body)
|
||||
}
|
||||
completed := false
|
||||
finish := func(response *StoredResponse) error {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Context()), time.Second)
|
||||
defer cancel()
|
||||
return i.config.Store.Complete(ctx, key, owner, response, i.config.TTL)
|
||||
}
|
||||
defer func() {
|
||||
if !completed {
|
||||
if err := finish(nil); err != nil {
|
||||
logx.ErrorContext(c.Context(), "idempotency.persistFailed", "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
buffer := &boundedResponse{headers: c.Raw().Header().Clone(), limit: i.config.MaxResponseBytes}
|
||||
child := c.Clone(c.Context(), buffer)
|
||||
err = next(child)
|
||||
if ctxErr := child.Context().Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
child.WriteError(err)
|
||||
}
|
||||
if buffer.err != nil {
|
||||
return apierrors.Internal(buffer.err)
|
||||
}
|
||||
if buffer.status == 0 {
|
||||
buffer.status = 200
|
||||
}
|
||||
var response *StoredResponse
|
||||
if buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") {
|
||||
response = &StoredResponse{Status: buffer.status, Headers: buffer.headers.Clone(), Body: append([]byte(nil), buffer.body.Bytes()...)}
|
||||
}
|
||||
if err = finish(response); err != nil {
|
||||
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "outcome requires reconciliation").WithCause(err)
|
||||
}
|
||||
completed = true
|
||||
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())
|
||||
}
|
||||
|
||||
var ErrLimitCapacity = errors.New("middleware: shared rate limiter capacity")
|
||||
|
||||
type RateLimitBackend interface {
|
||||
Take(context.Context, string) (time.Duration, error)
|
||||
}
|
||||
Reference in New Issue
Block a user