Skip to main content

max / makenotwork

1.9 KB · 77 lines History Blame Raw
1 //! Idempotency key storage for safe POST retries.
2
3 use sqlx::PgPool;
4 use crate::db::UserId;
5 use crate::error::Result;
6
7 /// A cached idempotency response.
8 #[derive(sqlx::FromRow)]
9 pub struct CachedResponse {
10 pub status_code: i16,
11 pub response_body: String,
12 }
13
14 /// Look up a cached response for an idempotency key.
15 /// Scoped to (key, user_id, method, path) to prevent cross-endpoint collisions.
16 #[tracing::instrument(skip_all)]
17 pub async fn get_cached_response(
18 pool: &PgPool,
19 key: &str,
20 user_id: UserId,
21 method: &str,
22 path: &str,
23 ) -> Result<Option<CachedResponse>> {
24 let row = sqlx::query_as::<_, CachedResponse>(
25 "SELECT status_code, response_body FROM idempotency_keys WHERE key = $1 AND user_id = $2 AND method = $3 AND path = $4",
26 )
27 .bind(key)
28 .bind(user_id)
29 .bind(method)
30 .bind(path)
31 .fetch_optional(pool)
32 .await?;
33
34 Ok(row)
35 }
36
37 /// Store a response for an idempotency key. Uses ON CONFLICT to handle
38 /// race conditions (first writer wins).
39 #[tracing::instrument(skip_all)]
40 pub async fn store_response(
41 pool: &PgPool,
42 key: &str,
43 user_id: UserId,
44 method: &str,
45 path: &str,
46 status_code: u16,
47 response_body: &str,
48 ) -> Result<()> {
49 sqlx::query(
50 r#"INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body)
51 VALUES ($1, $2, $3, $4, $5, $6)
52 ON CONFLICT (key, user_id, method, path) DO NOTHING"#,
53 )
54 .bind(key)
55 .bind(user_id)
56 .bind(method)
57 .bind(path)
58 .bind(status_code as i16)
59 .bind(response_body)
60 .execute(pool)
61 .await?;
62
63 Ok(())
64 }
65
66 /// Delete expired idempotency keys (older than 24 hours).
67 #[tracing::instrument(skip_all)]
68 pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
69 let result = sqlx::query(
70 "DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'",
71 )
72 .execute(pool)
73 .await?;
74
75 Ok(result.rows_affected())
76 }
77