• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

alexferl / zerohttp / 23093061092

14 Mar 2026 05:45PM UTC coverage: 93.164% (+0.5%) from 92.697%
23093061092

push

github

web-flow
feat: add AcceptsJSON and RenderAuto for content negotiation (#105)

* feat: add AcceptsJSON and RenderAuto for content negotiation

- Add AcceptsJSON function to detect JSON-capable clients
- Add RenderAuto method that returns JSON or plain text based on Accept header
- Add tests for both AcceptsJSON and RenderAuto
- Update middleware to use RenderAuto instead of Render

Signed-off-by: alexferl <me@alexferl.com>

* increase coverage

Signed-off-by: alexferl <me@alexferl.com>

---------

Signed-off-by: alexferl <me@alexferl.com>

64 of 67 new or added lines in 17 files covered. (95.52%)

7 existing lines in 2 files now uncovered.

8899 of 9552 relevant lines covered (93.16%)

76.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.71
/middleware/idempotency.go
1
package middleware
2

3
import (
4
        "bytes"
5
        "crypto/sha256"
6
        "encoding/hex"
7
        "io"
8
        "math"
9
        "math/rand"
10
        "net/http"
11
        "time"
12

13
        "github.com/alexferl/zerohttp/config"
14
        zconfig "github.com/alexferl/zerohttp/internal/config"
15
        "github.com/alexferl/zerohttp/internal/problem"
16
        "github.com/alexferl/zerohttp/internal/rwutil"
17
        "github.com/alexferl/zerohttp/log"
18
)
19

20
// Idempotency creates middleware for idempotent request handling.
21
// It caches responses for state-changing operations and replays them for identical requests.
22
func Idempotency(cfg ...config.IdempotencyConfig) func(http.Handler) http.Handler {
26✔
23
        c := config.DefaultIdempotencyConfig
26✔
24
        if len(cfg) > 0 {
52✔
25
                zconfig.Merge(&c, cfg[0])
26✔
26
        }
26✔
27

28
        var store config.IdempotencyStore
26✔
29
        if c.Store != nil {
31✔
30
                store = c.Store
5✔
31
        } else {
26✔
32
                store = NewIdempotencyMemoryStore(c.MaxKeys)
21✔
33
        }
21✔
34

35
        stateChangingMethods := map[string]bool{
26✔
36
                http.MethodPost:   true,
26✔
37
                http.MethodPut:    true,
26✔
38
                http.MethodPatch:  true,
26✔
39
                http.MethodDelete: true,
26✔
40
        }
26✔
41

26✔
42
        return func(next http.Handler) http.Handler {
73✔
43
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94✔
44
                        if !stateChangingMethods[r.Method] {
49✔
45
                                next.ServeHTTP(w, r)
2✔
46
                                return
2✔
47
                        }
2✔
48

49
                        for _, exemptPath := range c.ExemptPaths {
47✔
50
                                if pathMatches(r.URL.Path, exemptPath) {
4✔
51
                                        next.ServeHTTP(w, r)
2✔
52
                                        return
2✔
53
                                }
2✔
54
                        }
55

56
                        idempotencyKey := r.Header.Get(c.HeaderName)
43✔
57

43✔
58
                        if c.Required && idempotencyKey == "" {
46✔
59
                                detail := problem.NewDetail(http.StatusBadRequest, "Idempotency-Key header is required")
3✔
60
                                _ = detail.RenderAuto(w, r)
3✔
61
                                return
3✔
62
                        }
3✔
63

64
                        if idempotencyKey == "" {
40✔
65
                                next.ServeHTTP(w, r)
×
66
                                return
×
67
                        }
×
68

69
                        body, err := io.ReadAll(io.LimitReader(r.Body, c.MaxBodySize+1))
40✔
70
                        if err != nil {
40✔
71
                                log.GetGlobalLogger().Error("Failed to read request body for idempotency", log.E(err))
×
72
                                detail := problem.NewDetail(http.StatusInternalServerError, "Failed to read request body")
×
NEW
73
                                _ = detail.RenderAuto(w, r)
×
74
                                return
×
75
                        }
×
76
                        _ = r.Body.Close()
40✔
77

40✔
78
                        if int64(len(body)) > c.MaxBodySize {
42✔
79
                                r.Body = io.NopCloser(bytes.NewReader(body))
2✔
80
                                next.ServeHTTP(w, r)
2✔
81
                                return
2✔
82
                        }
2✔
83

84
                        r.Body = io.NopCloser(bytes.NewReader(body))
38✔
85

38✔
86
                        bodyHash := sha256.Sum256(body)
38✔
87
                        cacheKey := idempotencyKey + ":" + r.Method + ":" + r.URL.Path + ":" + hex.EncodeToString(bodyHash[:])
38✔
88

38✔
89
                        record, found, err := store.Get(r.Context(), cacheKey)
38✔
90
                        if err != nil {
39✔
91
                                // Log error and continue (fail open)
1✔
92
                                log.GetGlobalLogger().Error("Idempotency store get failed", log.E(err), log.F("key", cacheKey))
1✔
93
                        } else if found {
47✔
94
                                // Replay headers from cached record, avoiding duplicates from other middleware
9✔
95
                                replayHeaders(w, record.Headers)
9✔
96
                                w.Header().Set("X-Idempotency-Replay", "true")
9✔
97
                                w.WriteHeader(record.StatusCode)
9✔
98
                                _, _ = w.Write(record.Body)
9✔
99
                                return
9✔
100
                        }
9✔
101

102
                        locked, err := store.Lock(r.Context(), cacheKey)
29✔
103
                        if err != nil {
30✔
104
                                log.GetGlobalLogger().Error("Idempotency store lock failed", log.E(err), log.F("key", cacheKey))
1✔
105
                                next.ServeHTTP(w, r)
1✔
106
                                return
1✔
107
                        }
1✔
108
                        if !locked {
32✔
109
                                // Another request is in-flight, wait for it to complete with exponential backoff and jitter
4✔
110
                                sleepInterval := c.LockRetryInterval
4✔
111
                                for retries := 0; retries < c.LockMaxRetries; retries++ {
23✔
112
                                        jitteredInterval := addJitter(sleepInterval)
19✔
113

19✔
114
                                        select {
19✔
115
                                        case <-time.After(jitteredInterval):
19✔
116
                                        case <-r.Context().Done():
×
117
                                                detail := problem.NewDetail(http.StatusServiceUnavailable, "Request cancelled")
×
NEW
118
                                                _ = detail.RenderAuto(w, r)
×
119
                                                return
×
120
                                        }
121

122
                                        sleepInterval = time.Duration(math.Min(
19✔
123
                                                float64(sleepInterval)*c.LockBackoffMultiplier,
19✔
124
                                                float64(c.LockMaxInterval),
19✔
125
                                        ))
19✔
126

19✔
127
                                        record, found, err = store.Get(r.Context(), cacheKey)
19✔
128
                                        if err != nil {
19✔
129
                                                log.GetGlobalLogger().Error("Idempotency store get failed while waiting", log.E(err), log.F("key", cacheKey))
×
130
                                                next.ServeHTTP(w, r)
×
131
                                                return
×
132
                                        }
×
133
                                        if found {
20✔
134
                                                // Replay headers from cached record, avoiding duplicates from other middleware
1✔
135
                                                replayHeaders(w, record.Headers)
1✔
136
                                                w.Header().Set("X-Idempotency-Replay", "true")
1✔
137
                                                w.WriteHeader(record.StatusCode)
1✔
138
                                                _, _ = w.Write(record.Body)
1✔
139
                                                return
1✔
140
                                        }
1✔
141
                                }
142
                                // Max retries exhausted, another request is still in-flight
143
                                detail := problem.NewDetail(http.StatusConflict, "Idempotent request is still being processed")
3✔
144
                                _ = detail.RenderAuto(w, r)
3✔
145
                                return
3✔
146
                        }
147

148
                        // Ensure unlock happens even if handler panics
149
                        defer func() {
48✔
150
                                if err := store.Unlock(r.Context(), cacheKey); err != nil {
25✔
151
                                        log.GetGlobalLogger().Error("Idempotency store unlock failed", log.E(err), log.F("key", cacheKey))
1✔
152
                                }
1✔
153
                        }()
154

155
                        recorder := &idempotencyResponseRecorder{
24✔
156
                                ResponseBuffer: rwutil.NewResponseBuffer(w, 0), // 0 = unlimited buffering
24✔
157
                        }
24✔
158

24✔
159
                        next.ServeHTTP(recorder, r)
24✔
160

24✔
161
                        if recorder.HasWritten && recorder.Status >= 200 && recorder.Status < 300 {
43✔
162
                                record := config.IdempotencyRecord{
19✔
163
                                        StatusCode: recorder.Status,
19✔
164
                                        Headers:    recorder.headers,
19✔
165
                                        Body:       recorder.Buf.Bytes(),
19✔
166
                                        CreatedAt:  time.Now().UTC(),
19✔
167
                                }
19✔
168

19✔
169
                                if err := store.Set(r.Context(), cacheKey, record, c.TTL); err != nil {
20✔
170
                                        log.GetGlobalLogger().Error("Idempotency store set failed", log.E(err), log.F("key", cacheKey))
1✔
171
                                }
1✔
172
                        }
173
                })
174
        }
