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::"). 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]) }