136 lines
5.5 KiB
Go
136 lines
5.5 KiB
Go
package middlewares
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Idempotency makes a mutation safe to retry over a flaky rider connection. The
|
|
// client sends an "Idempotency-Key" header — any stable unique string it picks
|
|
// per logical action (e.g. a UUID minted when the rider taps the button). The
|
|
// first request with that key runs normally and its response (status + body) is
|
|
// cached in Redis; a retry with the same key returns that stored response
|
|
// verbatim instead of executing the handler again. So a dropped ack over a bad
|
|
// network never turns into a double pickup-complete, a double COD payment or a
|
|
// double delivery.
|
|
//
|
|
// Optional by design: no header, or Redis being unavailable, means the handler
|
|
// runs exactly as before — nothing about the existing contract changes for
|
|
// clients that don't send a key. The key is namespaced per rider, so one
|
|
// rider's key can neither collide with nor read another rider's response.
|
|
func Idempotency() fiber.Handler {
|
|
const (
|
|
ttl = 24 * time.Hour
|
|
lockTTL = 30 * time.Second
|
|
sep = "\n"
|
|
)
|
|
return func(c *fiber.Ctx) error {
|
|
key := c.Get("Idempotency-Key")
|
|
if key == "" || db.Rdb == nil {
|
|
return c.Next()
|
|
}
|
|
base := "idem:" + idempotencyScope(c) + ":" + key
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
// Replay a previously stored response if this key already completed.
|
|
if stored, err := db.Rdb.Get(ctx, base).Result(); err == nil {
|
|
if idx := strings.Index(stored, sep); idx > 0 {
|
|
if status, convErr := strconv.Atoi(stored[:idx]); convErr == nil {
|
|
c.Set("Idempotent-Replay", "true")
|
|
return c.Status(status).Type("json").SendString(stored[idx+1:])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Claim the key so two concurrent retries don't both execute. The loser
|
|
// gets a clear, retryable signal rather than running the mutation twice.
|
|
claimed, _ := db.Rdb.SetNX(ctx, base+":lock", "1", lockTTL).Result()
|
|
if !claimed {
|
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
|
"success": false,
|
|
"code": constants.ErrIdempotencyInProgress,
|
|
"message": "an identical request is still being processed",
|
|
})
|
|
}
|
|
|
|
if err := c.Next(); err != nil {
|
|
db.Rdb.Del(context.Background(), base+":lock")
|
|
return err
|
|
}
|
|
|
|
// Cache SUCCESS only.
|
|
//
|
|
// This used to store any status below 500, on the reasoning that a 4xx
|
|
// is deterministic. A 4xx is not deterministic — it is a refusal made
|
|
// against state that moves. POST /customer/auth/otp/verify returns 401
|
|
// when the submitted code does not match the one in Redis, and the whole
|
|
// point of that screen is that the customer then gets the code right.
|
|
// With the refusal cached for 24 hours, the retry that should have
|
|
// worked replayed the old 401 instead — confirmed live against
|
|
// api.doormile.com, where the second attempt came back carrying
|
|
// Idempotent-Replay: true. One typo locked a customer out for a day.
|
|
// The same shape applies to 403 after a permission is granted, 404 after
|
|
// a record is created, and 429 after a window rolls over.
|
|
//
|
|
// Nothing is lost by narrowing it. This middleware exists to stop a retry
|
|
// repeating a SIDE EFFECT — a second pickup, a second COD collection, a
|
|
// second session. A request that ended 4xx performed no side effect, so
|
|
// re-executing it is exactly as safe as the first attempt was, and
|
|
// strictly more correct than replaying a stale no.
|
|
status := c.Response().StatusCode()
|
|
if isCacheableStatus(status) {
|
|
body := string(c.Response().Body())
|
|
db.Rdb.Set(context.Background(), base, strconv.Itoa(status)+sep+body, ttl)
|
|
}
|
|
db.Rdb.Del(context.Background(), base+":lock")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// isCacheableStatus reports whether a response may be stored and replayed to
|
|
// a later request carrying the same key. Only a 2xx may — see above.
|
|
func isCacheableStatus(status int) bool {
|
|
return status >= 200 && status < 300
|
|
}
|
|
|
|
// idempotencyScope namespaces a key so one caller's stored response can never
|
|
// be replayed to another.
|
|
//
|
|
// For an authenticated request the caller's own user id is the scope, which is
|
|
// what this middleware has always used.
|
|
//
|
|
// An UNAUTHENTICATED request has no user id, and defaulting to 0 would put
|
|
// every anonymous caller in one namespace: two customers who happened to pick
|
|
// the same Idempotency-Key on POST /customer/auth/otp/verify would collide, and
|
|
// the second would be handed the first's access token, refresh token and
|
|
// customer record. So an anonymous request is scoped by the request path and a
|
|
// hash of its body instead — the same body replays, a different body does not,
|
|
// and one person's session can never be served to another.
|
|
// The authenticated form is byte-identical to what this middleware has always
|
|
// produced ("idem:<uid>:<key>"). Changing it would orphan every in-flight key
|
|
// in Redis at deploy time, and a rider retrying a pickup-complete across that
|
|
// boundary would execute it a second time instead of replaying — the exact
|
|
// double-collection this middleware exists to prevent.
|
|
func idempotencyScope(c *fiber.Ctx) string {
|
|
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
|
|
return fmt.Sprintf("%d", uid)
|
|
}
|
|
// Anonymous callers get their own namespace shape, which no previous key
|
|
// can collide with: every key written before this change had a numeric
|
|
// scope, and this one never is.
|
|
sum := sha256.Sum256(append([]byte(c.Path()+"\x00"), c.Body()...))
|
|
return "anon-" + hex.EncodeToString(sum[:16])
|
|
}
|