idempotencyGuard runs the Idempotency-Key handshake at the start of a side-effectful POST handler. The transport contract is Stripe-style: - No header → transparent no-op. The handler runs unchanged. - Header set + cached completed response (same body) → server writes the cached response verbatim a
(w http.ResponseWriter, r *http.Request, userID string, bodyBytes []byte)
| 52 | // can ignore it entirely — the helper is a no-op when the writer |
| 53 | // isn't a capturing wrapper. |
| 54 | func (a *API) idempotencyGuard(w http.ResponseWriter, r *http.Request, userID string, bodyBytes []byte) (replayed bool, out http.ResponseWriter, finalize func()) { |
| 55 | noop := func() {} |
| 56 | |
| 57 | if a.idempotency == nil { |
| 58 | return false, w, noop |
| 59 | } |
| 60 | key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) |
| 61 | if key == "" { |
| 62 | return false, w, noop |
| 63 | } |
| 64 | if len(key) > idempotency.MaxKeyLength { |
| 65 | http.Error(w, "Idempotency-Key exceeds max length", http.StatusBadRequest) |
| 66 | return true, nil, nil |
| 67 | } |
| 68 | |
| 69 | res, err := a.idempotency.Claim(r.Context(), userID, key, r.URL.Path, idempotency.HashRequest(r.URL.Path, bodyBytes)) |
| 70 | if err != nil { |
| 71 | log.Printf("[idempotency] claim error: %v (failing open)", err) |
| 72 | return false, w, noop |
| 73 | } |
| 74 | |
| 75 | switch res.Outcome { |
| 76 | case idempotency.OutcomeReplay: |
| 77 | if res.Cached.ContentType != "" { |
| 78 | w.Header().Set("Content-Type", res.Cached.ContentType) |
| 79 | } |
| 80 | w.Header().Set("Idempotent-Replayed", "true") |
| 81 | w.WriteHeader(res.Cached.StatusCode) |
| 82 | _, _ = w.Write(res.Cached.Body) |
| 83 | return true, nil, nil |
| 84 | |
| 85 | case idempotency.OutcomeMismatch: |
| 86 | http.Error(w, "Idempotency-Key reused with a different request body", http.StatusUnprocessableEntity) |
| 87 | return true, nil, nil |
| 88 | |
| 89 | case idempotency.OutcomeInFlight: |
| 90 | http.Error(w, "another request with this Idempotency-Key is in progress", http.StatusConflict) |
| 91 | return true, nil, nil |
| 92 | |
| 93 | case idempotency.OutcomeAcquired: |
| 94 | cap := &capturingWriter{ResponseWriter: w} |
| 95 | return false, cap, func() { |
| 96 | // WriteHeader may not have been called explicitly (Go's |
| 97 | // http package implicitly writes 200 on first Write). |
| 98 | code := cap.statusCode |
| 99 | if code == 0 { |
| 100 | code = http.StatusOK |
| 101 | } |
| 102 | if shouldCacheResponse(code, cap.sideEffectCommitted) { |
| 103 | if err := a.idempotency.Complete(r.Context(), userID, key, idempotency.CachedResponse{ |
| 104 | StatusCode: code, |
| 105 | ContentType: cap.Header().Get("Content-Type"), |
| 106 | Body: cap.body.Bytes(), |
| 107 | }); err != nil { |
| 108 | log.Printf("[idempotency] complete error: %v", err) |
| 109 | } |
| 110 | return |
| 111 | } |