|
1 | 1 | //! Rate limit types. |
2 | 2 |
|
3 | | -use chrono::{DateTime, Utc}; |
| 3 | +use chrono::{DateTime, Duration, Utc}; |
4 | 4 | use serde::{Deserialize, Serialize}; |
5 | 5 |
|
6 | 6 | /// Rate limit information. |
@@ -174,11 +174,112 @@ pub fn parse_rate_limit_headers( |
174 | 174 | info.tokens_remaining = Some(remaining); |
175 | 175 | } |
176 | 176 |
|
177 | | - if let Some(reset) = headers.get("x-ratelimit-reset-requests") { |
178 | | - if let Ok(ts) = reset.parse::<i64>() { |
179 | | - info.reset_at = DateTime::from_timestamp(ts, 0); |
180 | | - } |
| 177 | + if let Some(reset) = headers |
| 178 | + .get("x-ratelimit-reset-requests") |
| 179 | + .or_else(|| headers.get("x-ratelimit-reset-tokens")) |
| 180 | + { |
| 181 | + info.reset_at = parse_reset_header(reset, Utc::now()); |
181 | 182 | } |
182 | 183 |
|
183 | 184 | info |
184 | 185 | } |
| 186 | + |
| 187 | +fn parse_reset_header(value: &str, now: DateTime<Utc>) -> Option<DateTime<Utc>> { |
| 188 | + let value = value.trim(); |
| 189 | + if value.is_empty() { |
| 190 | + return None; |
| 191 | + } |
| 192 | + |
| 193 | + if let Ok(timestamp) = value.parse::<i64>() { |
| 194 | + return if timestamp > 1_000_000_000 { |
| 195 | + DateTime::from_timestamp(timestamp, 0) |
| 196 | + } else { |
| 197 | + Some(now + Duration::seconds(timestamp)) |
| 198 | + }; |
| 199 | + } |
| 200 | + |
| 201 | + if let Ok(timestamp) = value.parse::<DateTime<Utc>>() { |
| 202 | + return Some(timestamp); |
| 203 | + } |
| 204 | + |
| 205 | + parse_duration_seconds(value).map(|seconds| now + Duration::seconds(seconds)) |
| 206 | +} |
| 207 | + |
| 208 | +fn parse_duration_seconds(value: &str) -> Option<i64> { |
| 209 | + let mut seconds = 0_i64; |
| 210 | + let mut digits = String::new(); |
| 211 | + let mut saw_unit = false; |
| 212 | + |
| 213 | + for ch in value.chars() { |
| 214 | + if ch.is_ascii_digit() { |
| 215 | + digits.push(ch); |
| 216 | + continue; |
| 217 | + } |
| 218 | + |
| 219 | + let amount = digits.parse::<i64>().ok()?; |
| 220 | + digits.clear(); |
| 221 | + saw_unit = true; |
| 222 | + match ch { |
| 223 | + 'h' => seconds = seconds.checked_add(amount.checked_mul(60 * 60)?)?, |
| 224 | + 'm' => seconds = seconds.checked_add(amount.checked_mul(60)?)?, |
| 225 | + 's' => seconds = seconds.checked_add(amount)?, |
| 226 | + _ => return None, |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + if saw_unit && digits.is_empty() { |
| 231 | + Some(seconds) |
| 232 | + } else { |
| 233 | + None |
| 234 | + } |
| 235 | +} |
| 236 | + |
| 237 | +#[cfg(test)] |
| 238 | +mod tests { |
| 239 | + use super::*; |
| 240 | + use std::collections::HashMap; |
| 241 | + |
| 242 | + fn fixed_now() -> DateTime<Utc> { |
| 243 | + DateTime::from_timestamp(1_700_000_000, 0).unwrap() |
| 244 | + } |
| 245 | + |
| 246 | + #[test] |
| 247 | + fn parses_openai_duration_reset_headers() { |
| 248 | + let reset = parse_reset_header("6m0s", fixed_now()).unwrap(); |
| 249 | + |
| 250 | + assert_eq!(reset, fixed_now() + Duration::seconds(360)); |
| 251 | + } |
| 252 | + |
| 253 | + #[test] |
| 254 | + fn parses_plain_integer_reset_as_seconds_from_now() { |
| 255 | + let reset = parse_reset_header("60", fixed_now()).unwrap(); |
| 256 | + |
| 257 | + assert_eq!(reset, fixed_now() + Duration::seconds(60)); |
| 258 | + } |
| 259 | + |
| 260 | + #[test] |
| 261 | + fn preserves_unix_timestamp_reset_headers() { |
| 262 | + let reset = parse_reset_header("1800000000", fixed_now()).unwrap(); |
| 263 | + |
| 264 | + assert_eq!(reset, DateTime::from_timestamp(1_800_000_000, 0).unwrap()); |
| 265 | + } |
| 266 | + |
| 267 | + #[test] |
| 268 | + fn parses_reset_headers_in_rate_limit_info() { |
| 269 | + let mut headers = HashMap::new(); |
| 270 | + headers.insert( |
| 271 | + "x-ratelimit-reset-requests".to_string(), |
| 272 | + "1m30s".to_string(), |
| 273 | + ); |
| 274 | + |
| 275 | + let info = parse_rate_limit_headers(&headers); |
| 276 | + |
| 277 | + assert!(info.reset_at.is_some()); |
| 278 | + assert!(info.reset_at.unwrap() > Utc::now()); |
| 279 | + } |
| 280 | + |
| 281 | + #[test] |
| 282 | + fn ignores_invalid_reset_headers() { |
| 283 | + assert!(parse_reset_header("soon", fixed_now()).is_none()); |
| 284 | + } |
| 285 | +} |
0 commit comments