diff --git a/cover.html b/cover.html
new file mode 100644
index 0000000..e6df120
--- /dev/null
+++ b/cover.html
@@ -0,0 +1,422 @@
+
+
+
+
+
+
+
package potency
+
+import (
+ "crypto/sha256"
+ "hash"
+ "io"
+)
+
+type bodyIntercept struct {
+ source io.ReadCloser
+ sha256 hash.Hash
+}
+
+func newBodyIntercept(source io.ReadCloser) *bodyIntercept {
+ return &bodyIntercept{
+ source: source,
+ sha256: sha256.New(),
+ }
+}
+
+func (bi *bodyIntercept) Read(p []byte) (int, error) {
+ numBytes, err := bi.source.Read(p)
+ bi.sha256.Write(p[:numBytes])
+
+ return numBytes, err
+}
+
+func (bi *bodyIntercept) Close() error {
+ return bi.source.Close()
+}
+
+
+
package potency
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gopatchy/jsrest"
+)
+
+type Potency struct {
+ handler http.Handler
+
+ lifetime time.Duration
+
+ cache map[string]*savedResult
+ cacheOldest *savedResult
+ cacheNewest *savedResult
+ cacheMu sync.RWMutex
+
+ inProgress map[string]bool
+ inProgressMu sync.Mutex
+}
+
+type savedResult struct {
+ key string
+
+ method string
+ url string
+ requestHeader http.Header
+ sha256 []byte
+
+ statusCode int
+ responseHeader http.Header
+ responseBody []byte
+
+ added time.Time
+ newer *savedResult
+}
+
+var (
+ ErrConflict = errors.New("conflict")
+ ErrMismatch = errors.New("idempotency mismatch")
+ ErrBodyMismatch = fmt.Errorf("request body mismatch: %w", ErrMismatch)
+ ErrMethodMismatch = fmt.Errorf("HTTP method mismatch: %w", ErrMismatch)
+ ErrURLMismatch = fmt.Errorf("URL mismatch: %w", ErrMismatch)
+ ErrHeaderMismatch = fmt.Errorf("Header mismatch: %w", ErrMismatch)
+ ErrInvalidKey = errors.New("invalid Idempotency-Key")
+
+ criticalHeaders = []string{
+ "Accept",
+ "Authorization",
+ }
+)
+
+func NewPotency(handler http.Handler) *Potency {
+ return &Potency{
+ handler: handler,
+ lifetime: 6 * time.Hour,
+ cache: map[string]*savedResult{},
+ inProgress: map[string]bool{},
+ }
+}
+
+func (p *Potency) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ val := r.Header.Get("Idempotency-Key")
+ if val == "" {
+ p.handler.ServeHTTP(w, r)
+ return
+ }
+
+ err := p.serveHTTP(w, r, val)
+ if err != nil {
+ jsrest.WriteError(w, err)
+ }
+}
+
+func (p *Potency) SetLifetime(lifetime time.Duration) {
+ p.cacheMu.Lock()
+ defer p.cacheMu.Unlock()
+
+ p.lifetime = lifetime
+}
+
+func (p *Potency) NumCached() int {
+ p.cacheMu.RLock()
+ defer p.cacheMu.RUnlock()
+
+ return len(p.cache)
+}
+
+func (p *Potency) serveHTTP(w http.ResponseWriter, r *http.Request, val string) error {
+ if len(val) < 2 || !strings.HasPrefix(val, `"`) || !strings.HasSuffix(val, `"`) {
+ return jsrest.Errorf(jsrest.ErrBadRequest, "%s (%w)", val, ErrInvalidKey)
+ }
+
+ key := val[1 : len(val)-1]
+
+ saved := p.read(key)
+
+ if saved != nil {
+ if r.Method != saved.method {
+ return jsrest.Errorf(jsrest.ErrBadRequest, "%s (%w)", r.Method, ErrMethodMismatch)
+ }
+
+ if r.URL.String() != saved.url {
+ return jsrest.Errorf(jsrest.ErrBadRequest, "%s (%w)", r.URL.String(), ErrURLMismatch)
+ }
+
+ for _, h := range criticalHeaders {
+ if saved.requestHeader.Get(h) != r.Header.Get(h) {
+ return jsrest.Errorf(jsrest.ErrBadRequest, "%s: %s (%w)", h, r.Header.Get(h), ErrHeaderMismatch)
+ }
+ }
+
+ h := sha256.New()
+
+ _, err := io.Copy(h, r.Body)
+ if err != nil {
+ return jsrest.Errorf(jsrest.ErrBadRequest, "hash request body failed (%w)", err)
+ }
+
+ sha256 := h.Sum(nil)
+ if !bytes.Equal(sha256, saved.sha256) {
+ return jsrest.Errorf(jsrest.ErrBadRequest, "%s vs %s (%w)", sha256, saved.sha256, ErrBodyMismatch)
+ }
+
+ for key, vals := range saved.responseHeader {
+ w.Header().Set(key, vals[0])
+ }
+
+ w.WriteHeader(saved.statusCode)
+ _, _ = w.Write(saved.responseBody)
+
+ return nil
+ }
+
+ // Store miss, proceed to normal execution with interception
+ err := p.lockKey(key)
+ if err != nil {
+ return jsrest.Errorf(jsrest.ErrConflict, "%s", key)
+ }
+
+ defer p.unlockKey(key)
+
+ requestHeader := http.Header{}
+ for _, h := range criticalHeaders {
+ requestHeader.Set(h, r.Header.Get(h))
+ }
+
+ bi := newBodyIntercept(r.Body)
+ r.Body = bi
+
+ rwi := newResponseWriterIntercept(w)
+ w = rwi
+
+ p.handler.ServeHTTP(w, r)
+
+ save := &savedResult{
+ key: key,
+
+ method: r.Method,
+ url: r.URL.String(),
+ requestHeader: requestHeader,
+ sha256: bi.sha256.Sum(nil),
+
+ statusCode: rwi.statusCode,
+ responseHeader: rwi.Header(),
+ responseBody: rwi.buf.Bytes(),
+ }
+
+ p.write(save)
+
+ return nil
+}
+
+func (p *Potency) lockKey(key string) error {
+ p.inProgressMu.Lock()
+ defer p.inProgressMu.Unlock()
+
+ if p.inProgress[key] {
+ return ErrConflict
+ }
+
+ p.inProgress[key] = true
+
+ return nil
+}
+
+func (p *Potency) unlockKey(key string) {
+ p.inProgressMu.Lock()
+ defer p.inProgressMu.Unlock()
+
+ delete(p.inProgress, key)
+}
+
+func (p *Potency) read(key string) *savedResult {
+ p.cacheMu.RLock()
+ defer p.cacheMu.RUnlock()
+
+ return p.cache[key]
+}
+
+func (p *Potency) write(sr *savedResult) {
+ p.cacheMu.Lock()
+ defer p.cacheMu.Unlock()
+
+ sr.added = time.Now()
+
+ p.cache[sr.key] = sr
+
+ if p.cacheNewest != nil {
+ p.cacheNewest.newer = sr
+ }
+
+ p.cacheNewest = sr
+
+ if p.cacheOldest == nil {
+ p.cacheOldest = sr
+ }
+
+ p.removeExpired()
+}
+
+func (p *Potency) removeExpired() {
+ cutoff := time.Now().Add(-1 * p.lifetime)
+
+ for iter := p.cacheOldest; iter != nil && iter.added.Before(cutoff); iter = iter.newer {
+ delete(p.cache, iter.key)
+ p.cacheOldest = iter
+ }
+}
+
+
+
package potency
+
+import (
+ "bytes"
+ "net/http"
+)
+
+type responseWriterIntercept struct {
+ dest http.ResponseWriter
+ buf bytes.Buffer
+ statusCode int
+}
+
+func newResponseWriterIntercept(dest http.ResponseWriter) *responseWriterIntercept {
+ return &responseWriterIntercept{
+ dest: dest,
+ buf: bytes.Buffer{},
+ statusCode: http.StatusOK,
+ }
+}
+
+func (rwi *responseWriterIntercept) Header() http.Header {
+ return rwi.dest.Header()
+}
+
+func (rwi *responseWriterIntercept) Write(data []byte) (int, error) {
+ rwi.buf.Write(data)
+ return rwi.dest.Write(data)
+}
+
+func (rwi *responseWriterIntercept) WriteHeader(statusCode int) {
+ rwi.statusCode = statusCode
+ rwi.dest.WriteHeader(statusCode)
+}
+
+
+
+
+
+
diff --git a/cover.out b/cover.out
new file mode 100644
index 0000000..59502da
--- /dev/null
+++ b/cover.out
@@ -0,0 +1,49 @@
+mode: atomic
+github.com/gopatchy/potency/bodyintercept.go:14.60,19.2 1 6
+github.com/gopatchy/potency/bodyintercept.go:21.54,26.2 3 6
+github.com/gopatchy/potency/bodyintercept.go:28.40,30.2 1 0
+github.com/gopatchy/potency/potency.go:62.48,69.2 1 3
+github.com/gopatchy/potency/potency.go:71.69,73.15 2 14
+github.com/gopatchy/potency/potency.go:73.15,76.3 2 0
+github.com/gopatchy/potency/potency.go:78.2,79.16 2 14
+github.com/gopatchy/potency/potency.go:79.16,81.3 1 5
+github.com/gopatchy/potency/potency.go:84.55,89.2 3 1
+github.com/gopatchy/potency/potency.go:91.35,96.2 3 4
+github.com/gopatchy/potency/potency.go:98.87,99.82 1 14
+github.com/gopatchy/potency/potency.go:99.82,101.3 1 0
+github.com/gopatchy/potency/potency.go:103.2,107.18 3 14
+github.com/gopatchy/potency/potency.go:107.18,108.31 1 8
+github.com/gopatchy/potency/potency.go:108.31,110.4 1 1
+github.com/gopatchy/potency/potency.go:112.3,112.34 1 7
+github.com/gopatchy/potency/potency.go:112.34,114.4 1 1
+github.com/gopatchy/potency/potency.go:116.3,116.37 1 6
+github.com/gopatchy/potency/potency.go:116.37,117.53 1 11
+github.com/gopatchy/potency/potency.go:117.53,119.5 1 2
+github.com/gopatchy/potency/potency.go:122.3,125.17 3 4
+github.com/gopatchy/potency/potency.go:125.17,127.4 1 0
+github.com/gopatchy/potency/potency.go:129.3,130.41 2 4
+github.com/gopatchy/potency/potency.go:130.41,132.4 1 1
+github.com/gopatchy/potency/potency.go:134.3,134.47 1 3
+github.com/gopatchy/potency/potency.go:134.47,136.4 1 3
+github.com/gopatchy/potency/potency.go:138.3,141.13 3 3
+github.com/gopatchy/potency/potency.go:145.2,146.16 2 6
+github.com/gopatchy/potency/potency.go:146.16,148.3 1 0
+github.com/gopatchy/potency/potency.go:150.2,153.36 3 6
+github.com/gopatchy/potency/potency.go:153.36,155.3 1 12
+github.com/gopatchy/potency/potency.go:157.2,180.12 8 6
+github.com/gopatchy/potency/potency.go:183.45,187.23 3 6
+github.com/gopatchy/potency/potency.go:187.23,189.3 1 0
+github.com/gopatchy/potency/potency.go:191.2,193.12 2 6
+github.com/gopatchy/potency/potency.go:196.41,201.2 3 6
+github.com/gopatchy/potency/potency.go:203.49,208.2 3 14
+github.com/gopatchy/potency/potency.go:210.42,218.26 5 6
+github.com/gopatchy/potency/potency.go:218.26,220.3 1 3
+github.com/gopatchy/potency/potency.go:222.2,224.26 2 6
+github.com/gopatchy/potency/potency.go:224.26,226.3 1 3
+github.com/gopatchy/potency/potency.go:228.2,228.19 1 6
+github.com/gopatchy/potency/potency.go:231.35,234.89 2 6
+github.com/gopatchy/potency/potency.go:234.89,237.3 2 2
+github.com/gopatchy/potency/responsewriterintercept.go:14.84,20.2 1 6
+github.com/gopatchy/potency/responsewriterintercept.go:22.58,24.2 1 12
+github.com/gopatchy/potency/responsewriterintercept.go:26.69,29.2 2 6
+github.com/gopatchy/potency/responsewriterintercept.go:31.65,34.2 2 0