package middlewares import ( "context" "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() } uid, _ := c.Locals("userid").(int) base := fmt.Sprintf("idem:%d:%s", uid, 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 only deterministic outcomes (2xx/4xx). A 5xx is transient — the // retry should get a genuine second attempt, not a cached failure. status := c.Response().StatusCode() if status < 500 { 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 } }