Files
doormile_backend/middlewares/idempotency.go
2026-09-16 11:42:06 +05:30

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])
}