Skip to main content

rate_limits/
reset_time.rs

1use crate::convert;
2use crate::error::{Error, Result};
3use time::{
4    OffsetDateTime,
5    format_description::well_known::{Rfc2822, Rfc3339},
6};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub(crate) enum ResetTimeKind {
10    Seconds,
11    Timestamp,
12    TimestampMillis,
13    ImfFixdate,
14    Iso8601,
15    OpenAiDuration,
16}
17
18/// Reset time of rate limiting
19///
20/// There are different variants on how to specify reset times
21/// in rate limit headers. The most common ones are seconds and datetime.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ResetTime {
24    /// Number of seconds until rate limit is lifted
25    Seconds(usize),
26    /// Date when rate limit will be lifted
27    DateTime(OffsetDateTime),
28}
29
30impl ResetTime {
31    /// Create a new reset time from a header value and a reset time kind
32    ///
33    /// # Errors
34    ///
35    /// This function returns an error if the header value cannot be parsed
36    /// or if the reset time kind is unknown.
37    pub(crate) fn new(value: &str, kind: ResetTimeKind) -> Result<Self> {
38        match kind {
39            ResetTimeKind::Seconds => {
40                let s = convert::to_usize(value)?;
41                Ok(ResetTime::Seconds(s))
42            }
43            ResetTimeKind::Timestamp => {
44                let s = value.parse::<i64>().map_err(|_| Error::NoMatchingVariant)?;
45                let dt =
46                    OffsetDateTime::from_unix_timestamp(s).map_err(|_| Error::NoMatchingVariant)?;
47                Ok(ResetTime::DateTime(dt))
48            }
49            ResetTimeKind::TimestampMillis => {
50                let ms = value
51                    .parse::<i128>()
52                    .map_err(|_| Error::NoMatchingVariant)?;
53                let dt = OffsetDateTime::from_unix_timestamp_nanos(ms * 1_000_000)
54                    .map_err(|_| Error::NoMatchingVariant)?;
55                Ok(ResetTime::DateTime(dt))
56            }
57            ResetTimeKind::ImfFixdate => {
58                let dt =
59                    OffsetDateTime::parse(value, &Rfc2822).map_err(|_| Error::NoMatchingVariant)?;
60                Ok(ResetTime::DateTime(dt))
61            }
62            ResetTimeKind::Iso8601 => {
63                let dt =
64                    OffsetDateTime::parse(value, &Rfc3339).map_err(|_| Error::NoMatchingVariant)?;
65                Ok(ResetTime::DateTime(dt))
66            }
67            ResetTimeKind::OpenAiDuration => {
68                let seconds = parse_openai_duration(value).ok_or(Error::NoMatchingVariant)?;
69                Ok(ResetTime::Seconds(seconds))
70            }
71        }
72    }
73
74    /// Get the number of seconds until the rate limit gets lifted.
75    #[must_use]
76    pub fn seconds(&self) -> usize {
77        match self {
78            ResetTime::Seconds(s) => *s,
79            // OffsetDateTime is not timezone aware, so we need to convert it to UTC
80            // and then convert it to seconds.
81            // If the reset time is in the past, we return 0.
82            #[allow(clippy::cast_possible_truncation)]
83            ResetTime::DateTime(d) => {
84                let diff = *d - OffsetDateTime::now_utc();
85                let seconds = diff.whole_seconds();
86                if seconds < 0 { 0 } else { seconds as usize }
87            }
88        }
89    }
90
91    /// Convert reset time to duration
92    #[must_use]
93    pub fn duration(&self) -> std::time::Duration {
94        match self {
95            ResetTime::Seconds(s) => std::time::Duration::from_secs(*s as u64),
96            ResetTime::DateTime(d) => {
97                let diff = *d - OffsetDateTime::now_utc();
98                std::time::Duration::try_from(diff).unwrap_or(std::time::Duration::ZERO)
99            }
100        }
101    }
102}
103
104impl TryFrom<&str> for ResetTime {
105    type Error = Error;
106
107    /// Best-effort parsing of a reset-time header value when the vendor
108    /// (and therefore the `ResetTimeKind`) is not known.
109    ///
110    /// Tries, in order:
111    ///
112    /// 1. Numeric Unix timestamp, if the value is large enough to plausibly
113    ///    be one (above ~Sep 2001).
114    /// 2. Numeric seconds-from-now offset, for smaller numeric values.
115    /// 3. RFC 2822 / IMF-fixdate.
116    /// 4. RFC 3339 / ISO 8601.
117    ///
118    /// Returns `Error::NoMatchingVariant` if none of these succeed.
119    fn try_from(value: &str) -> Result<Self> {
120        if let Ok(n) = convert::to_usize(value) {
121            // Values above ~Sep 2001 are almost certainly Unix timestamps;
122            // smaller values are interpreted as a seconds-from-now offset.
123            let kind = if n > 1_000_000_000 {
124                ResetTimeKind::Timestamp
125            } else {
126                ResetTimeKind::Seconds
127            };
128            return Self::new(value, kind);
129        }
130        if let Ok(r) = Self::new(value, ResetTimeKind::ImfFixdate) {
131            return Ok(r);
132        }
133        if let Ok(r) = Self::new(value, ResetTimeKind::Iso8601) {
134            return Ok(r);
135        }
136        Err(Error::NoMatchingVariant)
137    }
138}
139
140/// Parse an OpenAI-style duration string into a whole number of seconds,
141/// rounded up.
142///
143/// OpenAI's `x-ratelimit-reset-*` headers encode reset durations as
144/// concatenated `<number><unit>` segments rather than plain seconds.
145/// Supported units (case-sensitive):
146///
147/// | Unit | Meaning      |
148/// |------|--------------|
149/// | `ms` | milliseconds |
150/// | `s`  | seconds      |
151/// | `m`  | minutes      |
152/// | `h`  | hours        |
153/// | `d`  | days         |
154///
155/// Numbers may be fractional (e.g. `1.5s`). Multiple segments are summed,
156/// e.g. `1m30s` → 90 seconds, `500ms` → 1 second (rounded up).
157///
158/// Returns `None` if the input is empty, malformed, or contains an unknown
159/// unit.
160fn parse_openai_duration(s: &str) -> Option<usize> {
161    if s.is_empty() {
162        return None;
163    }
164
165    let mut total_ms = 0.0_f64;
166    let mut num_start: Option<usize> = None;
167    let bytes = s.as_bytes();
168    let mut i = 0;
169
170    while i < bytes.len() {
171        let c = bytes[i];
172        if c.is_ascii_digit() || c == b'.' {
173            if num_start.is_none() {
174                num_start = Some(i);
175            }
176            i += 1;
177            continue;
178        }
179
180        // We hit a unit character; we must have collected a number first.
181        let start = num_start.take()?;
182        let val: f64 = s[start..i].parse().ok()?;
183
184        // Disambiguate `m` (minutes) from `ms` (milliseconds).
185        let (multiplier_ms, consumed) = match c {
186            b's' => (1_000.0, 1),
187            b'm' if bytes.get(i + 1) == Some(&b's') => (1.0, 2),
188            b'm' => (60_000.0, 1),
189            b'h' => (3_600_000.0, 1),
190            b'd' => (86_400_000.0, 1),
191            _ => return None,
192        };
193
194        total_ms += val * multiplier_ms;
195        i += consumed;
196    }
197
198    // Trailing number with no unit is malformed (e.g. "10").
199    if num_start.is_some() {
200        return None;
201    }
202
203    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
204    Some((total_ms / 1000.0).ceil() as usize)
205}
206
207#[cfg(test)]
208mod openai_duration_tests {
209    use super::parse_openai_duration;
210
211    #[test]
212    fn seconds() {
213        assert_eq!(parse_openai_duration("1s"), Some(1));
214        assert_eq!(parse_openai_duration("42s"), Some(42));
215    }
216
217    #[test]
218    fn milliseconds_round_up() {
219        assert_eq!(parse_openai_duration("500ms"), Some(1));
220        assert_eq!(parse_openai_duration("1000ms"), Some(1));
221        assert_eq!(parse_openai_duration("1001ms"), Some(2));
222    }
223
224    #[test]
225    fn minutes_vs_milliseconds() {
226        assert_eq!(parse_openai_duration("1m"), Some(60));
227        assert_eq!(parse_openai_duration("1ms"), Some(1));
228    }
229
230    #[test]
231    fn compound() {
232        assert_eq!(parse_openai_duration("1m30s"), Some(90));
233        assert_eq!(parse_openai_duration("1h2m3s"), Some(3723));
234    }
235
236    #[test]
237    fn fractional() {
238        assert_eq!(parse_openai_duration("1.5s"), Some(2));
239        assert_eq!(parse_openai_duration("0.5m"), Some(30));
240    }
241
242    #[test]
243    fn hours_and_days() {
244        assert_eq!(parse_openai_duration("1h"), Some(3600));
245        assert_eq!(parse_openai_duration("1d"), Some(86_400));
246    }
247
248    #[test]
249    fn invalid() {
250        assert_eq!(parse_openai_duration(""), None);
251        assert_eq!(parse_openai_duration("10"), None); // no unit
252        assert_eq!(parse_openai_duration("s"), None); // no number
253        assert_eq!(parse_openai_duration("10x"), None); // unknown unit
254        assert_eq!(parse_openai_duration("abc"), None);
255    }
256}