Skip to main content

baukit_http/
idempotency.rs

1use std::{collections::BTreeMap, error::Error as StdError, fmt};
2
3use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
4use serde_json::Value;
5
6use crate::ApiError;
7
8/// The `Idempotency-Key` request header.
9pub const IDEMPOTENCY_KEY: HeaderName = HeaderName::from_static("idempotency-key");
10
11/// The longest key any [`IdempotencyKeyRule`] may accept, in bytes.
12pub const MAX_IDEMPOTENCY_KEY_BYTES: usize = 255;
13
14/// Error code for a route that requires `Idempotency-Key` and received none.
15pub const IDEMPOTENCY_KEY_REQUIRED_CODE: &str = "idempotency_key_required";
16
17/// Error code for an `Idempotency-Key` value outside the route's grammar.
18pub const INVALID_IDEMPOTENCY_KEY_CODE: &str = "invalid_idempotency_key";
19
20/// Error code for a key already used in the same scope with a different request.
21pub const IDEMPOTENCY_KEY_REUSED_CODE: &str = "idempotency_key_reused";
22
23/// Error code for a key whose first request has not finished.
24pub const IDEMPOTENCY_KEY_IN_PROGRESS_CODE: &str = "idempotency_key_in_progress";
25
26const REASON_DETAIL: &str = "reason";
27
28/// Length bounds for `Idempotency-Key` values on one route.
29///
30/// A key is `min_bytes..=max_bytes` bytes of visible ASCII (`0x21..=0x7E`). The key is opaque:
31/// quotes are part of the key, and nothing is trimmed beyond what the HTTP stack removes.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct IdempotencyKeyRule {
34    min_bytes: usize,
35    max_bytes: usize,
36}
37
38impl IdempotencyKeyRule {
39    /// Creates a rule for keys of `min_bytes..=max_bytes` bytes.
40    ///
41    /// # Panics
42    ///
43    /// Panics unless `1 <= min_bytes <= max_bytes <= MAX_IDEMPOTENCY_KEY_BYTES`. In a `const`
44    /// item the panic is a compile error. Use [`IdempotencyKeyRule::try_new`] for bounds read at
45    /// runtime.
46    #[must_use]
47    pub const fn new(min_bytes: usize, max_bytes: usize) -> Self {
48        assert!(
49            are_valid_bounds(min_bytes, max_bytes),
50            "invalid idempotency key bounds"
51        );
52        Self {
53            min_bytes,
54            max_bytes,
55        }
56    }
57
58    /// Creates a rule from bounds read at runtime.
59    pub const fn try_new(
60        min_bytes: usize,
61        max_bytes: usize,
62    ) -> Result<Self, InvalidIdempotencyKeyRule> {
63        if are_valid_bounds(min_bytes, max_bytes) {
64            Ok(Self {
65                min_bytes,
66                max_bytes,
67            })
68        } else {
69            Err(InvalidIdempotencyKeyRule)
70        }
71    }
72
73    /// Returns the shortest accepted key, in bytes.
74    #[must_use]
75    pub const fn min_bytes(&self) -> usize {
76        self.min_bytes
77    }
78
79    /// Returns the longest accepted key, in bytes.
80    #[must_use]
81    pub const fn max_bytes(&self) -> usize {
82        self.max_bytes
83    }
84
85    /// Parses one `Idempotency-Key` field value.
86    pub fn parse<'h>(
87        &self,
88        value: &'h HeaderValue,
89    ) -> Result<IdempotencyKey<'h>, InvalidIdempotencyKey> {
90        let bytes = value.as_bytes();
91        if bytes.is_empty() {
92            return Err(InvalidIdempotencyKey::Empty);
93        }
94        if !bytes.iter().all(u8::is_ascii_graphic) {
95            return Err(InvalidIdempotencyKey::InvalidCharacter);
96        }
97        if bytes.len() < self.min_bytes {
98            return Err(InvalidIdempotencyKey::TooShort);
99        }
100        if bytes.len() > self.max_bytes {
101            return Err(InvalidIdempotencyKey::TooLong);
102        }
103        std::str::from_utf8(bytes)
104            .map(IdempotencyKey)
105            .map_err(|_| InvalidIdempotencyKey::InvalidCharacter)
106    }
107
108    /// Reads the key from a route that requires `Idempotency-Key`.
109    ///
110    /// A missing header returns [`IdempotencyError::Required`].
111    pub fn required<'h>(
112        &self,
113        headers: &'h HeaderMap,
114    ) -> Result<IdempotencyKey<'h>, IdempotencyError> {
115        self.optional(headers)?.ok_or(IdempotencyError::Required)
116    }
117
118    /// Reads the key from a route where `Idempotency-Key` is optional.
119    ///
120    /// A missing header returns `Ok(None)`, which runs the mutation without a replay record.
121    pub fn optional<'h>(
122        &self,
123        headers: &'h HeaderMap,
124    ) -> Result<Option<IdempotencyKey<'h>>, IdempotencyError> {
125        let mut values = headers.get_all(IDEMPOTENCY_KEY).iter();
126        let Some(value) = values.next() else {
127            return Ok(None);
128        };
129        if values.next().is_some() {
130            return Err(InvalidIdempotencyKey::RepeatedHeader.into());
131        }
132        Ok(Some(self.parse(value)?))
133    }
134}
135
136/// Bounds outside `1 <= min_bytes <= max_bytes <= MAX_IDEMPOTENCY_KEY_BYTES`.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct InvalidIdempotencyKeyRule;
139
140impl fmt::Display for InvalidIdempotencyKeyRule {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        write!(
143            formatter,
144            "idempotency key bounds must satisfy 1 <= min <= max <= {MAX_IDEMPOTENCY_KEY_BYTES}"
145        )
146    }
147}
148
149impl StdError for InvalidIdempotencyKeyRule {}
150
151/// A validated `Idempotency-Key` value.
152///
153/// `Debug` hides the value so the key does not reach logs through a derived `Debug`.
154#[derive(Clone, Copy, Eq, Hash, PartialEq)]
155pub struct IdempotencyKey<'h>(&'h str);
156
157impl<'h> IdempotencyKey<'h> {
158    /// Returns the key as sent.
159    #[must_use]
160    pub const fn as_str(&self) -> &'h str {
161        self.0
162    }
163}
164
165impl fmt::Debug for IdempotencyKey<'_> {
166    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167        formatter.write_str("IdempotencyKey(..)")
168    }
169}
170
171/// Why an `Idempotency-Key` value is outside the route's grammar.
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum InvalidIdempotencyKey {
174    /// The request carries more than one `Idempotency-Key` header.
175    RepeatedHeader,
176    /// The header value is empty.
177    Empty,
178    /// The value contains a byte outside visible ASCII.
179    InvalidCharacter,
180    /// The value is shorter than the rule's minimum.
181    TooShort,
182    /// The value is longer than the rule's maximum.
183    TooLong,
184}
185
186impl InvalidIdempotencyKey {
187    /// Returns the stable snake_case reason sent in the error `details.reason`.
188    #[must_use]
189    pub const fn reason(self) -> &'static str {
190        match self {
191            Self::RepeatedHeader => "repeated_header",
192            Self::Empty => "empty",
193            Self::InvalidCharacter => "invalid_character",
194            Self::TooShort => "too_short",
195            Self::TooLong => "too_long",
196        }
197    }
198}
199
200impl fmt::Display for InvalidIdempotencyKey {
201    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(formatter, "invalid Idempotency-Key: {}", self.reason())
203    }
204}
205
206impl StdError for InvalidIdempotencyKey {}
207
208/// A keyed mutation that cannot run or replay.
209///
210/// Converts into [`ApiError`] as 400 `idempotency_key_required`, 400 `invalid_idempotency_key`,
211/// 409 `idempotency_key_reused`, or 409 `idempotency_key_in_progress`.
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum IdempotencyError {
214    /// A route that requires `Idempotency-Key` received none.
215    Required,
216    /// The `Idempotency-Key` value is outside the route's grammar.
217    Invalid(InvalidIdempotencyKey),
218    /// The key was used in the same scope with a different request fingerprint.
219    Reused,
220    /// The first request with this key has not finished, and the product chose not to wait.
221    InProgress,
222}
223
224impl IdempotencyError {
225    /// Returns the HTTP status for this error.
226    #[must_use]
227    pub const fn status(&self) -> StatusCode {
228        match self {
229            Self::Required | Self::Invalid(_) => StatusCode::BAD_REQUEST,
230            Self::Reused | Self::InProgress => StatusCode::CONFLICT,
231        }
232    }
233
234    /// Returns the stable error code for this error.
235    #[must_use]
236    pub const fn code(&self) -> &'static str {
237        match self {
238            Self::Required => IDEMPOTENCY_KEY_REQUIRED_CODE,
239            Self::Invalid(_) => INVALID_IDEMPOTENCY_KEY_CODE,
240            Self::Reused => IDEMPOTENCY_KEY_REUSED_CODE,
241            Self::InProgress => IDEMPOTENCY_KEY_IN_PROGRESS_CODE,
242        }
243    }
244
245    const fn message(&self) -> &'static str {
246        match self {
247            Self::Required => "Idempotency-Key is required",
248            Self::Invalid(_) => "Idempotency-Key is not a valid key for this route",
249            Self::Reused => "Idempotency-Key was already used with a different request",
250            Self::InProgress => "A request with this Idempotency-Key is still running",
251        }
252    }
253
254    fn details(&self) -> BTreeMap<String, Value> {
255        match self {
256            Self::Invalid(reason) => {
257                BTreeMap::from([(REASON_DETAIL.to_owned(), Value::from(reason.reason()))])
258            }
259            Self::Required | Self::Reused | Self::InProgress => BTreeMap::new(),
260        }
261    }
262}
263
264impl From<InvalidIdempotencyKey> for IdempotencyError {
265    fn from(error: InvalidIdempotencyKey) -> Self {
266        Self::Invalid(error)
267    }
268}
269
270impl fmt::Display for IdempotencyError {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        write!(formatter, "{} ({})", self.message(), self.code())
273    }
274}
275
276impl StdError for IdempotencyError {}
277
278impl From<IdempotencyError> for ApiError {
279    fn from(error: IdempotencyError) -> Self {
280        Self::new(error.status(), error.code(), error.message()).with_details(error.details())
281    }
282}
283
284const fn are_valid_bounds(min_bytes: usize, max_bytes: usize) -> bool {
285    min_bytes >= 1 && min_bytes <= max_bytes && max_bytes <= MAX_IDEMPOTENCY_KEY_BYTES
286}
287
288#[cfg(test)]
289mod tests;