175
}
176

177
// idempotencyResponseRecorder captures response data for idempotency caching.
178
type idempotencyResponseRecorder struct {
179
        *rwutil.ResponseBuffer
180
        headers []string // flat slice: [key1, val1, key2, val2, ...]
181
}
182

183
func (i *idempotencyResponseRecorder) WriteHeader(statusCode int) {
22✔
184
        if i.HasWritten {
23✔
185
                return
1✔
186
        }
1✔
187
        i.ResponseBuffer.WriteHeader(statusCode)
21✔
188

21✔
189
        // Build flat header slice for efficient storage and replay
21✔
190
        for k, v := range i.Header() {
30✔
191
                // Skip hop-by-hop headers
9✔
192
                if k == "Connection" || k == "Keep-Alive" {
11✔
193
                        continue
2✔
194
                }
195
                for _, val := range v {
16✔
196
                        i.headers = append(i.headers, k, val)
9✔
197
                }
9✔
198
        }
199

200
        i.ResponseWriter.WriteHeader(statusCode)
21✔
201
        i.HeaderWritten = true
21✔
202
}
203

204
// Write captures the response body and forwards to the underlying ResponseWriter.
205
func (i *idempotencyResponseRecorder) Write(p []byte) (int, error) {
14✔
206
        if !i.HasWritten {
14✔
207
                i.WriteHeader(http.StatusOK)
×
208
        }
×
209
        // Buffer for caching and write through to client
210
        _, _ = i.Buf.Write(p)
14✔
211
        return i.ResponseWriter.Write(p)
14✔
212
}
213

