Skip to content

Commit 6d3c936

Browse files
feat: add idempotency-key inspection endpoint
Add GET /api/v1/idempotency/:key so clients can inspect whether a key has a stored response, when it expires, and the associated request fingerprint. - Add IdempotencyRecord struct and Lookup method to IdempotencyStore - Implement Lookup on PostgresIdempotencyStore (scope-aware, expires=nil) - Implement Lookup on InMemoryIdempotencyStore with createdAt tracking - Register route under auth-protected v1 group with shared store instance - Results are tenant-scoped; cross-tenant lookups return 404 Closes #680
1 parent e541c6b commit 6d3c936

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

internal/middleware/idempotency_store.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,24 @@ import (
1313
// ErrRequestMismatch is returned when an idempotency key is reused with a different request.
1414
var ErrRequestMismatch = errors.New("idempotency key reused with a different request")
1515

16+
// IdempotencyRecord holds the stored metadata for a single idempotency key.
17+
type IdempotencyRecord struct {
18+
PayloadHash string
19+
StatusCode int
20+
ExpiresAt time.Time
21+
UsedAt time.Time
22+
}
23+
1624
// IdempotencyStore defines the contract for persisting idempotency keys and request states.
1725
type IdempotencyStore interface {
1826
GetOrInsert(ctx context.Context, scope, key, method, path, payloadHash string, ttl time.Duration) (statusCode int, responseBody []byte, isReplay bool, isInFlight bool, err error)
1927
UpdateResponse(ctx context.Context, scope, key string, statusCode int, responseBody []byte) error
2028
Delete(ctx context.Context, scope, key string) error
2129
DeleteExpiredBatch(ctx context.Context, batchSize int) (int64, error)
2230
CountExpiredPending(ctx context.Context) (int64, error)
31+
// Lookup returns the stored metadata for a key scoped to the caller.
32+
// Returns nil when the key does not exist or has expired.
33+
Lookup(ctx context.Context, scope, key string) (*IdempotencyRecord, error)
2334
}
2435

2536
// PostgresIdempotencyStore implements IdempotencyStore backed by PostgreSQL.
@@ -183,13 +194,43 @@ func (s *PostgresIdempotencyStore) CountExpiredPending(ctx context.Context) (int
183194
return count, nil
184195
}
185196

197+
// Lookup returns the stored metadata for a key scoped to the caller.
198+
// Returns nil when the key does not exist or has expired.
199+
func (s *PostgresIdempotencyStore) Lookup(ctx context.Context, scope, key string) (*IdempotencyRecord, error) {
200+
if s.pool == nil {
201+
return nil, errors.New("postgres connection pool is nil")
202+
}
203+
204+
var rec IdempotencyRecord
205+
err := s.pool.QueryRow(ctx, `
206+
SELECT payload_hash, status_code, expires_at, created_at
207+
FROM idempotency_keys
208+
WHERE scope = $1 AND key = $2`,
209+
scope, key,
210+
).Scan(&rec.PayloadHash, &rec.StatusCode, &rec.ExpiresAt, &rec.UsedAt)
211+
212+
if err != nil {
213+
if errors.Is(err, pgx.ErrNoRows) {
214+
return nil, nil
215+
}
216+
return nil, err
217+
}
218+
219+
if time.Now().After(rec.ExpiresAt) {
220+
return nil, nil
221+
}
222+
223+
return &rec, nil
224+
}
225+
186226
// InMemoryIdempotencyEntry represents a single cached item.
187227
type InMemoryIdempotencyEntry struct {
188228
method string
189229
path string
190230
payloadHash string
191231
statusCode int
192232
responseBody []byte
233+
createdAt time.Time
193234
expiresAt time.Time
194235
}
195236

@@ -226,6 +267,7 @@ func (s *InMemoryIdempotencyStore) GetOrInsert(ctx context.Context, scope, key,
226267
path: path,
227268
payloadHash: payloadHash,
228269
statusCode: 0,
270+
createdAt: now,
229271
expiresAt: now.Add(ttl),
230272
}
231273
return 0, nil, false, false, nil
@@ -298,3 +340,23 @@ func (s *InMemoryIdempotencyStore) CountExpiredPending(ctx context.Context) (int
298340
}
299341
return count, nil
300342
}
343+
344+
// Lookup returns the stored metadata for a key scoped to the caller.
345+
// Returns nil when the key does not exist or has expired.
346+
func (s *InMemoryIdempotencyStore) Lookup(ctx context.Context, scope, key string) (*IdempotencyRecord, error) {
347+
s.mu.RLock()
348+
defer s.mu.RUnlock()
349+
350+
mapKey := scope + "/" + key
351+
entry, exists := s.keys[mapKey]
352+
if !exists || time.Now().After(entry.expiresAt) {
353+
return nil, nil
354+
}
355+
356+
return &IdempotencyRecord{
357+
PayloadHash: entry.payloadHash,
358+
StatusCode: entry.statusCode,
359+
ExpiresAt: entry.expiresAt,
360+
UsedAt: entry.createdAt,
361+
}, nil
362+
}

internal/routes/routes.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ func Register(r *gin.Engine) {
9797
// expected (e.g. Kubernetes NetworkPolicy or reverse-auth proxy).
9898
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
9999

100+
// Shared idempotency store — use the same instance for the middleware
101+
// (when wired) and the inspection endpoint so they see the same key space.
102+
idempotencyStore := middleware.NewInMemoryIdempotencyStore()
103+
idempotencyHandler := handlers.NewIdempotencyHandler(idempotencyStore)
104+
100105
// V1 routes are all protected
101106
v1.Use(authMiddleware)
102107
{
@@ -110,6 +115,7 @@ func Register(r *gin.Engine) {
110115
v1.GET("/statements", handlers.NewListStatementsHandler(stmtSvc))
111116
v1.POST("/tenants/me/export", handlers.NewTenantExportHandler(exportJobManager))
112117
v1.GET("/operations/:id", handlers.NewOperationStatusHandler(exportJobManager))
118+
v1.GET("/idempotency/:key", idempotencyHandler.InspectKey)
113119
}
114120

115121
// CSP violation reports — public (no auth; browsers send without tokens),

0 commit comments

Comments
 (0)