@@ -13,13 +13,24 @@ import (
1313// ErrRequestMismatch is returned when an idempotency key is reused with a different request.
1414var 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.
1725type 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.
187227type 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+ }
0 commit comments