updates
This commit is contained in:
@@ -70,10 +70,27 @@ func Idempotency() fiber.Handler {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache only deterministic outcomes (2xx/4xx). A 5xx is transient — the
|
||||
// retry should get a genuine second attempt, not a cached failure.
|
||||
// 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 status < 500 {
|
||||
if isCacheableStatus(status) {
|
||||
body := string(c.Response().Body())
|
||||
db.Rdb.Set(context.Background(), base, strconv.Itoa(status)+sep+body, ttl)
|
||||
}
|
||||
@@ -82,6 +99,12 @@ func Idempotency() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
|
||||
@@ -120,3 +120,53 @@ func TestPincodeInOperatingCity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What may be replayed from the idempotency cache.
|
||||
//
|
||||
// The middleware exists to stop a retry repeating a SIDE EFFECT — a second
|
||||
// pickup, a second COD collection, a second session. It used to cache every
|
||||
// status below 500, which quietly extended that to refusals.
|
||||
//
|
||||
// POST /customer/auth/otp/verify is where it bit: a wrong code returns 401, and
|
||||
// the whole purpose of the screen is that the customer then gets it right. With
|
||||
// the 401 cached for 24 hours, the retry that should have worked replayed the
|
||||
// old refusal. Confirmed live against api.doormile.com — the second attempt
|
||||
// came back carrying `Idempotent-Replay: true`.
|
||||
|
||||
func TestOnlySuccessfulResponsesAreCacheable(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{200, true, "a completed mutation is exactly what must not run twice"},
|
||||
{201, true, "a created booking must not be created again"},
|
||||
{204, true, "a completed no-content mutation still ran"},
|
||||
|
||||
{400, false, "a malformed body performed no side effect; re-running is free"},
|
||||
{401, false, "the code was wrong; the retry is meant to be right"},
|
||||
{403, false, "a permission can be granted between attempts"},
|
||||
{404, false, "the record can exist by the time of the retry"},
|
||||
{409, false, "a conflict can clear"},
|
||||
{422, false, "a district can reopen"},
|
||||
{429, false, "the rate-limit window rolls over"},
|
||||
|
||||
{500, false, "transient; the retry deserves a genuine second attempt"},
|
||||
{503, false, "the dependency can come back"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isCacheableStatus(tc.status); got != tc.want {
|
||||
t.Errorf("status %d cacheable = %v, want %v — %s", tc.status, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The specific regression, stated as itself: a failed sign-in must never be
|
||||
// replayed to a customer who has since typed the right code.
|
||||
func TestAFailedOtpVerifyIsNotCached(t *testing.T) {
|
||||
if isCacheableStatus(fiber.StatusUnauthorized) {
|
||||
t.Fatal("a 401 from /customer/auth/otp/verify would be cached for 24 hours, " +
|
||||
"so the retry with the correct code replays the refusal instead of running")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user