Files
David Gageot 058f1523e8 feat(server): idempotency keys for follow-ups so retries are safe
A client whose POST /followup times out can't tell whether the
follow-up landed. Retrying risked delivering the prompt twice; not
retrying risked losing it.

Accept an optional Idempotency-Key header on /followup. A repeat with a
key already seen for the session is acknowledged (status "duplicate",
202) without delivering the message again, so retries are safe. The
reservation is per-session, bounded (recent keys only), made atomically
to settle concurrent retries to a single delivery, and rolled back if
delivery fails so a genuine failure stays retryable.
2026-06-15 11:01:08 +02:00

69 lines
1.9 KiB
Go

package server
import "sync"
// defaultIdempotencyCapacity is how many recent follow-up keys are remembered
// per session. It only needs to cover the window in which a client might retry
// a request it is unsure landed, so a small bound is plenty.
const defaultIdempotencyCapacity = 256
// idempotencyCache remembers recently-seen idempotency keys for a single
// session so a retried request (e.g. after a network timeout that actually
// succeeded) is not processed twice. It is a bounded FIFO set: the oldest key
// is evicted once capacity is reached.
type idempotencyCache struct {
capacity int
mu sync.Mutex
set map[string]struct{}
ring []string
}
func newIdempotencyCache(capacity int) *idempotencyCache {
if capacity <= 0 {
capacity = defaultIdempotencyCapacity
}
return &idempotencyCache{
capacity: capacity,
set: make(map[string]struct{}),
}
}
// reserve records key and reports whether it had already been seen. The
// check-and-record is atomic so concurrent retries with the same key resolve
// to exactly one reservation: the first call returns false (caller should
// process), every other returns true (caller should treat as a duplicate).
func (c *idempotencyCache) reserve(key string) (duplicate bool) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.set[key]; ok {
return true
}
c.set[key] = struct{}{}
c.ring = append(c.ring, key)
if len(c.ring) > c.capacity {
oldest := c.ring[0]
c.ring = c.ring[1:]
delete(c.set, oldest)
}
return false
}
// release forgets key so a subsequent request with it is processed again.
// Used to roll back a reservation when the reserved operation fails, so a
// failure is retryable rather than being mistaken for a completed duplicate.
func (c *idempotencyCache) release(key string) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.set[key]; !ok {
return
}
delete(c.set, key)
for i, k := range c.ring {
if k == key {
c.ring = append(c.ring[:i], c.ring[i+1:]...)
break
}
}
}