Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c67151a
Add idempotency key core, store, routing middleware, and tests
Copilot May 20, 2026
5c2446f
Apply idempotency review cleanups
Copilot May 20, 2026
a70142b
Apply idempotency review cleanups
Copilot May 20, 2026
6b1a664
Drop idempotency key validation since it's user's responsability.
diasbruno May 20, 2026
defaf3a
Add pure idempotency closure and use it in routing middleware
Copilot May 21, 2026
5fccfcf
Refine idempotency closure call style and shared key normalization
Copilot May 21, 2026
f3216da
Add key validator and tighten idempotency lifecycle tests
Copilot May 21, 2026
b06b4ff
Use normalized idempotency key for routing validity check
Copilot May 21, 2026
4963c2d
Avoid double normalization in idempotency key validation
Copilot May 21, 2026
0fc4f54
Clarify routing key validation branches and reuse trim chars constant
Copilot May 21, 2026
1447b2e
Document engine precedence and streamline key validation path
Copilot May 21, 2026
8332d8c
Remove idempotency routing middleware layer and tests
Copilot May 23, 2026
341307b
Remove idempotency key normalization API
Copilot May 23, 2026
699d94c
Clarify caller-managed idempotency key formatting
Copilot May 23, 2026
79e6827
Remove idempotency closure pipeline helper
Copilot May 23, 2026
8c1164b
Split idempotency main and store methods
Copilot May 23, 2026
ffb1145
Remove redundant scope type check
Copilot May 23, 2026
68ab33a
Rename idempotency API to register/drop/store
Copilot May 23, 2026
a2fd04a
Use class for idempotency engine
Copilot May 23, 2026
ac2c7e3
Allow idempotency engine subclasses to define storage behavior
Copilot May 23, 2026
aa31870
Make storage slot subclass-defined in idempotency engine
Copilot May 23, 2026
3e7f20f
Clarify subclass storage method contract
Copilot May 23, 2026
8b2c764
Tighten subclass storage docs and test assertion
Copilot May 23, 2026
a2e852c
Move memory store implementation out of idempotency package
Copilot May 23, 2026
fb7ad77
Make core engine constructor explicitly abstract
Copilot May 23, 2026
7b830ab
Clean up abstract engine constructor and formatting
Copilot May 23, 2026
b4623e4
Group idempotency generics before methods
Copilot May 23, 2026
be52a73
Inline storage into memory idempotency engine
Copilot May 23, 2026
d1868e5
Remove redundant memory engine constructor
Copilot May 23, 2026
cd7dcf2
Wrap abstract engine error message lines
Copilot May 23, 2026
374e790
Document memory engine instantiation pattern
Copilot May 23, 2026
681c4f0
Refine idempotency messaging docs
Copilot May 23, 2026
22af3e1
Polish idempotency constructor guidance
Copilot May 23, 2026
59bfceb
Remove obsolete idempotency-engine-store generic
Copilot May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions idempotency/memory-store.lisp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
(defpackage #:io.github.cl-sdk.wst.idempotency.memory-store
(:use #:cl #:io.github.cl-sdk.wst.idempotency.store)
(:import-from #:io.github.cl-sdk.wst.idempotency
#:idempotency-engine)
(:documentation "In-memory idempotency backend.

Suitable for single-process scenarios; not thread-safe.")
(:export
#:memory-idempotency-engine))

(in-package #:io.github.cl-sdk.wst.idempotency.memory-store)

(defclass memory-idempotency-engine (idempotency-engine)
Comment thread
diasbruno marked this conversation as resolved.
((table :initarg :table
:initform (make-hash-table :test #'equal)
:reader memory-idempotency-engine-table))
(:documentation "Hash-table-backed in-memory idempotency engine.
Not thread-safe.

Instantiate with MAKE-INSTANCE and optional initargs:
- :table hash table used to persist entries (defaults to (make-hash-table :test #'equal))
- :ttl-seconds, :clock, :cache-response-p (inherited from idempotency-engine)."))

(defun %expired-p (entry now)
(and entry
(idempotency-entry-expires-at entry)
(>= now (idempotency-entry-expires-at entry))))

(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now)
(let* ((table (memory-idempotency-engine-table engine))
(entry (gethash key table)))
(when (%expired-p entry now)
(remhash key table)
(setf entry nil))
(cond
((null entry)
(let ((new-entry (make-idempotency-entry
:state :processing
:fingerprint fingerprint
:response nil
:expires-at (+ now ttl-seconds))))
(setf (gethash key table) new-entry)
(values :started new-entry)))
((not (string= fingerprint (idempotency-entry-fingerprint entry)))
(values :conflict entry))
((eq :completed (idempotency-entry-state entry))
(values :replay entry))
(t
(values :in-progress entry)))))

(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now)
(let* ((table (memory-idempotency-engine-table engine))
(entry (gethash key table)))
(when (%expired-p entry now)
(remhash key table)
(setf entry nil))
(when (and entry
(eq :processing (idempotency-entry-state entry))
(string= fingerprint (idempotency-entry-fingerprint entry)))
(setf (idempotency-entry-state entry) :completed
(idempotency-entry-response entry) response
(idempotency-entry-expires-at entry) (+ now ttl-seconds))
t)))

(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint)
(let* ((table (memory-idempotency-engine-table engine))
(entry (gethash key table)))
(when (and entry
(eq :processing (idempotency-entry-state entry))
(string= fingerprint (idempotency-entry-fingerprint entry)))
(remhash key table)
t)))
104 changes: 104 additions & 0 deletions idempotency/package.lisp
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
(defpackage #:io.github.cl-sdk.wst.idempotency
(:use #:cl)
(:documentation "Core idempotency-key primitives independent of HTTP objects.")
(:import-from #:io.github.cl-sdk.wst.idempotency.store
#:create-entry
#:update-entry
#:delete-entry
#:idempotency-entry-response)
(:export
#:cached-response
#:make-cached-response
#:cached-response-status
#:cached-response-headers
#:cached-response-content
#:idempotency-engine
#:make-idempotency-engine
#:idempotency-engine-ttl-seconds
#:idempotency-engine-clock
#:idempotency-engine-cache-response-p
#:valid-idempotency-key-p
#:register-request
#:drop-request
#:store-response))

(in-package #:io.github.cl-sdk.wst.idempotency)

(defstruct cached-response
"Serializable response snapshot for replay."
status
headers
content)

(defclass idempotency-engine ()
Comment thread
diasbruno marked this conversation as resolved.
((ttl-seconds :initarg :ttl-seconds
:initform 86400
:reader idempotency-engine-ttl-seconds)
(clock :initarg :clock
:initform #'get-universal-time
:reader idempotency-engine-clock)
(cache-response-p :initarg :cache-response-p
:initform (lambda (response)
(< (cached-response-status response) 500))
:reader idempotency-engine-cache-response-p))
(:documentation "Core idempotency orchestration object.

Engine subclasses can define their own storage slots and specialize
io.github.cl-sdk.wst.idempotency.store:create-entry,
io.github.cl-sdk.wst.idempotency.store:update-entry and
io.github.cl-sdk.wst.idempotency.store:delete-entry for storage behavior.
For a complete engine implementation, specialize all three methods."))

(defgeneric register-request (engine scope key fingerprint)
(:documentation "Register lifecycle processing ownership for SCOPE/KEY/FINGERPRINT.

Returns two values:
- DECISION: one of :started, :replay, :in-progress, :conflict
- PAYLOAD: cached-response for :replay, NIL otherwise."))

(defgeneric store-response (engine scope key fingerprint response)
(:documentation "Persist RESPONSE for SCOPE/KEY/FINGERPRINT.

Returns T when completion happened, NIL otherwise."))

(defgeneric drop-request (engine scope key fingerprint)
(:documentation "Release processing lock for SCOPE/KEY/FINGERPRINT.

Returns T when release happened, NIL otherwise."))

(defun make-idempotency-engine ()
(error "No default store-backed idempotency engine is provided in io.github.cl-sdk.wst.idempotency. Use a concrete engine implementation such as (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine) or your own subclass."))

(defun valid-idempotency-key-p (value)
"Return T when VALUE is a non-empty key of at most 255 chars.

Key formatting/normalization is caller-managed."
(and (stringp value)
(not (string= "" value))
(<= (length value) 255)))

(defmethod register-request ((engine idempotency-engine) scope key fingerprint)
(multiple-value-bind (status entry)
(create-entry engine
(list scope key)
fingerprint
(idempotency-engine-ttl-seconds engine)
(funcall (idempotency-engine-clock engine)))
(ecase status
(:started (values :started nil))
(:in-progress (values :in-progress nil))
(:conflict (values :conflict nil))
(:replay (values :replay (idempotency-entry-response entry))))))

(defmethod store-response ((engine idempotency-engine) scope key fingerprint response)
(update-entry engine
(list scope key)
fingerprint
response
(idempotency-engine-ttl-seconds engine)
(funcall (idempotency-engine-clock engine))))

(defmethod drop-request ((engine idempotency-engine) scope key fingerprint)
(delete-entry engine
(list scope key)
fingerprint))
Comment thread
diasbruno marked this conversation as resolved.
47 changes: 47 additions & 0 deletions idempotency/store.lisp
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
(defpackage #:io.github.cl-sdk.wst.idempotency.store
(:use #:cl)
(:documentation "Storage backend protocol for idempotency key tracking.

Implementations can provide atomic semantics for create/update/delete entry
operations in single-node or distributed stores.")
(:export
#:idempotency-entry
#:make-idempotency-entry
#:idempotency-entry-state
#:idempotency-entry-fingerprint
#:idempotency-entry-response
#:idempotency-entry-expires-at
#:create-entry
#:update-entry
#:delete-entry))

(in-package #:io.github.cl-sdk.wst.idempotency.store)

(defstruct idempotency-entry
"Stored lifecycle record for a key.

STATE is one of:
- :processing
- :completed"
state
fingerprint
response
expires-at)

(defgeneric create-entry (store key fingerprint ttl-seconds now)
(:documentation "Try to claim KEY for FINGERPRINT.

Returns two values:
- STATUS keyword: one of :started, :replay, :in-progress, :conflict
- ENTRY (or NIL): an idempotency-entry when useful for caller decisions."))

(defgeneric update-entry (store key fingerprint response ttl-seconds now)
(:documentation "Persist RESPONSE as completed for KEY/FINGERPRINT.

Returns T when completion happened, NIL otherwise."))

(defgeneric delete-entry (store key fingerprint)
(:documentation "Release a processing entry for KEY/FINGERPRINT.

Used when the current request should not be cached (for example 5xx).
Returns T when release happened, NIL otherwise."))
Comment thread
diasbruno marked this conversation as resolved.
9 changes: 9 additions & 0 deletions io.github.cl-sdk.wst.idempotency.asd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency
:description "Core idempotency-key lifecycle and replay policy."
:author "Bruno Dias"
:license "Unlicense"
:version "0.1.0"
:depends-on (#:io.github.cl-sdk.wst.idempotency.store)
:pathname "idempotency"
:serial t
:components ((:file "package")))
10 changes: 10 additions & 0 deletions io.github.cl-sdk.wst.idempotency.memory-store.asd
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.memory-store
:description "Built-in in-memory storage backend for idempotency keys."
:author "Bruno Dias"
:license "Unlicense"
:version "0.1.0"
:depends-on (#:io.github.cl-sdk.wst.idempotency.store
#:io.github.cl-sdk.wst.idempotency)
:pathname "idempotency"
:serial t
:components ((:file "memory-store")))
9 changes: 9 additions & 0 deletions io.github.cl-sdk.wst.idempotency.store.asd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.store
:description "Storage backend protocol for idempotency keys."
:author "Bruno Dias"
:license "Unlicense"
:version "0.1.0"
:depends-on ()
:pathname "idempotency"
:serial t
:components ((:file "store")))
12 changes: 12 additions & 0 deletions io.github.cl-sdk.wst.idempotency.test.asd
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.test
:description "Tests for io.github.cl-sdk.wst.idempotency."
:author "Bruno Dias"
:license "Unlicense"
:version "0.1.0"
:depends-on (#:io.github.cl-sdk.wst.test.support
#:io.github.cl-sdk.wst.idempotency.store
#:io.github.cl-sdk.wst.idempotency.memory-store
#:io.github.cl-sdk.wst.idempotency)
:pathname "t"
:serial t
:components ((:file "idempotency-tests")))
1 change: 1 addition & 0 deletions io.github.cl-sdk.wst.test.asd
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#:io.github.cl-sdk.wst.routing.dsl.test
#:io.github.cl-sdk.wst.circuit-breaker.test
#:io.github.cl-sdk.wst.rate-limit.test
#:io.github.cl-sdk.wst.idempotency.test
#:io.github.cl-sdk.wst.cors.test
#:io.github.cl-sdk.wst.routing.woo.test
#:io.github.cl-sdk.wst.routing.response.dsl.test
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Behavior is built from pipelines and composition, where middleware and handlers
- CORS middleware
- Static file routing
- Rate limiting with pluggable stores
- Idempotency core lifecycle API with pluggable stores
- Circuit breaker core and routing middleware integration
- Accept-aware response selection helpers (`io.github.cl-sdk.wst.request-accept`)
- W3C Trace Context propagation (`traceparent`/`tracestate`) with routing adapter (`io.github.cl-sdk.wst.trace-context`)
Expand Down
Loading
Loading