214
// replayHeaders writes cached headers to the response, skipping headers
215
// that may already be present from other middleware (e.g., security headers).
216
// Headers are stored as a flat slice [key1, val1, key2, val2, ...].
217
func replayHeaders(w http.ResponseWriter, headers []string) {
10✔
218
        // Check which header keys are already present (set by other middleware)
10✔
219
        // We do this once before replay to avoid duplicates while still allowing
10✔
220
        // multi-value headers to be replayed correctly.
10✔
221
        existingKeys := make(map[string]bool, len(headers)/2)
10✔
222
        for i := 0; i < len(headers)-1; i += 2 {
19✔
223
                key := headers[i]
9✔
224
                if !existingKeys[key] {
18✔
225
                        if w.Header().Get(key) != "" {
11✔
226
                                existingKeys[key] = true
2✔
227
                        }
2✔
228
                }
229
        }
230

231
        // Replay all headers, skipping keys that were already set by middleware
232
        for i := 0; i < len(headers)-1; i += 2 {
19✔
233
                key := headers[i]
9✔
234
                if existingKeys[key] {
11✔
235
                        continue
2✔
236
                }
237
                w.Header().Add(key, headers[i+1])
7✔
238
        }
239
}
240

241
// addJitter returns a duration with random jitter between 0.5x and 1.5x the base duration.
242
// This helps prevent thundering herd problems when many requests wait for the same lock.
243
// Uses math/rand (not crypto/rand) since jitter doesn't need cryptographic randomness.
244
func addJitter(base time.Duration) time.Duration {
19✔
245
        // Random value between 0.5 and 1.5 (represents +/- 50% jitter)
19✔
246
        jitter := 0.5 + rand.Float64()
19✔
247
        return time.Duration(float64(base) * jitter)
19✔
248
}
19✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc