From c67151aab5724d3edbb15afe0bbee9a1c8054d66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 02:04:41 +0000 Subject: [PATCH 01/34] Add idempotency key core, store, routing middleware, and tests Agent-Logs-Url: https://github.com/cl-sdk/io.github.cl-sdk.wst/sessions/e82ed90f-5737-4d71-b0d6-688eab1aa882 Co-authored-by: diasbruno <362368+diasbruno@users.noreply.github.com> --- idempotency/memory-store.lisp | 66 ++++++++++ idempotency/package.lisp | 109 ++++++++++++++++ idempotency/routing.lisp | 116 ++++++++++++++++++ idempotency/store.lisp | 48 ++++++++ io.github.cl-sdk.wst.idempotency.asd | 11 ++ ...ub.cl-sdk.wst.idempotency.memory-store.asd | 10 ++ io.github.cl-sdk.wst.idempotency.routing.asd | 12 ++ ...ub.cl-sdk.wst.idempotency.routing.test.asd | 12 ++ io.github.cl-sdk.wst.idempotency.store.asd | 10 ++ io.github.cl-sdk.wst.idempotency.test.asd | 13 ++ io.github.cl-sdk.wst.test.asd | 2 + readme.md | 1 + t/idempotency-routing-tests.lisp | 96 +++++++++++++++ t/idempotency-tests.lisp | 72 +++++++++++ 14 files changed, 578 insertions(+) create mode 100644 idempotency/memory-store.lisp create mode 100644 idempotency/package.lisp create mode 100644 idempotency/routing.lisp create mode 100644 idempotency/store.lisp create mode 100644 io.github.cl-sdk.wst.idempotency.asd create mode 100644 io.github.cl-sdk.wst.idempotency.memory-store.asd create mode 100644 io.github.cl-sdk.wst.idempotency.routing.asd create mode 100644 io.github.cl-sdk.wst.idempotency.routing.test.asd create mode 100644 io.github.cl-sdk.wst.idempotency.store.asd create mode 100644 io.github.cl-sdk.wst.idempotency.test.asd create mode 100644 t/idempotency-routing-tests.lisp create mode 100644 t/idempotency-tests.lisp diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp new file mode 100644 index 0000000..0f0eb36 --- /dev/null +++ b/idempotency/memory-store.lisp @@ -0,0 +1,66 @@ +(defpackage #:io.github.cl-sdk.wst.idempotency.memory-store + (:use #:cl #:io.github.cl-sdk.wst.idempotency.store) + (:documentation "In-memory idempotency backend. + +Suitable for single-process scenarios; not thread-safe.") + (:export + #:memory-store)) + +(in-package #:io.github.cl-sdk.wst.idempotency.memory-store) + +(defclass memory-store () + ((table :initform (make-hash-table :test #'equal) + :reader memory-store-table)) + (:documentation "Hash-table-backed idempotency store. +Not thread-safe.")) + +(defun %expired-p (entry now) + (and entry + (idempotency-entry-expires-at entry) + (>= now (idempotency-entry-expires-at entry)))) + +(defmethod claim-idempotency ((store memory-store) key fingerprint ttl-seconds now) + (let* ((table (memory-store-table store)) + (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 complete-idempotency ((store memory-store) key fingerprint response ttl-seconds now) + (let* ((table (memory-store-table store)) + (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 release-idempotency ((store memory-store) key fingerprint) + (let* ((table (memory-store-table store)) + (entry (gethash key table))) + (when (and entry + (eq :processing (idempotency-entry-state entry)) + (string= fingerprint (idempotency-entry-fingerprint entry))) + (remhash key table) + t))) + diff --git a/idempotency/package.lisp b/idempotency/package.lisp new file mode 100644 index 0000000..c027c95 --- /dev/null +++ b/idempotency/package.lisp @@ -0,0 +1,109 @@ +(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 + #:claim-idempotency + #:complete-idempotency + #:release-idempotency + #:idempotency-entry-response) + (:import-from #:io.github.cl-sdk.wst.idempotency.memory-store + #:memory-store) + (:export + #:cached-response + #:make-cached-response + #:cached-response-status + #:cached-response-headers + #:cached-response-content + #:idempotency-engine + #:make-idempotency-engine + #:idempotency-engine-store + #:idempotency-engine-ttl-seconds + #:idempotency-engine-clock + #:idempotency-engine-cache-response-p + #:normalize-idempotency-key + #:valid-idempotency-key-p + #:make-fingerprint + #:begin-idempotency + #:finish-idempotency)) + +(in-package #:io.github.cl-sdk.wst.idempotency) + +(defstruct cached-response + "Serializable response snapshot for replay." + status + headers + content) + +(defstruct (idempotency-engine + (:constructor make-idempotency-engine + (&key + (store (make-instance 'memory-store)) + (ttl-seconds 86400) + (clock #'get-universal-time) + (cache-response-p (lambda (response) + (< (cached-response-status response) 500)))))) + "Core idempotency orchestration object." + store + ttl-seconds + clock + cache-response-p) + +(defun normalize-idempotency-key (value) + "Trim VALUE and return a normalized key string, or NIL." + (when value + (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) value))) + (if (string= trimmed "") nil trimmed)))) + +(defun valid-idempotency-key-p (value) + "Return T when VALUE is a usable idempotency key." + (let ((key (normalize-idempotency-key value))) + (and key (<= (length key) 255)))) + +(defun make-fingerprint (&key method scope body) + "Build a deterministic fingerprint string." + (with-output-to-string (stream) + (prin1 (list :method method :scope scope :body body) stream))) + +(defun begin-idempotency (engine scope key fingerprint) + "Begin lifecycle for (SCOPE, KEY, FINGERPRINT). + +Returns two values: +- DECISION: one of :started, :replay, :in-progress, :conflict +- PAYLOAD: cached-response for :replay, NIL otherwise." + (check-type engine idempotency-engine) + (check-type scope t) + (check-type key string) + (check-type fingerprint string) + (multiple-value-bind (status entry) + (claim-idempotency (idempotency-engine-store 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)))))) + +(defun finish-idempotency (engine scope key fingerprint response) + "Finalize lifecycle for a started key. + +When response passes CACHE-RESPONSE-P policy, it is persisted for replay. +Otherwise the processing marker is released." + (check-type engine idempotency-engine) + (check-type key string) + (check-type fingerprint string) + (check-type response cached-response) + (let* ((store (idempotency-engine-store engine)) + (scoped-key (list scope key)) + (now (funcall (idempotency-engine-clock engine)))) + (if (funcall (idempotency-engine-cache-response-p engine) response) + (complete-idempotency store + scoped-key + fingerprint + response + (idempotency-engine-ttl-seconds engine) + now) + (release-idempotency store scoped-key fingerprint)))) + diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp new file mode 100644 index 0000000..aabb612 --- /dev/null +++ b/idempotency/routing.lisp @@ -0,0 +1,116 @@ +(defpackage #:io.github.cl-sdk.wst.idempotency.routing + (:use #:cl #:io.github.cl-sdk.wst.idempotency) + (:documentation "Routing middleware adapter for idempotency keys.") + (:export + #:idempotency-key + #:idempotency-context-of)) + +(in-package #:io.github.cl-sdk.wst.idempotency.routing) + +(defun %request-header-ci (request header-name) + (let ((headers (io.github.cl-sdk.wst.routing:request-headers request))) + (or (gethash header-name headers) + (loop :for key :being :the :hash-keys :of headers + :using (:hash-value value) + :when (and (stringp key) (string-equal key header-name)) + :return value)))) + +(defun %default-scope-fn (request) + (or (let ((route (getf (io.github.cl-sdk.wst.routing:request-data request) :route))) + (and route (io.github.cl-sdk.wst.routing:route-path route))) + (io.github.cl-sdk.wst.routing:request-uri request))) + +(defun %default-fingerprint-fn (request scope) + (make-fingerprint + :method (io.github.cl-sdk.wst.routing:request-method request) + :scope scope + :body (io.github.cl-sdk.wst.routing:request-content request))) + +(defun idempotency-context-of (request) + "Return idempotency context injected by IDEMPOTENCY-KEY middleware." + (getf (io.github.cl-sdk.wst.routing:request-data request) :idempotency-context)) + +(defun idempotency-key (&key + (methods '(:POST :PATCH)) + (header-name "Idempotency-Key") + (require-key t) + (ttl-seconds 86400) + (scope-fn #'%default-scope-fn) + (fingerprint-fn #'%default-fingerprint-fn) + (in-progress-status 409) + (in-progress-content "request is already processing") + (conflict-status 409) + (conflict-content "idempotency key conflicts with a different request") + (missing-key-status 400) + (missing-key-content "missing idempotency key") + (engine (make-idempotency-engine :ttl-seconds ttl-seconds))) + "Create idempotency middleware pair for `wst.routing.dsl:build-webserver`. + +Returns: +- :BEFORE middleware that enforces idempotency key policy +- :AFTER middleware that persists replayable responses" + (labels ((protected-method-p (request) + (member (io.github.cl-sdk.wst.routing:request-method request) + methods + :test #'eql)) + (replay-response (response cached) + (setf (io.github.cl-sdk.wst.routing:response-status response) + (cached-response-status cached) + (io.github.cl-sdk.wst.routing:response-headers response) + (append (copy-list (cached-response-headers cached)) + (list :idempotency-replayed "true")) + (io.github.cl-sdk.wst.routing:response-content response) + (cached-response-content cached)) + response) + (deny-response (response status content) + (io.github.cl-sdk.wst.routing:write-response response status nil content) + response) + (response->cached (response) + (make-cached-response + :status (io.github.cl-sdk.wst.routing:response-status response) + :headers (copy-list (io.github.cl-sdk.wst.routing:response-headers response)) + :content (io.github.cl-sdk.wst.routing:response-content response)))) + (list + :before + (lambda (request response) + (if (not (protected-method-p request)) + (cons :continue response) + (let* ((raw-key (%request-header-ci request header-name)) + (key (normalize-idempotency-key raw-key))) + (cond + ((and raw-key (not (valid-idempotency-key-p key))) + (cons :halt (deny-response response missing-key-status missing-key-content))) + ((and require-key (not key)) + (cons :halt (deny-response response missing-key-status missing-key-content))) + ((not key) + (cons :continue response)) + (t + (let* ((scope (funcall scope-fn request)) + (fingerprint (funcall fingerprint-fn request scope))) + (multiple-value-bind (decision replayed) + (begin-idempotency engine scope key fingerprint) + (ecase decision + (:started + (io.github.cl-sdk.wst.routing:append-request-data + request + :idempotency-context + (list :scope scope + :key key + :fingerprint fingerprint)) + (cons :continue response)) + (:replay + (cons :halt (replay-response response replayed))) + (:in-progress + (cons :halt (deny-response response in-progress-status in-progress-content))) + (:conflict + (cons :halt (deny-response response conflict-status conflict-content))))))))))) + :after + (lambda (request response) + (alexandria:when-let ((ctx (idempotency-context-of request))) + (finish-idempotency engine + (getf ctx :scope) + (getf ctx :key) + (getf ctx :fingerprint) + (response->cached response)) + (io.github.cl-sdk.wst.routing:remove-request-data request :idempotency-context)) + response)))) diff --git a/idempotency/store.lisp b/idempotency/store.lisp new file mode 100644 index 0000000..39b07e9 --- /dev/null +++ b/idempotency/store.lisp @@ -0,0 +1,48 @@ +(defpackage #:io.github.cl-sdk.wst.idempotency.store + (:use #:cl) + (:documentation "Storage backend protocol for idempotency key tracking. + +Implementations can provide atomic semantics for claim/complete/release 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 + #:claim-idempotency + #:complete-idempotency + #:release-idempotency)) + +(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 claim-idempotency (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 complete-idempotency (store key fingerprint response ttl-seconds now) + (:documentation "Persist RESPONSE as completed for KEY/FINGERPRINT. + +Returns T when completion happened, NIL otherwise.")) + +(defgeneric release-idempotency (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.")) + diff --git a/io.github.cl-sdk.wst.idempotency.asd b/io.github.cl-sdk.wst.idempotency.asd new file mode 100644 index 0000000..1546ed4 --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.asd @@ -0,0 +1,11 @@ +(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 + #:io.github.cl-sdk.wst.idempotency.memory-store) + :pathname "idempotency" + :serial t + :components ((:file "package"))) + diff --git a/io.github.cl-sdk.wst.idempotency.memory-store.asd b/io.github.cl-sdk.wst.idempotency.memory-store.asd new file mode 100644 index 0000000..f7031ee --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.memory-store.asd @@ -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) + :pathname "idempotency" + :serial t + :components ((:file "memory-store"))) + diff --git a/io.github.cl-sdk.wst.idempotency.routing.asd b/io.github.cl-sdk.wst.idempotency.routing.asd new file mode 100644 index 0000000..5b4e390 --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.routing.asd @@ -0,0 +1,12 @@ +(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.routing + :description "Routing middleware adapter for idempotency keys." + :author "Bruno Dias" + :license "Unlicense" + :version "0.1.0" + :depends-on (#:alexandria + #:io.github.cl-sdk.wst.idempotency + #:io.github.cl-sdk.wst.routing) + :pathname "idempotency" + :serial t + :components ((:file "routing"))) + diff --git a/io.github.cl-sdk.wst.idempotency.routing.test.asd b/io.github.cl-sdk.wst.idempotency.routing.test.asd new file mode 100644 index 0000000..55cb30b --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.routing.test.asd @@ -0,0 +1,12 @@ +(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.routing.test + :description "Tests for io.github.cl-sdk.wst.idempotency.routing." + :author "Bruno Dias" + :license "Unlicense" + :version "0.1.0" + :depends-on (#:io.github.cl-sdk.wst.test.support + #:io.github.cl-sdk.wst.routing.dsl + #:io.github.cl-sdk.wst.idempotency.routing) + :pathname "t" + :serial t + :components ((:file "idempotency-routing-tests"))) + diff --git a/io.github.cl-sdk.wst.idempotency.store.asd b/io.github.cl-sdk.wst.idempotency.store.asd new file mode 100644 index 0000000..41c1b29 --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.store.asd @@ -0,0 +1,10 @@ +(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"))) + diff --git a/io.github.cl-sdk.wst.idempotency.test.asd b/io.github.cl-sdk.wst.idempotency.test.asd new file mode 100644 index 0000000..35ac272 --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.test.asd @@ -0,0 +1,13 @@ +(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"))) + diff --git a/io.github.cl-sdk.wst.test.asd b/io.github.cl-sdk.wst.test.asd index 544c95e..9cb43de 100644 --- a/io.github.cl-sdk.wst.test.asd +++ b/io.github.cl-sdk.wst.test.asd @@ -7,6 +7,8 @@ #: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.idempotency.routing.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 diff --git a/readme.md b/readme.md index 7e707af..7e09790 100644 --- a/readme.md +++ b/readme.md @@ -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-key middleware with replay and conflict handling - 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`) diff --git a/t/idempotency-routing-tests.lisp b/t/idempotency-routing-tests.lisp new file mode 100644 index 0000000..36dd580 --- /dev/null +++ b/t/idempotency-routing-tests.lisp @@ -0,0 +1,96 @@ +(in-package :io.github.cl-sdk.wst.test) + +(5am:def-suite wst.idempotency.routing.suite + :description "Tests for wst.idempotency.routing middleware.") + +(5am:in-suite wst.idempotency.routing.suite) + +(def-route-testing idempotency-middleware-requires-key-by-default () + (let ((calls 0) + (middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key))) + (io.github.cl-sdk.wst.routing.dsl:build-webserver + `(:wrap + :before ,(getf middleware :before) + :after ,(getf middleware :after) + :route (:route :POST create "/" (lambda (request response) + (declare (ignore request)) + (incf calls) + (io.github.cl-sdk.wst.routing:ok-response t response :content "ok") + response)))) + (let ((response (io.github.cl-sdk.wst.routing:dispatch-route + (io.github.cl-sdk.wst.routing:make-request :uri "/" :method :POST)))) + (5am:is (= 0 calls)) + (5am:is (= 400 (io.github.cl-sdk.wst.routing:response-status response)))))) + +(def-route-testing idempotency-middleware-replays-completed-response () + (let ((calls 0) + (middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key))) + (io.github.cl-sdk.wst.routing.dsl:build-webserver + `(:wrap + :before ,(getf middleware :before) + :after ,(getf middleware :after) + :route (:route :POST create "/" (lambda (request response) + (declare (ignore request)) + (incf calls) + (io.github.cl-sdk.wst.routing:ok-response t response :content "created") + response)))) + (let* ((headers (cl-hash-util:hash ("idempotency-key" "abc-1"))) + (first (io.github.cl-sdk.wst.routing:dispatch-route + (io.github.cl-sdk.wst.routing:make-request + :uri "/" :method :POST :headers headers))) + (second (io.github.cl-sdk.wst.routing:dispatch-route + (io.github.cl-sdk.wst.routing:make-request + :uri "/" :method :POST :headers headers)))) + (5am:is (= 1 calls)) + (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status first))) + (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status second))) + (5am:is (string= "created" (io.github.cl-sdk.wst.routing:response-content second))) + (5am:is (string= "true" (getf (io.github.cl-sdk.wst.routing:response-headers second) + :idempotency-replayed))))) + +(def-route-testing idempotency-middleware-rejects-conflicting-payload () + (let ((calls 0) + (middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key))) + (io.github.cl-sdk.wst.routing.dsl:build-webserver + `(:wrap + :before ,(getf middleware :before) + :after ,(getf middleware :after) + :route (:route :POST create "/" (lambda (request response) + (declare (ignore request)) + (incf calls) + (io.github.cl-sdk.wst.routing:ok-response t response :content "created") + response)))) + (let* ((headers (cl-hash-util:hash ("Idempotency-Key" "abc-2"))) + (first (io.github.cl-sdk.wst.routing:dispatch-route + (io.github.cl-sdk.wst.routing:make-request + :uri "/" :method :POST :headers headers :content "name=alice"))) + (second (io.github.cl-sdk.wst.routing:dispatch-route + (io.github.cl-sdk.wst.routing:make-request + :uri "/" :method :POST :headers headers :content "name=bob")))) + (5am:is (= 1 calls)) + (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status first))) + (5am:is (= 409 (io.github.cl-sdk.wst.routing:response-status second)))))) + +(5am:def-test idempotency-middleware-blocks-while-in-progress () + (let* ((middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key)) + (before (getf middleware :before)) + (request-a (io.github.cl-sdk.wst.routing:make-request + :uri "/payments" + :method :POST + :headers (cl-hash-util:hash ("Idempotency-Key" "same")))) + (request-b (io.github.cl-sdk.wst.routing:make-request + :uri "/payments" + :method :POST + :headers (cl-hash-util:hash ("Idempotency-Key" "same")))) + (response-a (io.github.cl-sdk.wst.routing:make-response)) + (response-b (io.github.cl-sdk.wst.routing:make-response))) + (destructuring-bind (control-a . resulting-a) + (funcall before request-a response-a) + (declare (ignore resulting-a)) + (5am:is (eq :continue control-a))) + (destructuring-bind (control-b . resulting-b) + (funcall before request-b response-b) + (declare (ignore resulting-b)) + (5am:is (eq :halt control-b)) + (5am:is (= 409 (io.github.cl-sdk.wst.routing:response-status response-b)))))) + diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp new file mode 100644 index 0000000..8dd13d8 --- /dev/null +++ b/t/idempotency-tests.lisp @@ -0,0 +1,72 @@ +(in-package :io.github.cl-sdk.wst.test) + +(5am:def-suite wst.idempotency.suite + :description "Tests for the wst.idempotency package.") + +(5am:in-suite wst.idempotency.suite) + +(5am:def-test idempotency-key-validation () + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p nil)) + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "")) + (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "abc-123"))) + +(5am:def-test begin-finish-then-replay () + (let* ((now 100) + (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine + :clock (lambda () now))) + (scope "checkout") + (key "k1") + (fingerprint "f1") + (cached (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers '(:content-type "text/plain") + :content "ok"))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :in-progress decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:finish-idempotency + engine scope key fingerprint cached)) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (5am:is (eq :replay decision)) + (5am:is (= 200 (io.github.cl-sdk.wst.idempotency:cached-response-status replayed))) + (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) + +(5am:def-test key-conflict-when-fingerprint-differs () + (let ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine "scope" "same-key" "fingerprint-a") + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine "scope" "same-key" "fingerprint-b") + (declare (ignore replayed)) + (5am:is (eq :conflict decision))))) + +(5am:def-test non-cacheable-response-releases-key () + (let* ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine + :cache-response-p (lambda (response) + (< (io.github.cl-sdk.wst.idempotency:cached-response-status response) 500)))) + (scope "scope") + (key "key") + (fingerprint "f") + (server-error (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 500 + :headers nil + :content "boom"))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:finish-idempotency + engine scope key fingerprint server-error)) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))))) + From 5c2446f4720b8f861629caad9e97d9d66b9c5f7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 02:06:13 +0000 Subject: [PATCH 02/34] Apply idempotency review cleanups Agent-Logs-Url: https://github.com/cl-sdk/io.github.cl-sdk.wst/sessions/e82ed90f-5737-4d71-b0d6-688eab1aa882 Co-authored-by: diasbruno <362368+diasbruno@users.noreply.github.com> --- idempotency/memory-store.lisp | 1 - idempotency/package.lisp | 1 - idempotency/store.lisp | 1 - io.github.cl-sdk.wst.idempotency.asd | 1 - io.github.cl-sdk.wst.idempotency.memory-store.asd | 1 - io.github.cl-sdk.wst.idempotency.routing.asd | 1 - io.github.cl-sdk.wst.idempotency.routing.test.asd | 1 - io.github.cl-sdk.wst.idempotency.store.asd | 1 - io.github.cl-sdk.wst.idempotency.test.asd | 1 - t/idempotency-routing-tests.lisp | 1 - t/idempotency-tests.lisp | 1 - 11 files changed, 11 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index 0f0eb36..2a74020 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -63,4 +63,3 @@ Not thread-safe.")) (string= fingerprint (idempotency-entry-fingerprint entry))) (remhash key table) t))) - diff --git a/idempotency/package.lisp b/idempotency/package.lisp index c027c95..8981f2e 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -106,4 +106,3 @@ Otherwise the processing marker is released." (idempotency-engine-ttl-seconds engine) now) (release-idempotency store scoped-key fingerprint)))) - diff --git a/idempotency/store.lisp b/idempotency/store.lisp index 39b07e9..bd1ca94 100644 --- a/idempotency/store.lisp +++ b/idempotency/store.lisp @@ -45,4 +45,3 @@ Returns T when completion happened, NIL otherwise.")) Used when the current request should not be cached (for example 5xx). Returns T when release happened, NIL otherwise.")) - diff --git a/io.github.cl-sdk.wst.idempotency.asd b/io.github.cl-sdk.wst.idempotency.asd index 1546ed4..7cfad1f 100644 --- a/io.github.cl-sdk.wst.idempotency.asd +++ b/io.github.cl-sdk.wst.idempotency.asd @@ -8,4 +8,3 @@ :pathname "idempotency" :serial t :components ((:file "package"))) - diff --git a/io.github.cl-sdk.wst.idempotency.memory-store.asd b/io.github.cl-sdk.wst.idempotency.memory-store.asd index f7031ee..197c411 100644 --- a/io.github.cl-sdk.wst.idempotency.memory-store.asd +++ b/io.github.cl-sdk.wst.idempotency.memory-store.asd @@ -7,4 +7,3 @@ :pathname "idempotency" :serial t :components ((:file "memory-store"))) - diff --git a/io.github.cl-sdk.wst.idempotency.routing.asd b/io.github.cl-sdk.wst.idempotency.routing.asd index 5b4e390..440d0c7 100644 --- a/io.github.cl-sdk.wst.idempotency.routing.asd +++ b/io.github.cl-sdk.wst.idempotency.routing.asd @@ -9,4 +9,3 @@ :pathname "idempotency" :serial t :components ((:file "routing"))) - diff --git a/io.github.cl-sdk.wst.idempotency.routing.test.asd b/io.github.cl-sdk.wst.idempotency.routing.test.asd index 55cb30b..b83a90f 100644 --- a/io.github.cl-sdk.wst.idempotency.routing.test.asd +++ b/io.github.cl-sdk.wst.idempotency.routing.test.asd @@ -9,4 +9,3 @@ :pathname "t" :serial t :components ((:file "idempotency-routing-tests"))) - diff --git a/io.github.cl-sdk.wst.idempotency.store.asd b/io.github.cl-sdk.wst.idempotency.store.asd index 41c1b29..b7b7ea3 100644 --- a/io.github.cl-sdk.wst.idempotency.store.asd +++ b/io.github.cl-sdk.wst.idempotency.store.asd @@ -7,4 +7,3 @@ :pathname "idempotency" :serial t :components ((:file "store"))) - diff --git a/io.github.cl-sdk.wst.idempotency.test.asd b/io.github.cl-sdk.wst.idempotency.test.asd index 35ac272..c0e0c64 100644 --- a/io.github.cl-sdk.wst.idempotency.test.asd +++ b/io.github.cl-sdk.wst.idempotency.test.asd @@ -10,4 +10,3 @@ :pathname "t" :serial t :components ((:file "idempotency-tests"))) - diff --git a/t/idempotency-routing-tests.lisp b/t/idempotency-routing-tests.lisp index 36dd580..85b7a21 100644 --- a/t/idempotency-routing-tests.lisp +++ b/t/idempotency-routing-tests.lisp @@ -93,4 +93,3 @@ (declare (ignore resulting-b)) (5am:is (eq :halt control-b)) (5am:is (= 409 (io.github.cl-sdk.wst.routing:response-status response-b)))))) - diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 8dd13d8..74b0ae7 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -69,4 +69,3 @@ (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :started decision))))) - From a70142b69327e9419954e3bcc418fc3f46bc27e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 02:06:13 +0000 Subject: [PATCH 03/34] Apply idempotency review cleanups Agent-Logs-Url: https://github.com/cl-sdk/io.github.cl-sdk.wst/sessions/e82ed90f-5737-4d71-b0d6-688eab1aa882 Co-authored-by: diasbruno <362368+diasbruno@users.noreply.github.com> --- t/idempotency-routing-tests.lisp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/t/idempotency-routing-tests.lisp b/t/idempotency-routing-tests.lisp index 85b7a21..75b6026 100644 --- a/t/idempotency-routing-tests.lisp +++ b/t/idempotency-routing-tests.lisp @@ -12,7 +12,7 @@ `(:wrap :before ,(getf middleware :before) :after ,(getf middleware :after) - :route (:route :POST create "/" (lambda (request response) + :route (:route :POST create "/" ,(lambda (request response) (declare (ignore request)) (incf calls) (io.github.cl-sdk.wst.routing:ok-response t response :content "ok") @@ -29,7 +29,7 @@ `(:wrap :before ,(getf middleware :before) :after ,(getf middleware :after) - :route (:route :POST create "/" (lambda (request response) + :route (:route :POST create "/" ,(lambda (request response) (declare (ignore request)) (incf calls) (io.github.cl-sdk.wst.routing:ok-response t response :content "created") @@ -46,7 +46,7 @@ (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status second))) (5am:is (string= "created" (io.github.cl-sdk.wst.routing:response-content second))) (5am:is (string= "true" (getf (io.github.cl-sdk.wst.routing:response-headers second) - :idempotency-replayed))))) + :idempotency-replayed)))))) (def-route-testing idempotency-middleware-rejects-conflicting-payload () (let ((calls 0) @@ -55,7 +55,7 @@ `(:wrap :before ,(getf middleware :before) :after ,(getf middleware :after) - :route (:route :POST create "/" (lambda (request response) + :route (:route :POST create "/" ,(lambda (request response) (declare (ignore request)) (incf calls) (io.github.cl-sdk.wst.routing:ok-response t response :content "created") From 6b1a6641800ca08f597531f0f2d9b69700be81c7 Mon Sep 17 00:00:00 2001 From: Bruno Dias Date: Tue, 19 May 2026 23:47:36 -0300 Subject: [PATCH 04/34] Drop idempotency key validation since it's user's responsability. --- idempotency/package.lisp | 12 ------------ t/idempotency-tests.lisp | 5 ----- 2 files changed, 17 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 8981f2e..9aa4b26 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -20,8 +20,6 @@ #:idempotency-engine-ttl-seconds #:idempotency-engine-clock #:idempotency-engine-cache-response-p - #:normalize-idempotency-key - #:valid-idempotency-key-p #:make-fingerprint #:begin-idempotency #:finish-idempotency)) @@ -48,16 +46,6 @@ clock cache-response-p) -(defun normalize-idempotency-key (value) - "Trim VALUE and return a normalized key string, or NIL." - (when value - (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) value))) - (if (string= trimmed "") nil trimmed)))) - -(defun valid-idempotency-key-p (value) - "Return T when VALUE is a usable idempotency key." - (let ((key (normalize-idempotency-key value))) - (and key (<= (length key) 255)))) (defun make-fingerprint (&key method scope body) "Build a deterministic fingerprint string." diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 74b0ae7..b1fb029 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -5,11 +5,6 @@ (5am:in-suite wst.idempotency.suite) -(5am:def-test idempotency-key-validation () - (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p nil)) - (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "")) - (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "abc-123"))) - (5am:def-test begin-finish-then-replay () (let* ((now 100) (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine From defaf3a2fdd0b15c120ecb9b0c673d2a0b08c117 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:23:22 +0000 Subject: [PATCH 05/34] Add pure idempotency closure and use it in routing middleware --- idempotency/package.lisp | 31 ++++++++++++++++++++++ idempotency/routing.lisp | 55 +++++++++++++++++++++------------------- t/idempotency-tests.lisp | 21 +++++++++++++++ 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 9aa4b26..e68250f 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -20,6 +20,7 @@ #:idempotency-engine-ttl-seconds #:idempotency-engine-clock #:idempotency-engine-cache-response-p + #:idempotency #:make-fingerprint #:begin-idempotency #:finish-idempotency)) @@ -46,6 +47,36 @@ clock cache-response-p) +(defun idempotency (&key + (store nil) + (ttl-seconds 86400) + (clock #'get-universal-time) + (cache-response-p (lambda (response) + (< (cached-response-status response) 500))) + (engine nil)) + "Create a pure idempotency lifecycle closure. + +The returned closure accepts OPERATION plus lifecycle arguments: + +- :BEGIN SCOPE KEY FINGERPRINT + -> returns two values from BEGIN-IDEMPOTENCY. +- :FINISH SCOPE KEY FINGERPRINT CACHED-RESPONSE + -> returns T/NIL from FINISH-IDEMPOTENCY. + +This API is independent of HTTP request/response objects." + (let ((engine (or engine + (make-idempotency-engine + :store (or store (make-instance 'memory-store)) + :ttl-seconds ttl-seconds + :clock clock + :cache-response-p cache-response-p)))) + (lambda (operation scope key fingerprint &optional response) + (ecase operation + (:begin + (begin-idempotency engine scope key fingerprint)) + (:finish + (finish-idempotency engine scope key fingerprint response)))))) + (defun make-fingerprint (&key method scope body) "Build a deterministic fingerprint string." diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index aabb612..303f4d8 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -43,7 +43,8 @@ (conflict-content "idempotency key conflicts with a different request") (missing-key-status 400) (missing-key-content "missing idempotency key") - (engine (make-idempotency-engine :ttl-seconds ttl-seconds))) + (engine nil) + (lifecycle (idempotency :ttl-seconds ttl-seconds :engine engine))) "Create idempotency middleware pair for `wst.routing.dsl:build-webserver`. Returns: @@ -75,21 +76,22 @@ Returns: (lambda (request response) (if (not (protected-method-p request)) (cons :continue response) - (let* ((raw-key (%request-header-ci request header-name)) - (key (normalize-idempotency-key raw-key))) - (cond - ((and raw-key (not (valid-idempotency-key-p key))) - (cons :halt (deny-response response missing-key-status missing-key-content))) - ((and require-key (not key)) - (cons :halt (deny-response response missing-key-status missing-key-content))) - ((not key) - (cons :continue response)) - (t - (let* ((scope (funcall scope-fn request)) - (fingerprint (funcall fingerprint-fn request scope))) - (multiple-value-bind (decision replayed) - (begin-idempotency engine scope key fingerprint) - (ecase decision + (let* ((raw-key (%request-header-ci request header-name)) + (key (and raw-key + (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) raw-key))) + (unless (string= "" trimmed) + trimmed))))) + (cond + ((and require-key (not key)) + (cons :halt (deny-response response missing-key-status missing-key-content))) + ((not key) + (cons :continue response)) + (t + (let* ((scope (funcall scope-fn request)) + (fingerprint (funcall fingerprint-fn request scope))) + (multiple-value-bind (decision replayed) + (funcall lifecycle :begin scope key fingerprint) + (ecase decision (:started (io.github.cl-sdk.wst.routing:append-request-data request @@ -104,13 +106,14 @@ Returns: (cons :halt (deny-response response in-progress-status in-progress-content))) (:conflict (cons :halt (deny-response response conflict-status conflict-content))))))))))) - :after - (lambda (request response) - (alexandria:when-let ((ctx (idempotency-context-of request))) - (finish-idempotency engine - (getf ctx :scope) - (getf ctx :key) - (getf ctx :fingerprint) - (response->cached response)) - (io.github.cl-sdk.wst.routing:remove-request-data request :idempotency-context)) - response)))) + :after + (lambda (request response) + (alexandria:when-let ((ctx (idempotency-context-of request))) + (funcall lifecycle + :finish + (getf ctx :scope) + (getf ctx :key) + (getf ctx :fingerprint) + (response->cached response)) + (io.github.cl-sdk.wst.routing:remove-request-data request :idempotency-context)) + response)))) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index b1fb029..afca287 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -5,6 +5,27 @@ (5am:in-suite wst.idempotency.suite) +(5am:def-test idempotency-closure-wraps-lifecycle () + (let* ((now 100) + (idempotency (io.github.cl-sdk.wst.idempotency:idempotency + :clock (lambda () now))) + (scope "checkout") + (key "k-closure") + (fingerprint "fp") + (cached (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers nil + :content "ok"))) + (multiple-value-bind (decision replayed) + (funcall idempotency :begin scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (funcall idempotency :finish scope key fingerprint cached)) + (multiple-value-bind (decision replayed) + (funcall idempotency :begin scope key fingerprint) + (5am:is (eq :replay decision)) + (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) + (5am:def-test begin-finish-then-replay () (let* ((now 100) (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine From 5fccfcfb0285e26572a9d6575635dd5daaf6d808 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:24:34 +0000 Subject: [PATCH 06/34] Refine idempotency closure call style and shared key normalization --- idempotency/package.lisp | 19 ++++++++++++++++--- idempotency/routing.lisp | 5 +---- t/idempotency-tests.lisp | 5 +++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index e68250f..3616512 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -21,6 +21,7 @@ #:idempotency-engine-clock #:idempotency-engine-cache-response-p #:idempotency + #:normalize-idempotency-key #:make-fingerprint #:begin-idempotency #:finish-idempotency)) @@ -70,12 +71,24 @@ This API is independent of HTTP request/response objects." :ttl-seconds ttl-seconds :clock clock :cache-response-p cache-response-p)))) - (lambda (operation scope key fingerprint &optional response) + (lambda (operation &rest arguments) (ecase operation (:begin - (begin-idempotency engine scope key fingerprint)) + (destructuring-bind (scope key fingerprint) arguments + (begin-idempotency engine scope key fingerprint))) (:finish - (finish-idempotency engine scope key fingerprint response)))))) + (destructuring-bind (scope key fingerprint response) arguments + (finish-idempotency engine scope key fingerprint response))))))) + +(defun normalize-idempotency-key (value) + "Normalize VALUE into an idempotency key string or NIL. + +Trims leading and trailing whitespace and returns NIL for non-string and +empty values." + (when (stringp value) + (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) value))) + (unless (string= "" trimmed) + trimmed)))) (defun make-fingerprint (&key method scope body) diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index 303f4d8..410a280 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -77,10 +77,7 @@ Returns: (if (not (protected-method-p request)) (cons :continue response) (let* ((raw-key (%request-header-ci request header-name)) - (key (and raw-key - (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) raw-key))) - (unless (string= "" trimmed) - trimmed))))) + (key (normalize-idempotency-key raw-key))) (cond ((and require-key (not key)) (cons :halt (deny-response response missing-key-status missing-key-content))) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index afca287..f475064 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -5,6 +5,11 @@ (5am:in-suite wst.idempotency.suite) +(5am:def-test normalize-idempotency-key-trims-and-rejects-empty () + (5am:is (string= "abc" (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " abc "))) + (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " \t ")) + (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key nil))) + (5am:def-test idempotency-closure-wraps-lifecycle () (let* ((now 100) (idempotency (io.github.cl-sdk.wst.idempotency:idempotency From f3216da6a9c7353a365fd5a4d4aed8fdbc662773 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:25:43 +0000 Subject: [PATCH 07/34] Add key validator and tighten idempotency lifecycle tests --- idempotency/package.lisp | 6 ++++++ idempotency/routing.lisp | 2 ++ t/idempotency-tests.lisp | 8 +++++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 3616512..31af9ae 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -22,6 +22,7 @@ #:idempotency-engine-cache-response-p #:idempotency #:normalize-idempotency-key + #:valid-idempotency-key-p #:make-fingerprint #:begin-idempotency #:finish-idempotency)) @@ -90,6 +91,11 @@ empty values." (unless (string= "" trimmed) trimmed)))) +(defun valid-idempotency-key-p (value) + "Return T when VALUE normalizes to a non-empty key of at most 255 chars." + (let ((normalized (normalize-idempotency-key value))) + (and normalized (<= (length normalized) 255)))) + (defun make-fingerprint (&key method scope body) "Build a deterministic fingerprint string." diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index 410a280..210fb78 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -79,6 +79,8 @@ Returns: (let* ((raw-key (%request-header-ci request header-name)) (key (normalize-idempotency-key raw-key))) (cond + ((and raw-key (not (valid-idempotency-key-p raw-key))) + (cons :halt (deny-response response missing-key-status missing-key-content))) ((and require-key (not key)) (cons :halt (deny-response response missing-key-status missing-key-content))) ((not key) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index f475064..959892d 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -8,7 +8,9 @@ (5am:def-test normalize-idempotency-key-trims-and-rejects-empty () (5am:is (string= "abc" (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " abc "))) (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " \t ")) - (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key nil))) + (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key nil)) + (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) (5am:def-test idempotency-closure-wraps-lifecycle () (let* ((now 100) @@ -23,8 +25,8 @@ :content "ok"))) (multiple-value-bind (decision replayed) (funcall idempotency :begin scope key fingerprint) - (declare (ignore replayed)) - (5am:is (eq :started decision))) + (5am:is (eq :started decision)) + (5am:is-false replayed)) (5am:is-true (funcall idempotency :finish scope key fingerprint cached)) (multiple-value-bind (decision replayed) (funcall idempotency :begin scope key fingerprint) From b06b4ff69023e7ac85ef15d02df6127d9bfc8657 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:26:22 +0000 Subject: [PATCH 08/34] Use normalized idempotency key for routing validity check --- idempotency/routing.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index 210fb78..58a5d3c 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -79,7 +79,7 @@ Returns: (let* ((raw-key (%request-header-ci request header-name)) (key (normalize-idempotency-key raw-key))) (cond - ((and raw-key (not (valid-idempotency-key-p raw-key))) + ((and raw-key (not (valid-idempotency-key-p key))) (cons :halt (deny-response response missing-key-status missing-key-content))) ((and require-key (not key)) (cons :halt (deny-response response missing-key-status missing-key-content))) From 4963c2dec2de9d69ad0acbe4ef038a08b9be480c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:27:25 +0000 Subject: [PATCH 09/34] Avoid double normalization in idempotency key validation --- idempotency/package.lisp | 12 ++++++++---- idempotency/routing.lisp | 2 +- t/idempotency-tests.lisp | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 31af9ae..4900da1 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -91,10 +91,14 @@ empty values." (unless (string= "" trimmed) trimmed)))) -(defun valid-idempotency-key-p (value) - "Return T when VALUE normalizes to a non-empty key of at most 255 chars." - (let ((normalized (normalize-idempotency-key value))) - (and normalized (<= (length normalized) 255)))) +(defun valid-idempotency-key-p (value &key (normalized-p nil)) + "Return T when VALUE is a non-empty key of at most 255 chars. + +When NORMALIZED-P is NIL (default), VALUE is normalized before validation." + (let ((candidate (if normalized-p value (normalize-idempotency-key value)))) + (and (stringp candidate) + (not (string= "" candidate)) + (<= (length candidate) 255)))) (defun make-fingerprint (&key method scope body) diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index 58a5d3c..2ecef5f 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -79,7 +79,7 @@ Returns: (let* ((raw-key (%request-header-ci request header-name)) (key (normalize-idempotency-key raw-key))) (cond - ((and raw-key (not (valid-idempotency-key-p key))) + ((and raw-key (not (valid-idempotency-key-p key :normalized-p t))) (cons :halt (deny-response response missing-key-status missing-key-content))) ((and require-key (not key)) (cons :halt (deny-response response missing-key-status missing-key-content))) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 959892d..6afaf1f 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -10,6 +10,7 @@ (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " \t ")) (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key nil)) (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) + (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key" :normalized-p t)) (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) (5am:def-test idempotency-closure-wraps-lifecycle () From 0fc4f54ba249b12698b8b656dfc7c82e4a4ef5c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:28:07 +0000 Subject: [PATCH 10/34] Clarify routing key validation branches and reuse trim chars constant --- idempotency/package.lisp | 5 ++++- idempotency/routing.lisp | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 4900da1..b48b4fc 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -29,6 +29,9 @@ (in-package #:io.github.cl-sdk.wst.idempotency) +(defparameter +idempotency-key-trim-chars+ + '(#\Space #\Tab #\Newline #\Return)) + (defstruct cached-response "Serializable response snapshot for replay." status @@ -87,7 +90,7 @@ This API is independent of HTTP request/response objects." Trims leading and trailing whitespace and returns NIL for non-string and empty values." (when (stringp value) - (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) value))) + (let ((trimmed (string-trim +idempotency-key-trim-chars+ value))) (unless (string= "" trimmed) trimmed)))) diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index 2ecef5f..3207a4a 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -79,7 +79,9 @@ Returns: (let* ((raw-key (%request-header-ci request header-name)) (key (normalize-idempotency-key raw-key))) (cond - ((and raw-key (not (valid-idempotency-key-p key :normalized-p t))) + ((and raw-key (not key)) + (cons :halt (deny-response response missing-key-status missing-key-content))) + ((and key (not (valid-idempotency-key-p key :normalized-p t))) (cons :halt (deny-response response missing-key-status missing-key-content))) ((and require-key (not key)) (cons :halt (deny-response response missing-key-status missing-key-content))) From 1447b2e56e2910e7867ba3593401a39ecdfdbec8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:28:53 +0000 Subject: [PATCH 11/34] Document engine precedence and streamline key validation path --- idempotency/package.lisp | 11 +++++++---- idempotency/routing.lisp | 4 +++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index b48b4fc..25f1379 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -98,10 +98,13 @@ empty values." "Return T when VALUE is a non-empty key of at most 255 chars. When NORMALIZED-P is NIL (default), VALUE is normalized before validation." - (let ((candidate (if normalized-p value (normalize-idempotency-key value)))) - (and (stringp candidate) - (not (string= "" candidate)) - (<= (length candidate) 255)))) + (if normalized-p + (and (stringp value) + (not (string= "" value)) + (<= (length value) 255)) + (let ((normalized (normalize-idempotency-key value))) + (and normalized + (<= (length normalized) 255))))) (defun make-fingerprint (&key method scope body) diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp index 3207a4a..ca9cb89 100644 --- a/idempotency/routing.lisp +++ b/idempotency/routing.lisp @@ -49,7 +49,9 @@ Returns: - :BEFORE middleware that enforces idempotency key policy -- :AFTER middleware that persists replayable responses" +- :AFTER middleware that persists replayable responses + +When ENGINE is provided, it is used as-is by LIFECYCLE." (labels ((protected-method-p (request) (member (io.github.cl-sdk.wst.routing:request-method request) methods From 8332d8cc881578986e7bfb159d6a613656fcb7a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 00:30:22 +0000 Subject: [PATCH 12/34] Remove idempotency routing middleware layer and tests --- idempotency/routing.lisp | 122 ------------------ io.github.cl-sdk.wst.idempotency.routing.asd | 11 -- ...ub.cl-sdk.wst.idempotency.routing.test.asd | 11 -- io.github.cl-sdk.wst.test.asd | 1 - readme.md | 2 +- t/idempotency-routing-tests.lisp | 95 -------------- 6 files changed, 1 insertion(+), 241 deletions(-) delete mode 100644 idempotency/routing.lisp delete mode 100644 io.github.cl-sdk.wst.idempotency.routing.asd delete mode 100644 io.github.cl-sdk.wst.idempotency.routing.test.asd delete mode 100644 t/idempotency-routing-tests.lisp diff --git a/idempotency/routing.lisp b/idempotency/routing.lisp deleted file mode 100644 index ca9cb89..0000000 --- a/idempotency/routing.lisp +++ /dev/null @@ -1,122 +0,0 @@ -(defpackage #:io.github.cl-sdk.wst.idempotency.routing - (:use #:cl #:io.github.cl-sdk.wst.idempotency) - (:documentation "Routing middleware adapter for idempotency keys.") - (:export - #:idempotency-key - #:idempotency-context-of)) - -(in-package #:io.github.cl-sdk.wst.idempotency.routing) - -(defun %request-header-ci (request header-name) - (let ((headers (io.github.cl-sdk.wst.routing:request-headers request))) - (or (gethash header-name headers) - (loop :for key :being :the :hash-keys :of headers - :using (:hash-value value) - :when (and (stringp key) (string-equal key header-name)) - :return value)))) - -(defun %default-scope-fn (request) - (or (let ((route (getf (io.github.cl-sdk.wst.routing:request-data request) :route))) - (and route (io.github.cl-sdk.wst.routing:route-path route))) - (io.github.cl-sdk.wst.routing:request-uri request))) - -(defun %default-fingerprint-fn (request scope) - (make-fingerprint - :method (io.github.cl-sdk.wst.routing:request-method request) - :scope scope - :body (io.github.cl-sdk.wst.routing:request-content request))) - -(defun idempotency-context-of (request) - "Return idempotency context injected by IDEMPOTENCY-KEY middleware." - (getf (io.github.cl-sdk.wst.routing:request-data request) :idempotency-context)) - -(defun idempotency-key (&key - (methods '(:POST :PATCH)) - (header-name "Idempotency-Key") - (require-key t) - (ttl-seconds 86400) - (scope-fn #'%default-scope-fn) - (fingerprint-fn #'%default-fingerprint-fn) - (in-progress-status 409) - (in-progress-content "request is already processing") - (conflict-status 409) - (conflict-content "idempotency key conflicts with a different request") - (missing-key-status 400) - (missing-key-content "missing idempotency key") - (engine nil) - (lifecycle (idempotency :ttl-seconds ttl-seconds :engine engine))) - "Create idempotency middleware pair for `wst.routing.dsl:build-webserver`. - -Returns: -- :BEFORE middleware that enforces idempotency key policy -- :AFTER middleware that persists replayable responses - -When ENGINE is provided, it is used as-is by LIFECYCLE." - (labels ((protected-method-p (request) - (member (io.github.cl-sdk.wst.routing:request-method request) - methods - :test #'eql)) - (replay-response (response cached) - (setf (io.github.cl-sdk.wst.routing:response-status response) - (cached-response-status cached) - (io.github.cl-sdk.wst.routing:response-headers response) - (append (copy-list (cached-response-headers cached)) - (list :idempotency-replayed "true")) - (io.github.cl-sdk.wst.routing:response-content response) - (cached-response-content cached)) - response) - (deny-response (response status content) - (io.github.cl-sdk.wst.routing:write-response response status nil content) - response) - (response->cached (response) - (make-cached-response - :status (io.github.cl-sdk.wst.routing:response-status response) - :headers (copy-list (io.github.cl-sdk.wst.routing:response-headers response)) - :content (io.github.cl-sdk.wst.routing:response-content response)))) - (list - :before - (lambda (request response) - (if (not (protected-method-p request)) - (cons :continue response) - (let* ((raw-key (%request-header-ci request header-name)) - (key (normalize-idempotency-key raw-key))) - (cond - ((and raw-key (not key)) - (cons :halt (deny-response response missing-key-status missing-key-content))) - ((and key (not (valid-idempotency-key-p key :normalized-p t))) - (cons :halt (deny-response response missing-key-status missing-key-content))) - ((and require-key (not key)) - (cons :halt (deny-response response missing-key-status missing-key-content))) - ((not key) - (cons :continue response)) - (t - (let* ((scope (funcall scope-fn request)) - (fingerprint (funcall fingerprint-fn request scope))) - (multiple-value-bind (decision replayed) - (funcall lifecycle :begin scope key fingerprint) - (ecase decision - (:started - (io.github.cl-sdk.wst.routing:append-request-data - request - :idempotency-context - (list :scope scope - :key key - :fingerprint fingerprint)) - (cons :continue response)) - (:replay - (cons :halt (replay-response response replayed))) - (:in-progress - (cons :halt (deny-response response in-progress-status in-progress-content))) - (:conflict - (cons :halt (deny-response response conflict-status conflict-content))))))))))) - :after - (lambda (request response) - (alexandria:when-let ((ctx (idempotency-context-of request))) - (funcall lifecycle - :finish - (getf ctx :scope) - (getf ctx :key) - (getf ctx :fingerprint) - (response->cached response)) - (io.github.cl-sdk.wst.routing:remove-request-data request :idempotency-context)) - response)))) diff --git a/io.github.cl-sdk.wst.idempotency.routing.asd b/io.github.cl-sdk.wst.idempotency.routing.asd deleted file mode 100644 index 440d0c7..0000000 --- a/io.github.cl-sdk.wst.idempotency.routing.asd +++ /dev/null @@ -1,11 +0,0 @@ -(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.routing - :description "Routing middleware adapter for idempotency keys." - :author "Bruno Dias" - :license "Unlicense" - :version "0.1.0" - :depends-on (#:alexandria - #:io.github.cl-sdk.wst.idempotency - #:io.github.cl-sdk.wst.routing) - :pathname "idempotency" - :serial t - :components ((:file "routing"))) diff --git a/io.github.cl-sdk.wst.idempotency.routing.test.asd b/io.github.cl-sdk.wst.idempotency.routing.test.asd deleted file mode 100644 index b83a90f..0000000 --- a/io.github.cl-sdk.wst.idempotency.routing.test.asd +++ /dev/null @@ -1,11 +0,0 @@ -(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.routing.test - :description "Tests for io.github.cl-sdk.wst.idempotency.routing." - :author "Bruno Dias" - :license "Unlicense" - :version "0.1.0" - :depends-on (#:io.github.cl-sdk.wst.test.support - #:io.github.cl-sdk.wst.routing.dsl - #:io.github.cl-sdk.wst.idempotency.routing) - :pathname "t" - :serial t - :components ((:file "idempotency-routing-tests"))) diff --git a/io.github.cl-sdk.wst.test.asd b/io.github.cl-sdk.wst.test.asd index 9cb43de..7466572 100644 --- a/io.github.cl-sdk.wst.test.asd +++ b/io.github.cl-sdk.wst.test.asd @@ -8,7 +8,6 @@ #: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.idempotency.routing.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 diff --git a/readme.md b/readme.md index 7e09790..954965c 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,7 @@ Behavior is built from pipelines and composition, where middleware and handlers - CORS middleware - Static file routing - Rate limiting with pluggable stores -- Idempotency-key middleware with replay and conflict handling +- 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`) diff --git a/t/idempotency-routing-tests.lisp b/t/idempotency-routing-tests.lisp deleted file mode 100644 index 75b6026..0000000 --- a/t/idempotency-routing-tests.lisp +++ /dev/null @@ -1,95 +0,0 @@ -(in-package :io.github.cl-sdk.wst.test) - -(5am:def-suite wst.idempotency.routing.suite - :description "Tests for wst.idempotency.routing middleware.") - -(5am:in-suite wst.idempotency.routing.suite) - -(def-route-testing idempotency-middleware-requires-key-by-default () - (let ((calls 0) - (middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key))) - (io.github.cl-sdk.wst.routing.dsl:build-webserver - `(:wrap - :before ,(getf middleware :before) - :after ,(getf middleware :after) - :route (:route :POST create "/" ,(lambda (request response) - (declare (ignore request)) - (incf calls) - (io.github.cl-sdk.wst.routing:ok-response t response :content "ok") - response)))) - (let ((response (io.github.cl-sdk.wst.routing:dispatch-route - (io.github.cl-sdk.wst.routing:make-request :uri "/" :method :POST)))) - (5am:is (= 0 calls)) - (5am:is (= 400 (io.github.cl-sdk.wst.routing:response-status response)))))) - -(def-route-testing idempotency-middleware-replays-completed-response () - (let ((calls 0) - (middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key))) - (io.github.cl-sdk.wst.routing.dsl:build-webserver - `(:wrap - :before ,(getf middleware :before) - :after ,(getf middleware :after) - :route (:route :POST create "/" ,(lambda (request response) - (declare (ignore request)) - (incf calls) - (io.github.cl-sdk.wst.routing:ok-response t response :content "created") - response)))) - (let* ((headers (cl-hash-util:hash ("idempotency-key" "abc-1"))) - (first (io.github.cl-sdk.wst.routing:dispatch-route - (io.github.cl-sdk.wst.routing:make-request - :uri "/" :method :POST :headers headers))) - (second (io.github.cl-sdk.wst.routing:dispatch-route - (io.github.cl-sdk.wst.routing:make-request - :uri "/" :method :POST :headers headers)))) - (5am:is (= 1 calls)) - (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status first))) - (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status second))) - (5am:is (string= "created" (io.github.cl-sdk.wst.routing:response-content second))) - (5am:is (string= "true" (getf (io.github.cl-sdk.wst.routing:response-headers second) - :idempotency-replayed)))))) - -(def-route-testing idempotency-middleware-rejects-conflicting-payload () - (let ((calls 0) - (middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key))) - (io.github.cl-sdk.wst.routing.dsl:build-webserver - `(:wrap - :before ,(getf middleware :before) - :after ,(getf middleware :after) - :route (:route :POST create "/" ,(lambda (request response) - (declare (ignore request)) - (incf calls) - (io.github.cl-sdk.wst.routing:ok-response t response :content "created") - response)))) - (let* ((headers (cl-hash-util:hash ("Idempotency-Key" "abc-2"))) - (first (io.github.cl-sdk.wst.routing:dispatch-route - (io.github.cl-sdk.wst.routing:make-request - :uri "/" :method :POST :headers headers :content "name=alice"))) - (second (io.github.cl-sdk.wst.routing:dispatch-route - (io.github.cl-sdk.wst.routing:make-request - :uri "/" :method :POST :headers headers :content "name=bob")))) - (5am:is (= 1 calls)) - (5am:is (= 200 (io.github.cl-sdk.wst.routing:response-status first))) - (5am:is (= 409 (io.github.cl-sdk.wst.routing:response-status second)))))) - -(5am:def-test idempotency-middleware-blocks-while-in-progress () - (let* ((middleware (io.github.cl-sdk.wst.idempotency.routing:idempotency-key)) - (before (getf middleware :before)) - (request-a (io.github.cl-sdk.wst.routing:make-request - :uri "/payments" - :method :POST - :headers (cl-hash-util:hash ("Idempotency-Key" "same")))) - (request-b (io.github.cl-sdk.wst.routing:make-request - :uri "/payments" - :method :POST - :headers (cl-hash-util:hash ("Idempotency-Key" "same")))) - (response-a (io.github.cl-sdk.wst.routing:make-response)) - (response-b (io.github.cl-sdk.wst.routing:make-response))) - (destructuring-bind (control-a . resulting-a) - (funcall before request-a response-a) - (declare (ignore resulting-a)) - (5am:is (eq :continue control-a))) - (destructuring-bind (control-b . resulting-b) - (funcall before request-b response-b) - (declare (ignore resulting-b)) - (5am:is (eq :halt control-b)) - (5am:is (= 409 (io.github.cl-sdk.wst.routing:response-status response-b)))))) From 341307baf741ef04d52a93a56db327ec6b08184b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 00:52:24 +0000 Subject: [PATCH 13/34] Remove idempotency key normalization API --- idempotency/package.lisp | 30 +++++------------------------- t/idempotency-tests.lisp | 8 +++----- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 25f1379..a69d300 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -21,7 +21,6 @@ #:idempotency-engine-clock #:idempotency-engine-cache-response-p #:idempotency - #:normalize-idempotency-key #:valid-idempotency-key-p #:make-fingerprint #:begin-idempotency @@ -29,9 +28,6 @@ (in-package #:io.github.cl-sdk.wst.idempotency) -(defparameter +idempotency-key-trim-chars+ - '(#\Space #\Tab #\Newline #\Return)) - (defstruct cached-response "Serializable response snapshot for replay." status @@ -84,27 +80,11 @@ This API is independent of HTTP request/response objects." (destructuring-bind (scope key fingerprint response) arguments (finish-idempotency engine scope key fingerprint response))))))) -(defun normalize-idempotency-key (value) - "Normalize VALUE into an idempotency key string or NIL. - -Trims leading and trailing whitespace and returns NIL for non-string and -empty values." - (when (stringp value) - (let ((trimmed (string-trim +idempotency-key-trim-chars+ value))) - (unless (string= "" trimmed) - trimmed)))) - -(defun valid-idempotency-key-p (value &key (normalized-p nil)) - "Return T when VALUE is a non-empty key of at most 255 chars. - -When NORMALIZED-P is NIL (default), VALUE is normalized before validation." - (if normalized-p - (and (stringp value) - (not (string= "" value)) - (<= (length value) 255)) - (let ((normalized (normalize-idempotency-key value))) - (and normalized - (<= (length normalized) 255))))) +(defun valid-idempotency-key-p (value) + "Return T when VALUE is a non-empty key of at most 255 chars." + (and (stringp value) + (not (string= "" value)) + (<= (length value) 255))) (defun make-fingerprint (&key method scope body) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 6afaf1f..5ea0b96 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -5,12 +5,10 @@ (5am:in-suite wst.idempotency.suite) -(5am:def-test normalize-idempotency-key-trims-and-rejects-empty () - (5am:is (string= "abc" (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " abc "))) - (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key " \t ")) - (5am:is-false (io.github.cl-sdk.wst.idempotency:normalize-idempotency-key nil)) +(5am:def-test valid-idempotency-key-rejects-empty-and-too-long () + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p nil)) + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "")) (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) - (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key" :normalized-p t)) (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) (5am:def-test idempotency-closure-wraps-lifecycle () From 699d94caf93d88211321310c39bb08f1256bec65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 00:52:57 +0000 Subject: [PATCH 14/34] Clarify caller-managed idempotency key formatting --- idempotency/package.lisp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index a69d300..7cda390 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -81,7 +81,9 @@ This API is independent of HTTP request/response objects." (finish-idempotency engine scope key fingerprint response))))))) (defun valid-idempotency-key-p (value) - "Return T when VALUE is a non-empty key of at most 255 chars." + "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))) From 79e6827eb223642454ad88cdc4c67b47ce018193 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 00:59:35 +0000 Subject: [PATCH 15/34] Remove idempotency closure pipeline helper --- idempotency/package.lisp | 33 --------------------------------- t/idempotency-tests.lisp | 21 --------------------- 2 files changed, 54 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 7cda390..0da5f63 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -20,7 +20,6 @@ #:idempotency-engine-ttl-seconds #:idempotency-engine-clock #:idempotency-engine-cache-response-p - #:idempotency #:valid-idempotency-key-p #:make-fingerprint #:begin-idempotency @@ -48,38 +47,6 @@ clock cache-response-p) -(defun idempotency (&key - (store nil) - (ttl-seconds 86400) - (clock #'get-universal-time) - (cache-response-p (lambda (response) - (< (cached-response-status response) 500))) - (engine nil)) - "Create a pure idempotency lifecycle closure. - -The returned closure accepts OPERATION plus lifecycle arguments: - -- :BEGIN SCOPE KEY FINGERPRINT - -> returns two values from BEGIN-IDEMPOTENCY. -- :FINISH SCOPE KEY FINGERPRINT CACHED-RESPONSE - -> returns T/NIL from FINISH-IDEMPOTENCY. - -This API is independent of HTTP request/response objects." - (let ((engine (or engine - (make-idempotency-engine - :store (or store (make-instance 'memory-store)) - :ttl-seconds ttl-seconds - :clock clock - :cache-response-p cache-response-p)))) - (lambda (operation &rest arguments) - (ecase operation - (:begin - (destructuring-bind (scope key fingerprint) arguments - (begin-idempotency engine scope key fingerprint))) - (:finish - (destructuring-bind (scope key fingerprint response) arguments - (finish-idempotency engine scope key fingerprint response))))))) - (defun valid-idempotency-key-p (value) "Return T when VALUE is a non-empty key of at most 255 chars. diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 5ea0b96..0bbb3ac 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -11,27 +11,6 @@ (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) -(5am:def-test idempotency-closure-wraps-lifecycle () - (let* ((now 100) - (idempotency (io.github.cl-sdk.wst.idempotency:idempotency - :clock (lambda () now))) - (scope "checkout") - (key "k-closure") - (fingerprint "fp") - (cached (io.github.cl-sdk.wst.idempotency:make-cached-response - :status 200 - :headers nil - :content "ok"))) - (multiple-value-bind (decision replayed) - (funcall idempotency :begin scope key fingerprint) - (5am:is (eq :started decision)) - (5am:is-false replayed)) - (5am:is-true (funcall idempotency :finish scope key fingerprint cached)) - (multiple-value-bind (decision replayed) - (funcall idempotency :begin scope key fingerprint) - (5am:is (eq :replay decision)) - (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) - (5am:def-test begin-finish-then-replay () (let* ((now 100) (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine From 8c1164be65de41ecb82e4e533e21e30826554929 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:07:19 +0000 Subject: [PATCH 16/34] Split idempotency main and store methods --- idempotency/memory-store.lisp | 6 +-- idempotency/package.lisp | 79 ++++++++++++++++++++++++----------- idempotency/store.lisp | 14 +++---- t/idempotency-tests.lisp | 21 ++++++++++ 4 files changed, 85 insertions(+), 35 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index 2a74020..6ecc447 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -19,7 +19,7 @@ Not thread-safe.")) (idempotency-entry-expires-at entry) (>= now (idempotency-entry-expires-at entry)))) -(defmethod claim-idempotency ((store memory-store) key fingerprint ttl-seconds now) +(defmethod store-claim-idempotency ((store memory-store) key fingerprint ttl-seconds now) (let* ((table (memory-store-table store)) (entry (gethash key table))) (when (%expired-p entry now) @@ -41,7 +41,7 @@ Not thread-safe.")) (t (values :in-progress entry))))) -(defmethod complete-idempotency ((store memory-store) key fingerprint response ttl-seconds now) +(defmethod store-complete-idempotency ((store memory-store) key fingerprint response ttl-seconds now) (let* ((table (memory-store-table store)) (entry (gethash key table))) (when (%expired-p entry now) @@ -55,7 +55,7 @@ Not thread-safe.")) (idempotency-entry-expires-at entry) (+ now ttl-seconds)) t))) -(defmethod release-idempotency ((store memory-store) key fingerprint) +(defmethod store-release-idempotency ((store memory-store) key fingerprint) (let* ((table (memory-store-table store)) (entry (gethash key table))) (when (and entry diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 0da5f63..d9fb383 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -2,9 +2,9 @@ (:use #:cl) (:documentation "Core idempotency-key primitives independent of HTTP objects.") (:import-from #:io.github.cl-sdk.wst.idempotency.store - #:claim-idempotency - #:complete-idempotency - #:release-idempotency + #:store-claim-idempotency + #:store-complete-idempotency + #:store-release-idempotency #:idempotency-entry-response) (:import-from #:io.github.cl-sdk.wst.idempotency.memory-store #:memory-store) @@ -22,6 +22,9 @@ #:idempotency-engine-cache-response-p #:valid-idempotency-key-p #:make-fingerprint + #:claim-idempotency + #:complete-idempotency + #:release-idempotency #:begin-idempotency #:finish-idempotency)) @@ -61,6 +64,49 @@ Key formatting/normalization is caller-managed." (with-output-to-string (stream) (prin1 (list :method method :scope scope :body body) stream))) +(defgeneric claim-idempotency (engine scope key fingerprint) + (:documentation "Claim 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.")) + +(defmethod claim-idempotency ((engine idempotency-engine) scope key fingerprint) + (multiple-value-bind (status entry) + (store-claim-idempotency (idempotency-engine-store 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)))))) + +(defgeneric complete-idempotency (engine scope key fingerprint response) + (:documentation "Persist RESPONSE for SCOPE/KEY/FINGERPRINT. + +Returns T when completion happened, NIL otherwise.")) + +(defmethod complete-idempotency ((engine idempotency-engine) scope key fingerprint response) + (store-complete-idempotency (idempotency-engine-store engine) + (list scope key) + fingerprint + response + (idempotency-engine-ttl-seconds engine) + (funcall (idempotency-engine-clock engine)))) + +(defgeneric release-idempotency (engine scope key fingerprint) + (:documentation "Release processing lock for SCOPE/KEY/FINGERPRINT. + +Returns T when release happened, NIL otherwise.")) + +(defmethod release-idempotency ((engine idempotency-engine) scope key fingerprint) + (store-release-idempotency (idempotency-engine-store engine) + (list scope key) + fingerprint)) + (defun begin-idempotency (engine scope key fingerprint) "Begin lifecycle for (SCOPE, KEY, FINGERPRINT). @@ -71,17 +117,7 @@ Returns two values: (check-type scope t) (check-type key string) (check-type fingerprint string) - (multiple-value-bind (status entry) - (claim-idempotency (idempotency-engine-store 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)))))) + (claim-idempotency engine scope key fingerprint)) (defun finish-idempotency (engine scope key fingerprint response) "Finalize lifecycle for a started key. @@ -89,17 +125,10 @@ Returns two values: When response passes CACHE-RESPONSE-P policy, it is persisted for replay. Otherwise the processing marker is released." (check-type engine idempotency-engine) + (check-type scope t) (check-type key string) (check-type fingerprint string) (check-type response cached-response) - (let* ((store (idempotency-engine-store engine)) - (scoped-key (list scope key)) - (now (funcall (idempotency-engine-clock engine)))) - (if (funcall (idempotency-engine-cache-response-p engine) response) - (complete-idempotency store - scoped-key - fingerprint - response - (idempotency-engine-ttl-seconds engine) - now) - (release-idempotency store scoped-key fingerprint)))) + (if (funcall (idempotency-engine-cache-response-p engine) response) + (complete-idempotency engine scope key fingerprint response) + (release-idempotency engine scope key fingerprint))) diff --git a/idempotency/store.lisp b/idempotency/store.lisp index bd1ca94..4ad6a58 100644 --- a/idempotency/store.lisp +++ b/idempotency/store.lisp @@ -2,7 +2,7 @@ (:use #:cl) (:documentation "Storage backend protocol for idempotency key tracking. -Implementations can provide atomic semantics for claim/complete/release in +Implementations can provide atomic semantics for store-claim/store-complete/store-release in single-node or distributed stores.") (:export #:idempotency-entry @@ -11,9 +11,9 @@ single-node or distributed stores.") #:idempotency-entry-fingerprint #:idempotency-entry-response #:idempotency-entry-expires-at - #:claim-idempotency - #:complete-idempotency - #:release-idempotency)) + #:store-claim-idempotency + #:store-complete-idempotency + #:store-release-idempotency)) (in-package #:io.github.cl-sdk.wst.idempotency.store) @@ -28,19 +28,19 @@ STATE is one of: response expires-at) -(defgeneric claim-idempotency (store key fingerprint ttl-seconds now) +(defgeneric store-claim-idempotency (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 complete-idempotency (store key fingerprint response ttl-seconds now) +(defgeneric store-complete-idempotency (store key fingerprint response ttl-seconds now) (:documentation "Persist RESPONSE as completed for KEY/FINGERPRINT. Returns T when completion happened, NIL otherwise.")) -(defgeneric release-idempotency (store key fingerprint) +(defgeneric store-release-idempotency (store key fingerprint) (:documentation "Release a processing entry for KEY/FINGERPRINT. Used when the current request should not be cached (for example 5xx). diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 0bbb3ac..4b94e8b 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -11,6 +11,27 @@ (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) +(5am:def-test main-api-claim-complete-works () + (let* ((now 100) + (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine + :clock (lambda () now))) + (scope "checkout") + (key "k-main") + (fingerprint "f-main") + (cached (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers nil + :content "ok"))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:claim-idempotency engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:complete-idempotency engine scope key fingerprint cached)) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:claim-idempotency engine scope key fingerprint) + (5am:is (eq :replay decision)) + (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) + (5am:def-test begin-finish-then-replay () (let* ((now 100) (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine From ffb1145bec888d8a15153034e5886693a26ae6ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:07:47 +0000 Subject: [PATCH 17/34] Remove redundant scope type check --- idempotency/package.lisp | 1 - 1 file changed, 1 deletion(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index d9fb383..2ad21ee 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -125,7 +125,6 @@ Returns two values: When response passes CACHE-RESPONSE-P policy, it is persisted for replay. Otherwise the processing marker is released." (check-type engine idempotency-engine) - (check-type scope t) (check-type key string) (check-type fingerprint string) (check-type response cached-response) From 68ab33ab768df3a056466f2184ca644289ce2026 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:38:24 +0000 Subject: [PATCH 18/34] Rename idempotency API to register/drop/store --- idempotency/memory-store.lisp | 6 +-- idempotency/package.lisp | 88 +++++++++++------------------------ idempotency/store.lisp | 16 +++---- t/idempotency-tests.lisp | 42 +++++++---------- 4 files changed, 56 insertions(+), 96 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index 6ecc447..95782c8 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -19,7 +19,7 @@ Not thread-safe.")) (idempotency-entry-expires-at entry) (>= now (idempotency-entry-expires-at entry)))) -(defmethod store-claim-idempotency ((store memory-store) key fingerprint ttl-seconds now) +(defmethod create-entry ((store memory-store) key fingerprint ttl-seconds now) (let* ((table (memory-store-table store)) (entry (gethash key table))) (when (%expired-p entry now) @@ -41,7 +41,7 @@ Not thread-safe.")) (t (values :in-progress entry))))) -(defmethod store-complete-idempotency ((store memory-store) key fingerprint response ttl-seconds now) +(defmethod update-entry ((store memory-store) key fingerprint response ttl-seconds now) (let* ((table (memory-store-table store)) (entry (gethash key table))) (when (%expired-p entry now) @@ -55,7 +55,7 @@ Not thread-safe.")) (idempotency-entry-expires-at entry) (+ now ttl-seconds)) t))) -(defmethod store-release-idempotency ((store memory-store) key fingerprint) +(defmethod delete-entry ((store memory-store) key fingerprint) (let* ((table (memory-store-table store)) (entry (gethash key table))) (when (and entry diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 2ad21ee..9fc091b 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -2,9 +2,9 @@ (:use #:cl) (:documentation "Core idempotency-key primitives independent of HTTP objects.") (:import-from #:io.github.cl-sdk.wst.idempotency.store - #:store-claim-idempotency - #:store-complete-idempotency - #:store-release-idempotency + #:create-entry + #:update-entry + #:delete-entry #:idempotency-entry-response) (:import-from #:io.github.cl-sdk.wst.idempotency.memory-store #:memory-store) @@ -21,12 +21,9 @@ #:idempotency-engine-clock #:idempotency-engine-cache-response-p #:valid-idempotency-key-p - #:make-fingerprint - #:claim-idempotency - #:complete-idempotency - #:release-idempotency - #:begin-idempotency - #:finish-idempotency)) + #:register-request + #:drop-request + #:store-response)) (in-package #:io.github.cl-sdk.wst.idempotency) @@ -58,76 +55,45 @@ Key formatting/normalization is caller-managed." (not (string= "" value)) (<= (length value) 255))) - -(defun make-fingerprint (&key method scope body) - "Build a deterministic fingerprint string." - (with-output-to-string (stream) - (prin1 (list :method method :scope scope :body body) stream))) - -(defgeneric claim-idempotency (engine scope key fingerprint) - (:documentation "Claim lifecycle processing ownership for SCOPE/KEY/FINGERPRINT. +(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.")) -(defmethod claim-idempotency ((engine idempotency-engine) scope key fingerprint) +(defmethod register-request ((engine idempotency-engine) scope key fingerprint) (multiple-value-bind (status entry) - (store-claim-idempotency (idempotency-engine-store engine) - (list scope key) - fingerprint - (idempotency-engine-ttl-seconds engine) - (funcall (idempotency-engine-clock engine))) + (create-entry (idempotency-engine-store 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)))))) -(defgeneric complete-idempotency (engine scope key fingerprint response) +(defgeneric store-response (engine scope key fingerprint response) (:documentation "Persist RESPONSE for SCOPE/KEY/FINGERPRINT. Returns T when completion happened, NIL otherwise.")) -(defmethod complete-idempotency ((engine idempotency-engine) scope key fingerprint response) - (store-complete-idempotency (idempotency-engine-store engine) - (list scope key) - fingerprint - response - (idempotency-engine-ttl-seconds engine) - (funcall (idempotency-engine-clock engine)))) +(defmethod store-response ((engine idempotency-engine) scope key fingerprint response) + (update-entry (idempotency-engine-store engine) + (list scope key) + fingerprint + response + (idempotency-engine-ttl-seconds engine) + (funcall (idempotency-engine-clock engine)))) -(defgeneric release-idempotency (engine scope key fingerprint) +(defgeneric drop-request (engine scope key fingerprint) (:documentation "Release processing lock for SCOPE/KEY/FINGERPRINT. Returns T when release happened, NIL otherwise.")) -(defmethod release-idempotency ((engine idempotency-engine) scope key fingerprint) - (store-release-idempotency (idempotency-engine-store engine) - (list scope key) - fingerprint)) - -(defun begin-idempotency (engine scope key fingerprint) - "Begin lifecycle for (SCOPE, KEY, FINGERPRINT). - -Returns two values: -- DECISION: one of :started, :replay, :in-progress, :conflict -- PAYLOAD: cached-response for :replay, NIL otherwise." - (check-type engine idempotency-engine) - (check-type scope t) - (check-type key string) - (check-type fingerprint string) - (claim-idempotency engine scope key fingerprint)) - -(defun finish-idempotency (engine scope key fingerprint response) - "Finalize lifecycle for a started key. - -When response passes CACHE-RESPONSE-P policy, it is persisted for replay. -Otherwise the processing marker is released." - (check-type engine idempotency-engine) - (check-type key string) - (check-type fingerprint string) - (check-type response cached-response) - (if (funcall (idempotency-engine-cache-response-p engine) response) - (complete-idempotency engine scope key fingerprint response) - (release-idempotency engine scope key fingerprint))) +(defmethod drop-request ((engine idempotency-engine) scope key fingerprint) + (delete-entry (idempotency-engine-store engine) + (list scope key) + fingerprint)) diff --git a/idempotency/store.lisp b/idempotency/store.lisp index 4ad6a58..79b2b96 100644 --- a/idempotency/store.lisp +++ b/idempotency/store.lisp @@ -2,8 +2,8 @@ (:use #:cl) (:documentation "Storage backend protocol for idempotency key tracking. -Implementations can provide atomic semantics for store-claim/store-complete/store-release in -single-node or distributed stores.") +Implementations can provide atomic semantics for create/update/delete entry +operations in single-node or distributed stores.") (:export #:idempotency-entry #:make-idempotency-entry @@ -11,9 +11,9 @@ single-node or distributed stores.") #:idempotency-entry-fingerprint #:idempotency-entry-response #:idempotency-entry-expires-at - #:store-claim-idempotency - #:store-complete-idempotency - #:store-release-idempotency)) + #:create-entry + #:update-entry + #:delete-entry)) (in-package #:io.github.cl-sdk.wst.idempotency.store) @@ -28,19 +28,19 @@ STATE is one of: response expires-at) -(defgeneric store-claim-idempotency (store key fingerprint ttl-seconds now) +(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 store-complete-idempotency (store key fingerprint response ttl-seconds now) +(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 store-release-idempotency (store key fingerprint) +(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). diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 4b94e8b..b88cf89 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -11,7 +11,7 @@ (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) -(5am:def-test main-api-claim-complete-works () +(5am:def-test main-api-register-store-response-works () (let* ((now 100) (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine :clock (lambda () now))) @@ -23,16 +23,16 @@ :headers nil :content "ok"))) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:claim-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :started decision))) - (5am:is-true (io.github.cl-sdk.wst.idempotency:complete-idempotency engine scope key fingerprint cached)) + (5am:is-true (io.github.cl-sdk.wst.idempotency:store-response engine scope key fingerprint cached)) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:claim-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (5am:is (eq :replay decision)) (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) -(5am:def-test begin-finish-then-replay () +(5am:def-test register-store-then-replay () (let* ((now 100) (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine :clock (lambda () now))) @@ -44,17 +44,17 @@ :headers '(:content-type "text/plain") :content "ok"))) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :started decision))) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :in-progress decision))) - (5am:is-true (io.github.cl-sdk.wst.idempotency:finish-idempotency + (5am:is-true (io.github.cl-sdk.wst.idempotency:store-response engine scope key fingerprint cached)) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (5am:is (eq :replay decision)) (5am:is (= 200 (io.github.cl-sdk.wst.idempotency:cached-response-status replayed))) (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) @@ -62,32 +62,26 @@ (5am:def-test key-conflict-when-fingerprint-differs () (let ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine))) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine "scope" "same-key" "fingerprint-a") + (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "same-key" "fingerprint-a") (declare (ignore replayed)) (5am:is (eq :started decision))) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine "scope" "same-key" "fingerprint-b") + (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "same-key" "fingerprint-b") (declare (ignore replayed)) (5am:is (eq :conflict decision))))) -(5am:def-test non-cacheable-response-releases-key () - (let* ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine - :cache-response-p (lambda (response) - (< (io.github.cl-sdk.wst.idempotency:cached-response-status response) 500)))) +(5am:def-test drop-request-releases-key () + (let* ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine)) (scope "scope") (key "key") - (fingerprint "f") - (server-error (io.github.cl-sdk.wst.idempotency:make-cached-response - :status 500 - :headers nil - :content "boom"))) + (fingerprint "f")) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :started decision))) - (5am:is-true (io.github.cl-sdk.wst.idempotency:finish-idempotency - engine scope key fingerprint server-error)) + (5am:is-true (io.github.cl-sdk.wst.idempotency:drop-request + engine scope key fingerprint)) (multiple-value-bind (decision replayed) - (io.github.cl-sdk.wst.idempotency:begin-idempotency engine scope key fingerprint) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :started decision))))) From a2fd04a961ac33ec2fbc813952283324e8bdcd39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:57:08 +0000 Subject: [PATCH 19/34] Use class for idempotency engine --- idempotency/package.lisp | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 9fc091b..28a7156 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -33,19 +33,33 @@ headers content) -(defstruct (idempotency-engine - (:constructor make-idempotency-engine - (&key - (store (make-instance 'memory-store)) - (ttl-seconds 86400) - (clock #'get-universal-time) - (cache-response-p (lambda (response) - (< (cached-response-status response) 500)))))) - "Core idempotency orchestration object." - store - ttl-seconds - clock - cache-response-p) +(defclass idempotency-engine () + ((store :initarg :store + :initform (make-instance 'memory-store) + :reader idempotency-engine-store) + (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.")) + +(defun make-idempotency-engine (&key + (store (make-instance 'memory-store)) + (ttl-seconds 86400) + (clock #'get-universal-time) + (cache-response-p (lambda (response) + (< (cached-response-status response) 500)))) + (make-instance 'idempotency-engine + :store store + :ttl-seconds ttl-seconds + :clock clock + :cache-response-p cache-response-p)) (defun valid-idempotency-key-p (value) "Return T when VALUE is a non-empty key of at most 255 chars. From ac2c7e3c2372c2637d9aeada70c5e64cbe3b734c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:03:00 +0000 Subject: [PATCH 20/34] Allow idempotency engine subclasses to define storage behavior --- idempotency/package.lisp | 26 +++++++++++++++++++--- t/idempotency-tests.lisp | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 28a7156..ce36b80 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -76,9 +76,16 @@ Returns two values: - DECISION: one of :started, :replay, :in-progress, :conflict - PAYLOAD: cached-response for :replay, NIL otherwise.")) +(defmethod create-entry ((engine idempotency-engine) key fingerprint ttl-seconds now) + (create-entry (idempotency-engine-store engine) + key + fingerprint + ttl-seconds + now)) + (defmethod register-request ((engine idempotency-engine) scope key fingerprint) (multiple-value-bind (status entry) - (create-entry (idempotency-engine-store engine) + (create-entry engine (list scope key) fingerprint (idempotency-engine-ttl-seconds engine) @@ -94,8 +101,16 @@ Returns two values: Returns T when completion happened, NIL otherwise.")) -(defmethod store-response ((engine idempotency-engine) scope key fingerprint response) +(defmethod update-entry ((engine idempotency-engine) key fingerprint response ttl-seconds now) (update-entry (idempotency-engine-store engine) + key + fingerprint + response + ttl-seconds + now)) + +(defmethod store-response ((engine idempotency-engine) scope key fingerprint response) + (update-entry engine (list scope key) fingerprint response @@ -107,7 +122,12 @@ Returns T when completion happened, NIL otherwise.")) Returns T when release happened, NIL otherwise.")) -(defmethod drop-request ((engine idempotency-engine) scope key fingerprint) +(defmethod delete-entry ((engine idempotency-engine) key fingerprint) (delete-entry (idempotency-engine-store engine) + key + fingerprint)) + +(defmethod drop-request ((engine idempotency-engine) scope key fingerprint) + (delete-entry engine (list scope key) fingerprint)) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index b88cf89..b6cc331 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -5,6 +5,36 @@ (5am:in-suite wst.idempotency.suite) +(defclass test-idempotency-engine (io.github.cl-sdk.wst.idempotency:idempotency-engine) + ((calls :initform nil + :accessor test-idempotency-engine-calls))) + +(defmethod io.github.cl-sdk.wst.idempotency.store:create-entry ((engine test-idempotency-engine) + key + fingerprint + ttl-seconds + now) + (declare (ignore key fingerprint ttl-seconds now)) + (push :create (test-idempotency-engine-calls engine)) + (values :started nil)) + +(defmethod io.github.cl-sdk.wst.idempotency.store:update-entry ((engine test-idempotency-engine) + key + fingerprint + response + ttl-seconds + now) + (declare (ignore key fingerprint response ttl-seconds now)) + (push :update (test-idempotency-engine-calls engine)) + t) + +(defmethod io.github.cl-sdk.wst.idempotency.store:delete-entry ((engine test-idempotency-engine) + key + fingerprint) + (declare (ignore key fingerprint)) + (push :delete (test-idempotency-engine-calls engine)) + t) + (5am:def-test valid-idempotency-key-rejects-empty-and-too-long () (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p nil)) (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "")) @@ -85,3 +115,20 @@ (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) (declare (ignore replayed)) (5am:is (eq :started decision))))) + +(5am:def-test register-store-drop-can-be-specialized-on-engine () + (let ((engine (make-instance 'test-idempotency-engine))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "key" "fingerprint") + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:store-response + engine "scope" "key" "fingerprint" + (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers nil + :content "ok"))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:drop-request + engine "scope" "key" "fingerprint")) + (5am:is (equal '(:delete :update :create) + (test-idempotency-engine-calls engine))))) From aa3187064060262e63bd15ed95ae18e4b4e22def Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:09:41 +0000 Subject: [PATCH 21/34] Make storage slot subclass-defined in idempotency engine --- idempotency/package.lisp | 23 ++++++++++++++--------- t/idempotency-tests.lisp | 8 ++++++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index ce36b80..3f340b4 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -34,10 +34,7 @@ content) (defclass idempotency-engine () - ((store :initarg :store - :initform (make-instance 'memory-store) - :reader idempotency-engine-store) - (ttl-seconds :initarg :ttl-seconds + ((ttl-seconds :initarg :ttl-seconds :initform 86400 :reader idempotency-engine-ttl-seconds) (clock :initarg :clock @@ -47,7 +44,15 @@ :initform (lambda (response) (< (cached-response-status response) 500)) :reader idempotency-engine-cache-response-p)) - (:documentation "Core idempotency orchestration object.")) + (:documentation "Core idempotency orchestration object. + +Engine subclasses can define their own storage slots and specialize +create-entry/update-entry/delete-entry for storage behavior.")) + +(defclass memory-idempotency-engine (idempotency-engine) + ((store :initarg :store + :initform (make-instance 'memory-store) + :reader idempotency-engine-store))) (defun make-idempotency-engine (&key (store (make-instance 'memory-store)) @@ -55,7 +60,7 @@ (clock #'get-universal-time) (cache-response-p (lambda (response) (< (cached-response-status response) 500)))) - (make-instance 'idempotency-engine + (make-instance 'memory-idempotency-engine :store store :ttl-seconds ttl-seconds :clock clock @@ -76,7 +81,7 @@ Returns two values: - DECISION: one of :started, :replay, :in-progress, :conflict - PAYLOAD: cached-response for :replay, NIL otherwise.")) -(defmethod create-entry ((engine idempotency-engine) key fingerprint ttl-seconds now) +(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now) (create-entry (idempotency-engine-store engine) key fingerprint @@ -101,7 +106,7 @@ Returns two values: Returns T when completion happened, NIL otherwise.")) -(defmethod update-entry ((engine idempotency-engine) key fingerprint response ttl-seconds now) +(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now) (update-entry (idempotency-engine-store engine) key fingerprint @@ -122,7 +127,7 @@ Returns T when completion happened, NIL otherwise.")) Returns T when release happened, NIL otherwise.")) -(defmethod delete-entry ((engine idempotency-engine) key fingerprint) +(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint) (delete-entry (idempotency-engine-store engine) key fingerprint)) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index b6cc331..beadb16 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -7,7 +7,9 @@ (defclass test-idempotency-engine (io.github.cl-sdk.wst.idempotency:idempotency-engine) ((calls :initform nil - :accessor test-idempotency-engine-calls))) + :accessor test-idempotency-engine-calls) + (custom-storage :initarg :custom-storage + :reader test-idempotency-engine-custom-storage))) (defmethod io.github.cl-sdk.wst.idempotency.store:create-entry ((engine test-idempotency-engine) key @@ -117,7 +119,8 @@ (5am:is (eq :started decision))))) (5am:def-test register-store-drop-can-be-specialized-on-engine () - (let ((engine (make-instance 'test-idempotency-engine))) + (let ((engine (make-instance 'test-idempotency-engine + :custom-storage (make-hash-table)))) (multiple-value-bind (decision replayed) (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "key" "fingerprint") (declare (ignore replayed)) @@ -130,5 +133,6 @@ :content "ok"))) (5am:is-true (io.github.cl-sdk.wst.idempotency:drop-request engine "scope" "key" "fingerprint")) + (5am:is (hash-table-p (test-idempotency-engine-custom-storage engine))) (5am:is (equal '(:delete :update :create) (test-idempotency-engine-calls engine))))) From 3e7f20fa16a3bfe13864c53a3da3e29dc0276cac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:10:15 +0000 Subject: [PATCH 22/34] Clarify subclass storage method contract --- idempotency/package.lisp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 3f340b4..ce1946a 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -47,7 +47,8 @@ (:documentation "Core idempotency orchestration object. Engine subclasses can define their own storage slots and specialize -create-entry/update-entry/delete-entry for storage behavior.")) +create-entry/update-entry/delete-entry for storage behavior. +For a complete engine implementation, specialize all three methods.")) (defclass memory-idempotency-engine (idempotency-engine) ((store :initarg :store From 8b2c764d8fa30852e9e0cdce01abbeb854422efe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:10:52 +0000 Subject: [PATCH 23/34] Tighten subclass storage docs and test assertion --- idempotency/package.lisp | 4 +++- t/idempotency-tests.lisp | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index ce1946a..0363078 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -47,7 +47,9 @@ (:documentation "Core idempotency orchestration object. Engine subclasses can define their own storage slots and specialize -create-entry/update-entry/delete-entry for storage behavior. +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.")) (defclass memory-idempotency-engine (idempotency-engine) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index beadb16..ea4f846 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -119,8 +119,9 @@ (5am:is (eq :started decision))))) (5am:def-test register-store-drop-can-be-specialized-on-engine () - (let ((engine (make-instance 'test-idempotency-engine - :custom-storage (make-hash-table)))) + (let* ((storage (make-hash-table)) + (engine (make-instance 'test-idempotency-engine + :custom-storage storage))) (multiple-value-bind (decision replayed) (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "key" "fingerprint") (declare (ignore replayed)) @@ -134,5 +135,6 @@ (5am:is-true (io.github.cl-sdk.wst.idempotency:drop-request engine "scope" "key" "fingerprint")) (5am:is (hash-table-p (test-idempotency-engine-custom-storage engine))) + (5am:is (eq storage (test-idempotency-engine-custom-storage engine))) (5am:is (equal '(:delete :update :create) (test-idempotency-engine-calls engine))))) From a2e852c0685ec5a7d0094fa3d5426e7fd92ceeb1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:17:48 +0000 Subject: [PATCH 24/34] Move memory store implementation out of idempotency package --- idempotency/memory-store.lisp | 52 +++++++++++++++++-- idempotency/package.lisp | 34 ++---------- io.github.cl-sdk.wst.idempotency.asd | 3 +- ...ub.cl-sdk.wst.idempotency.memory-store.asd | 3 +- t/idempotency-tests.lisp | 8 +-- 5 files changed, 59 insertions(+), 41 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index 95782c8..f2726f4 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -1,10 +1,15 @@ (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 + #:idempotency-engine-store) (:documentation "In-memory idempotency backend. Suitable for single-process scenarios; not thread-safe.") (:export - #:memory-store)) + #:memory-store + #:memory-idempotency-engine + #:make-memory-idempotency-engine)) (in-package #:io.github.cl-sdk.wst.idempotency.memory-store) @@ -14,6 +19,23 @@ Suitable for single-process scenarios; not thread-safe.") (:documentation "Hash-table-backed idempotency store. Not thread-safe.")) +(defclass memory-idempotency-engine (idempotency-engine) + ((store :initarg :store + :initform (make-instance 'memory-store) + :reader idempotency-engine-store))) + +(defun make-memory-idempotency-engine (&key + (store (make-instance 'memory-store)) + (ttl-seconds 86400) + (clock #'get-universal-time) + (cache-response-p (lambda (response) + (< (io.github.cl-sdk.wst.idempotency:cached-response-status response) 500)))) + (make-instance 'memory-idempotency-engine + :store store + :ttl-seconds ttl-seconds + :clock clock + :cache-response-p cache-response-p)) + (defun %expired-p (entry now) (and entry (idempotency-entry-expires-at entry) @@ -41,6 +63,13 @@ Not thread-safe.")) (t (values :in-progress entry))))) +(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now) + (create-entry (idempotency-engine-store engine) + key + fingerprint + ttl-seconds + now)) + (defmethod update-entry ((store memory-store) key fingerprint response ttl-seconds now) (let* ((table (memory-store-table store)) (entry (gethash key table))) @@ -50,10 +79,18 @@ Not thread-safe.")) (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))) + (setf (idempotency-entry-state entry) :completed + (idempotency-entry-response entry) response + (idempotency-entry-expires-at entry) (+ now ttl-seconds)) + t))) + +(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now) + (update-entry (idempotency-engine-store engine) + key + fingerprint + response + ttl-seconds + now)) (defmethod delete-entry ((store memory-store) key fingerprint) (let* ((table (memory-store-table store)) @@ -63,3 +100,8 @@ Not thread-safe.")) (string= fingerprint (idempotency-entry-fingerprint entry))) (remhash key table) t))) + +(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint) + (delete-entry (idempotency-engine-store engine) + key + fingerprint)) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 0363078..e872443 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -6,8 +6,6 @@ #:update-entry #:delete-entry #:idempotency-entry-response) - (:import-from #:io.github.cl-sdk.wst.idempotency.memory-store - #:memory-store) (:export #:cached-response #:make-cached-response @@ -52,19 +50,17 @@ 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.")) -(defclass memory-idempotency-engine (idempotency-engine) - ((store :initarg :store - :initform (make-instance 'memory-store) - :reader idempotency-engine-store))) +(defgeneric idempotency-engine-store (engine) + (:documentation "Return the storage object owned by ENGINE. + +Subclasses may define this accessor when they keep a concrete store slot.")) (defun make-idempotency-engine (&key - (store (make-instance 'memory-store)) (ttl-seconds 86400) (clock #'get-universal-time) (cache-response-p (lambda (response) (< (cached-response-status response) 500)))) - (make-instance 'memory-idempotency-engine - :store store + (make-instance 'idempotency-engine :ttl-seconds ttl-seconds :clock clock :cache-response-p cache-response-p)) @@ -84,13 +80,6 @@ Returns two values: - DECISION: one of :started, :replay, :in-progress, :conflict - PAYLOAD: cached-response for :replay, NIL otherwise.")) -(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now) - (create-entry (idempotency-engine-store engine) - key - fingerprint - ttl-seconds - now)) - (defmethod register-request ((engine idempotency-engine) scope key fingerprint) (multiple-value-bind (status entry) (create-entry engine @@ -109,14 +98,6 @@ Returns two values: Returns T when completion happened, NIL otherwise.")) -(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now) - (update-entry (idempotency-engine-store engine) - key - fingerprint - response - ttl-seconds - now)) - (defmethod store-response ((engine idempotency-engine) scope key fingerprint response) (update-entry engine (list scope key) @@ -130,11 +111,6 @@ Returns T when completion happened, NIL otherwise.")) Returns T when release happened, NIL otherwise.")) -(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint) - (delete-entry (idempotency-engine-store engine) - key - fingerprint)) - (defmethod drop-request ((engine idempotency-engine) scope key fingerprint) (delete-entry engine (list scope key) diff --git a/io.github.cl-sdk.wst.idempotency.asd b/io.github.cl-sdk.wst.idempotency.asd index 7cfad1f..0a2ca7e 100644 --- a/io.github.cl-sdk.wst.idempotency.asd +++ b/io.github.cl-sdk.wst.idempotency.asd @@ -3,8 +3,7 @@ :author "Bruno Dias" :license "Unlicense" :version "0.1.0" - :depends-on (#:io.github.cl-sdk.wst.idempotency.store - #:io.github.cl-sdk.wst.idempotency.memory-store) + :depends-on (#:io.github.cl-sdk.wst.idempotency.store) :pathname "idempotency" :serial t :components ((:file "package"))) diff --git a/io.github.cl-sdk.wst.idempotency.memory-store.asd b/io.github.cl-sdk.wst.idempotency.memory-store.asd index 197c411..e19a05e 100644 --- a/io.github.cl-sdk.wst.idempotency.memory-store.asd +++ b/io.github.cl-sdk.wst.idempotency.memory-store.asd @@ -3,7 +3,8 @@ :author "Bruno Dias" :license "Unlicense" :version "0.1.0" - :depends-on (#:io.github.cl-sdk.wst.idempotency.store) + :depends-on (#:io.github.cl-sdk.wst.idempotency.store + #:io.github.cl-sdk.wst.idempotency) :pathname "idempotency" :serial t :components ((:file "memory-store"))) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index ea4f846..4beee91 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -45,7 +45,7 @@ (5am:def-test main-api-register-store-response-works () (let* ((now 100) - (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine + (engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine :clock (lambda () now))) (scope "checkout") (key "k-main") @@ -66,7 +66,7 @@ (5am:def-test register-store-then-replay () (let* ((now 100) - (engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine + (engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine :clock (lambda () now))) (scope "checkout") (key "k1") @@ -92,7 +92,7 @@ (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) (5am:def-test key-conflict-when-fingerprint-differs () - (let ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine))) + (let ((engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine))) (multiple-value-bind (decision replayed) (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "same-key" "fingerprint-a") (declare (ignore replayed)) @@ -103,7 +103,7 @@ (5am:is (eq :conflict decision))))) (5am:def-test drop-request-releases-key () - (let* ((engine (io.github.cl-sdk.wst.idempotency:make-idempotency-engine)) + (let* ((engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine)) (scope "scope") (key "key") (fingerprint "f")) From fb7ad772d2d8d8f5560c65ad64fbfd6d289a8df3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:18:25 +0000 Subject: [PATCH 25/34] Make core engine constructor explicitly abstract --- idempotency/package.lisp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index e872443..f86ea73 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -53,17 +53,16 @@ For a complete engine implementation, specialize all three methods.")) (defgeneric idempotency-engine-store (engine) (:documentation "Return the storage object owned by ENGINE. -Subclasses may define this accessor when they keep a concrete store slot.")) +Concrete engine implementations that delegate to a store object should +define this accessor.")) (defun make-idempotency-engine (&key (ttl-seconds 86400) (clock #'get-universal-time) (cache-response-p (lambda (response) (< (cached-response-status response) 500)))) - (make-instance 'idempotency-engine - :ttl-seconds ttl-seconds - :clock clock - :cache-response-p cache-response-p)) + (declare (ignore ttl-seconds clock cache-response-p)) + (error "No default store-backed idempotency engine is provided in io.github.cl-sdk.wst.idempotency. Use a concrete engine implementation such as io.github.cl-sdk.wst.idempotency.memory-store:make-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. From 7b830ab61bac3e06ded37ef43e45a1c2b46c1792 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:19:06 +0000 Subject: [PATCH 26/34] Clean up abstract engine constructor and formatting --- idempotency/memory-store.lisp | 8 ++++---- idempotency/package.lisp | 7 +------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index f2726f4..e7a494c 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -79,10 +79,10 @@ Not thread-safe.")) (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))) + (setf (idempotency-entry-state entry) :completed + (idempotency-entry-response entry) response + (idempotency-entry-expires-at entry) (+ now ttl-seconds)) + t))) (defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now) (update-entry (idempotency-engine-store engine) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index f86ea73..52714cb 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -56,12 +56,7 @@ For a complete engine implementation, specialize all three methods.")) Concrete engine implementations that delegate to a store object should define this accessor.")) -(defun make-idempotency-engine (&key - (ttl-seconds 86400) - (clock #'get-universal-time) - (cache-response-p (lambda (response) - (< (cached-response-status response) 500)))) - (declare (ignore ttl-seconds clock cache-response-p)) +(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 io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine or your own subclass.")) (defun valid-idempotency-key-p (value) From b4623e406f108a6f6e2f0919428081a733fcc7b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:22:50 +0000 Subject: [PATCH 27/34] Group idempotency generics before methods --- idempotency/package.lisp | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 52714cb..76e8de8 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -56,6 +56,23 @@ For a complete engine implementation, specialize all three methods.")) Concrete engine implementations that delegate to a store object should define this accessor.")) +(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 io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine or your own subclass.")) @@ -67,13 +84,6 @@ Key formatting/normalization is caller-managed." (not (string= "" value)) (<= (length value) 255))) -(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.")) - (defmethod register-request ((engine idempotency-engine) scope key fingerprint) (multiple-value-bind (status entry) (create-entry engine @@ -87,11 +97,6 @@ Returns two values: (:conflict (values :conflict nil)) (:replay (values :replay (idempotency-entry-response entry)))))) -(defgeneric store-response (engine scope key fingerprint response) - (:documentation "Persist RESPONSE for SCOPE/KEY/FINGERPRINT. - -Returns T when completion happened, NIL otherwise.")) - (defmethod store-response ((engine idempotency-engine) scope key fingerprint response) (update-entry engine (list scope key) @@ -100,11 +105,6 @@ Returns T when completion happened, NIL otherwise.")) (idempotency-engine-ttl-seconds engine) (funcall (idempotency-engine-clock engine)))) -(defgeneric drop-request (engine scope key fingerprint) - (:documentation "Release processing lock for SCOPE/KEY/FINGERPRINT. - -Returns T when release happened, NIL otherwise.")) - (defmethod drop-request ((engine idempotency-engine) scope key fingerprint) (delete-entry engine (list scope key) From be52a734d5b6ea79182b6e1e61d7823cbb13fdda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:27:42 +0000 Subject: [PATCH 28/34] Inline storage into memory idempotency engine --- idempotency/memory-store.lisp | 54 +++++++++-------------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index e7a494c..56f3bd4 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -1,37 +1,31 @@ (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 - #:idempotency-engine-store) + #:idempotency-engine) (:documentation "In-memory idempotency backend. Suitable for single-process scenarios; not thread-safe.") (:export - #:memory-store #:memory-idempotency-engine #:make-memory-idempotency-engine)) (in-package #:io.github.cl-sdk.wst.idempotency.memory-store) -(defclass memory-store () - ((table :initform (make-hash-table :test #'equal) - :reader memory-store-table)) - (:documentation "Hash-table-backed idempotency store. -Not thread-safe.")) - (defclass memory-idempotency-engine (idempotency-engine) - ((store :initarg :store - :initform (make-instance 'memory-store) - :reader idempotency-engine-store))) + ((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.")) (defun make-memory-idempotency-engine (&key - (store (make-instance 'memory-store)) + (table (make-hash-table :test #'equal)) (ttl-seconds 86400) (clock #'get-universal-time) (cache-response-p (lambda (response) (< (io.github.cl-sdk.wst.idempotency:cached-response-status response) 500)))) (make-instance 'memory-idempotency-engine - :store store + :table table :ttl-seconds ttl-seconds :clock clock :cache-response-p cache-response-p)) @@ -41,8 +35,8 @@ Not thread-safe.")) (idempotency-entry-expires-at entry) (>= now (idempotency-entry-expires-at entry)))) -(defmethod create-entry ((store memory-store) key fingerprint ttl-seconds now) - (let* ((table (memory-store-table store)) +(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) @@ -63,15 +57,8 @@ Not thread-safe.")) (t (values :in-progress entry))))) -(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now) - (create-entry (idempotency-engine-store engine) - key - fingerprint - ttl-seconds - now)) - -(defmethod update-entry ((store memory-store) key fingerprint response ttl-seconds now) - (let* ((table (memory-store-table store)) +(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) @@ -84,24 +71,11 @@ Not thread-safe.")) (idempotency-entry-expires-at entry) (+ now ttl-seconds)) t))) -(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now) - (update-entry (idempotency-engine-store engine) - key - fingerprint - response - ttl-seconds - now)) - -(defmethod delete-entry ((store memory-store) key fingerprint) - (let* ((table (memory-store-table store)) +(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))) - -(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint) - (delete-entry (idempotency-engine-store engine) - key - fingerprint)) From d1868e51e26494c94b1fe820cedaf4c71276cdaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:38:25 +0000 Subject: [PATCH 29/34] Remove redundant memory engine constructor --- idempotency/memory-store.lisp | 15 +-------------- idempotency/package.lisp | 2 +- t/idempotency-tests.lisp | 12 ++++++------ 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index 56f3bd4..cc0c464 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -6,8 +6,7 @@ Suitable for single-process scenarios; not thread-safe.") (:export - #:memory-idempotency-engine - #:make-memory-idempotency-engine)) + #:memory-idempotency-engine)) (in-package #:io.github.cl-sdk.wst.idempotency.memory-store) @@ -18,18 +17,6 @@ Suitable for single-process scenarios; not thread-safe.") (:documentation "Hash-table-backed in-memory idempotency engine. Not thread-safe.")) -(defun make-memory-idempotency-engine (&key - (table (make-hash-table :test #'equal)) - (ttl-seconds 86400) - (clock #'get-universal-time) - (cache-response-p (lambda (response) - (< (io.github.cl-sdk.wst.idempotency:cached-response-status response) 500)))) - (make-instance 'memory-idempotency-engine - :table table - :ttl-seconds ttl-seconds - :clock clock - :cache-response-p cache-response-p)) - (defun %expired-p (entry now) (and entry (idempotency-entry-expires-at entry) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 76e8de8..0887aec 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -74,7 +74,7 @@ Returns T when completion happened, NIL otherwise.")) 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 io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine or your own subclass.")) + (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. diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp index 4beee91..c733168 100644 --- a/t/idempotency-tests.lisp +++ b/t/idempotency-tests.lisp @@ -45,8 +45,8 @@ (5am:def-test main-api-register-store-response-works () (let* ((now 100) - (engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine - :clock (lambda () now))) + (engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine + :clock (lambda () now))) (scope "checkout") (key "k-main") (fingerprint "f-main") @@ -66,8 +66,8 @@ (5am:def-test register-store-then-replay () (let* ((now 100) - (engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine - :clock (lambda () now))) + (engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine + :clock (lambda () now))) (scope "checkout") (key "k1") (fingerprint "f1") @@ -92,7 +92,7 @@ (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) (5am:def-test key-conflict-when-fingerprint-differs () - (let ((engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine))) + (let ((engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine))) (multiple-value-bind (decision replayed) (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "same-key" "fingerprint-a") (declare (ignore replayed)) @@ -103,7 +103,7 @@ (5am:is (eq :conflict decision))))) (5am:def-test drop-request-releases-key () - (let* ((engine (io.github.cl-sdk.wst.idempotency.memory-store:make-memory-idempotency-engine)) + (let* ((engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine)) (scope "scope") (key "key") (fingerprint "f")) From cd7dcf245d876b6788a3a184c57ef16fe7a2b857 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:38:54 +0000 Subject: [PATCH 30/34] Wrap abstract engine error message lines --- idempotency/package.lisp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 0887aec..267fa9a 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -74,7 +74,12 @@ Returns T when completion happened, NIL otherwise.")) 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.")) + (error (concatenate 'string + "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. From 374e790747613ef5c4de35d95462967447db48d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:39:25 +0000 Subject: [PATCH 31/34] Document memory engine instantiation pattern --- idempotency/memory-store.lisp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index cc0c464..5630258 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -15,7 +15,11 @@ Suitable for single-process scenarios; not thread-safe.") :initform (make-hash-table :test #'equal) :reader memory-idempotency-engine-table)) (:documentation "Hash-table-backed in-memory idempotency engine. -Not thread-safe.")) +Not thread-safe. + +Instantiate with MAKE-INSTANCE and optional initargs: +- :table (subclass slot) +- :ttl-seconds, :clock, :cache-response-p (inherited from idempotency-engine).")) (defun %expired-p (entry now) (and entry From 681c4f0fc372057d1fd743ee916725b792c25ee5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:40:17 +0000 Subject: [PATCH 32/34] Refine idempotency messaging docs --- idempotency/memory-store.lisp | 2 +- idempotency/package.lisp | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index 5630258..fe4e2d7 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -18,7 +18,7 @@ Suitable for single-process scenarios; not thread-safe.") Not thread-safe. Instantiate with MAKE-INSTANCE and optional initargs: -- :table (subclass slot) +- :table (defined on memory-idempotency-engine) - :ttl-seconds, :clock, :cache-response-p (inherited from idempotency-engine).")) (defun %expired-p (entry now) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 267fa9a..ab803c3 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -74,12 +74,8 @@ Returns T when completion happened, NIL otherwise.")) Returns T when release happened, NIL otherwise.")) (defun make-idempotency-engine () - (error (concatenate 'string - "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."))) + (error "~A" + "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. From 22af3e151ad98320012750ac3f8f9cbbc70d7ef7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:40:45 +0000 Subject: [PATCH 33/34] Polish idempotency constructor guidance --- idempotency/memory-store.lisp | 2 +- idempotency/package.lisp | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp index fe4e2d7..4fedab1 100644 --- a/idempotency/memory-store.lisp +++ b/idempotency/memory-store.lisp @@ -18,7 +18,7 @@ Suitable for single-process scenarios; not thread-safe.") Not thread-safe. Instantiate with MAKE-INSTANCE and optional initargs: -- :table (defined on memory-idempotency-engine) +- :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) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index ab803c3..0887aec 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -74,8 +74,7 @@ Returns T when completion happened, NIL otherwise.")) Returns T when release happened, NIL otherwise.")) (defun make-idempotency-engine () - (error "~A" - "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.")) + (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. From 59bfceb81acc79ee5cea853cff45ee4c8841611d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 12:45:33 +0000 Subject: [PATCH 34/34] Remove obsolete idempotency-engine-store generic --- idempotency/package.lisp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/idempotency/package.lisp b/idempotency/package.lisp index 0887aec..439e49b 100644 --- a/idempotency/package.lisp +++ b/idempotency/package.lisp @@ -14,7 +14,6 @@ #:cached-response-content #:idempotency-engine #:make-idempotency-engine - #:idempotency-engine-store #:idempotency-engine-ttl-seconds #:idempotency-engine-clock #:idempotency-engine-cache-response-p @@ -50,12 +49,6 @@ 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 idempotency-engine-store (engine) - (:documentation "Return the storage object owned by ENGINE. - -Concrete engine implementations that delegate to a store object should -define this accessor.")) - (defgeneric register-request (engine scope key fingerprint) (:documentation "Register lifecycle processing ownership for SCOPE/KEY/FINGERPRINT.