├── .gitignore ├── src ├── convert.rs ├── error.rs ├── reset_time.rs ├── retryafter │ └── mod.rs ├── headers │ ├── types.rs │ ├── variants.rs │ └── mod.rs ├── lib.rs └── casesensitive_headermap.rs ├── Cargo.toml ├── tests └── parse.rs ├── .github └── workflows │ └── rust.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .vscode 4 | -------------------------------------------------------------------------------- /src/convert.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | 3 | pub(crate) fn to_usize(value: &str) -> Result { 4 | Ok(value.trim().parse::()?) 5 | } 6 | 7 | pub(crate) fn to_i64(value: &str) -> Result { 8 | Ok(value.trim().parse::()?) 9 | } 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rate-limits" 3 | authors = ["Matthias Endler "] 4 | version = "0.6.0" 5 | edition = "2021" 6 | description = "A parser for HTTP rate limit headers" 7 | license = "Apache-2.0/MIT" 8 | homepage = "https://github.com/mre/rate-limits" 9 | repository = "https://github.com/mre/rate-limits" 10 | documentation = "https://docs.rs/rate-limits" 11 | keywords = ["http", "rate-limit", "header", "parser"] 12 | 13 | [dependencies] 14 | displaydoc = "0.2.3" 15 | headers = "0.3.8" 16 | http = "0.2.9" 17 | once_cell = "1.17.1" 18 | thiserror = "1.0.39" 19 | time = { version = "0.3.20", features = ["parsing", "macros"] } 20 | 21 | [dev-dependencies] 22 | doc-comment = "0.3.3" 23 | indoc = "2.0.1" 24 | -------------------------------------------------------------------------------- /tests/parse.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod cli { 3 | use http::header::HeaderMap; 4 | use rate_limits::{RateLimit, ResetTime, Vendor}; 5 | use time::{Duration, OffsetDateTime}; 6 | 7 | use rate_limits::headers; 8 | 9 | #[test] 10 | fn test_example() { 11 | let mut headers = HeaderMap::new(); 12 | headers.insert("X-RATELIMIT-LIMIT", "5000".parse().unwrap()); 13 | headers.insert("X-RATELIMIT-REMAINING", "4987".parse().unwrap()); 14 | headers.insert("X-RATELIMIT-RESET", "1350085394".parse().unwrap()); 15 | 16 | assert_eq!( 17 | RateLimit::new(headers).unwrap(), 18 | RateLimit::Rfc6585(headers::Headers { 19 | limit: 5000, 20 | remaining: 4987, 21 | reset: ResetTime::DateTime( 22 | OffsetDateTime::from_unix_timestamp(1350085394).unwrap() 23 | ), 24 | window: Some(Duration::HOUR), 25 | vendor: Vendor::Github 26 | }), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::num::ParseIntError; 2 | 3 | use displaydoc::Display; 4 | use thiserror::Error; 5 | 6 | /// Error variants while parsing the rate limit headers 7 | #[derive(Display, Debug, Error)] 8 | pub enum Error { 9 | /// HTTP x-ratelimit-limit header not found 10 | MissingLimit, 11 | 12 | /// HTTP x-ratelimit-used header not found 13 | MissingUsed, 14 | 15 | /// HTTP x-ratelimit-remaining header not found 16 | MissingRemaining, 17 | 18 | /// HTTP x-ratelimit-reset header not found 19 | MissingReset, 20 | 21 | /// HTTP Retry-After header not found 22 | MissingRetryAfter, 23 | 24 | /// Invalid Retry-After header value 25 | InvalidRetryAfter(String), 26 | 27 | /// Header does not contain colon 28 | HeaderWithoutColon(String), 29 | 30 | /// Invalid header name 31 | InvalidHeaderName(#[from] http::header::InvalidHeaderName), 32 | 33 | /// Invalid header value 34 | InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), 35 | 36 | /// Cannot convert header value to string 37 | ToStr(#[from] http::header::ToStrError), 38 | 39 | /// Cannot parse rate limit header value: {0} 40 | InvalidValue(#[from] ParseIntError), 41 | 42 | /// Cannot lock header map 43 | Lock, 44 | 45 | /// Time Parsing error 46 | Parse(#[from] time::error::Parse), 47 | 48 | /// Error parsing reset time: {0} 49 | Time(#[from] time::error::ComponentRange), 50 | } 51 | 52 | pub(crate) type Result = std::result::Result; 53 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | repository_dispatch: 4 | workflow_dispatch: 5 | push: 6 | pull_request: 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | - name: Run cargo test 20 | uses: actions-rs/cargo@v1 21 | with: 22 | command: test 23 | 24 | lint: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: stable 31 | components: clippy 32 | - name: Run cargo fmt (check if all code is rustfmt-ed) 33 | uses: actions-rs/cargo@v1 34 | with: 35 | command: fmt 36 | args: --all -- --check 37 | - name: Run cargo clippy (deny warnings) 38 | uses: actions-rs/cargo@v1 39 | with: 40 | command: clippy 41 | # --all-targets makes it lint tests too 42 | args: --all-targets -- --deny warnings 43 | 44 | publish-check: 45 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 46 | name: publish check 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v1 50 | - uses: actions-rs/toolchain@v1 51 | with: 52 | toolchain: stable 53 | override: true 54 | - name: cargo fetch 55 | uses: actions-rs/cargo@v1 56 | with: 57 | command: fetch 58 | - run: cargo publish --dry-run 59 | 60 | publish: 61 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 62 | needs: 63 | - test 64 | - lint 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v1 68 | - uses: actions-rs/toolchain@v1 69 | with: 70 | toolchain: stable 71 | override: true 72 | - name: cargo fetch 73 | uses: actions-rs/cargo@v1 74 | with: 75 | command: fetch 76 | - run: cargo publish 77 | env: 78 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rate-limits 2 | 3 | [![docs.rs](https://docs.rs/rate-limits/badge.svg)](https://docs.rs/rate-limits) 4 | 5 | A crate for parsing HTTP rate limit headers as per the [IETF draft][draft]. 6 | Inofficial implementations like the [Github rate limit headers][github] are 7 | also supported on a best effort basis. See [vendor list] for support. 8 | 9 | ```rust 10 | use indoc::indoc; 11 | use std::str::FromStr; 12 | use time::{OffsetDateTime, Duration}; 13 | use rate_limits::{Vendor, RateLimit, ResetTime, Headers}; 14 | 15 | let headers = indoc! {" 16 | x-ratelimit-limit: 5000 17 | x-ratelimit-remaining: 4987 18 | x-ratelimit-reset: 1350085394 19 | "}; 20 | 21 | assert_eq!( 22 | RateLimit::new(headers).unwrap(), 23 | RateLimit::Rfc6585(Headers { 24 | limit: 5000, 25 | remaining: 4987, 26 | reset: ResetTime::DateTime( 27 | OffsetDateTime::from_unix_timestamp(1350085394).unwrap() 28 | ), 29 | window: Some(Duration::HOUR), 30 | vendor: Vendor::Github 31 | }), 32 | ); 33 | ``` 34 | 35 | Also takes the `Retry-After` header into account when calculating the reset 36 | time. 37 | 38 | [`http::HeaderMap`][headermap] is supported as well: 39 | 40 | ```rust 41 | use std::str::FromStr; 42 | use time::{OffsetDateTime, Duration}; 43 | use rate_limits::{Vendor, RateLimit, ResetTime, Headers}; 44 | use http::header::HeaderMap; 45 | 46 | let mut headers = HeaderMap::new(); 47 | headers.insert("X-RATELIMIT-LIMIT", "5000".parse().unwrap()); 48 | headers.insert("X-RATELIMIT-REMAINING", "4987".parse().unwrap()); 49 | headers.insert("X-RATELIMIT-RESET", "1350085394".parse().unwrap()); 50 | 51 | assert_eq!( 52 | RateLimit::new(headers).unwrap(), 53 | RateLimit::Rfc6585(Headers { 54 | limit: 5000, 55 | remaining: 4987, 56 | reset: ResetTime::DateTime( 57 | OffsetDateTime::from_unix_timestamp(1350085394).unwrap() 58 | ), 59 | window: Some(Duration::HOUR), 60 | vendor: Vendor::Github 61 | }), 62 | ); 63 | ``` 64 | 65 | ### Further development 66 | 67 | There is a new [IETF draft][draft_new] which supersedes the old "polli" draft. 68 | It introduces a new `RateLimit-Policy` header which specifies the rate limit 69 | quota policy. The goal is to support this new draft in this crate as well. 70 | 71 | ### Other resources: 72 | 73 | - [Examples of HTTP API Rate Limiting HTTP Response][stackoverflow] 74 | 75 | [draft]: https://datatracker.ietf.org/doc/html/draft-polli-ratelimit-headers-00 76 | [draft_new]: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ 77 | [headers]: https://stackoverflow.com/a/16022625/270334 78 | [github]: https://docs.github.com/en/rest/overview/resources-in-the-rest-api 79 | [vendor list]: https://docs.rs/rate-limits/latest/rate_limits/enum.Vendor.html 80 | [stackoverflow]: https://stackoverflow.com/questions/16022624/examples-of-http-api-rate-limiting-http-response-headers 81 | [headermap]: https://docs.rs/http/latest/http/header/struct.HeaderMap.html 82 | 83 | License: Apache-2.0/MIT 84 | -------------------------------------------------------------------------------- /src/reset_time.rs: -------------------------------------------------------------------------------- 1 | use crate::convert; 2 | use crate::error::{Error, Result}; 3 | use headers::HeaderValue; 4 | use time::format_description::well_known::{Iso8601, Rfc2822}; 5 | use time::{Duration, OffsetDateTime, PrimitiveDateTime}; 6 | 7 | /// The kind of rate limit reset time 8 | /// 9 | /// There are different ways to denote rate limits reset times. 10 | /// Some vendors use seconds, others use a timestamp format for example. 11 | /// 12 | /// This enum lists all known variants. 13 | #[derive(Copy, Clone, Debug, PartialEq)] 14 | pub enum ResetTimeKind { 15 | /// Number of seconds until rate limit is lifted 16 | Seconds, 17 | /// Unix timestamp when rate limit will be lifted 18 | Timestamp, 19 | /// RFC 2822 date when rate limit will be lifted 20 | ImfFixdate, 21 | /// ISO 8601 date when rate limit will be lifted 22 | Iso8601, 23 | } 24 | 25 | /// Reset time of rate limiting 26 | /// 27 | /// There are different variants on how to specify reset times 28 | /// in rate limit headers. The most common ones are seconds and datetime. 29 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd)] 30 | pub enum ResetTime { 31 | /// Number of seconds until rate limit is lifted 32 | Seconds(usize), 33 | /// Date when rate limit will be lifted 34 | DateTime(OffsetDateTime), 35 | } 36 | 37 | impl ResetTime { 38 | /// Create a new reset time from a header value and a reset time kind 39 | /// 40 | /// # Errors 41 | /// 42 | /// This function returns an error if the header value cannot be parsed 43 | /// or if the reset time kind is unknown. 44 | pub fn new(value: &HeaderValue, kind: ResetTimeKind) -> Result { 45 | let value = value.to_str()?; 46 | match kind { 47 | ResetTimeKind::Seconds => Ok(ResetTime::Seconds(convert::to_usize(value)?)), 48 | ResetTimeKind::Timestamp => Ok(Self::DateTime( 49 | OffsetDateTime::from_unix_timestamp(convert::to_i64(value)?) 50 | .map_err(Error::Time)?, 51 | )), 52 | ResetTimeKind::Iso8601 => { 53 | // https://github.com/time-rs/time/issues/378 54 | let d = PrimitiveDateTime::parse(value, &Iso8601::PARSING).map_err(Error::Parse)?; 55 | Ok(ResetTime::DateTime(d.assume_utc())) 56 | } 57 | ResetTimeKind::ImfFixdate => { 58 | let d = PrimitiveDateTime::parse(value, &Rfc2822).map_err(Error::Parse)?; 59 | Ok(ResetTime::DateTime(d.assume_utc())) 60 | } 61 | } 62 | } 63 | 64 | /// Get the number of seconds until the rate limit gets lifted. 65 | #[must_use] 66 | pub fn seconds(&self) -> usize { 67 | match self { 68 | ResetTime::Seconds(s) => *s, 69 | // OffsetDateTime is not timezone aware, so we need to convert it to UTC 70 | // and then convert it to seconds. 71 | // There are no negative values in the seconds field, so we can safely 72 | // cast it to usize. 73 | #[allow(clippy::cast_possible_truncation)] 74 | ResetTime::DateTime(d) => (*d - OffsetDateTime::now_utc()).whole_seconds() as usize, 75 | } 76 | } 77 | 78 | /// Convert reset time to duration 79 | #[must_use] 80 | pub fn duration(&self) -> Duration { 81 | match self { 82 | ResetTime::Seconds(s) => Duration::seconds(*s as i64), 83 | ResetTime::DateTime(d) => { 84 | Duration::seconds((*d - OffsetDateTime::now_utc()).whole_seconds()) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/retryafter/mod.rs: -------------------------------------------------------------------------------- 1 | //! Retry-After header parsing 2 | //! 3 | //! See 4 | use std::str::FromStr; 5 | 6 | use headers::HeaderValue; 7 | use time::{format_description::well_known::Rfc2822, Date}; 8 | 9 | use crate::{ 10 | casesensitive_headermap::CaseSensitiveHeaderMap, 11 | reset_time::{ResetTime, ResetTimeKind}, 12 | }; 13 | 14 | use super::error::{Error, Result}; 15 | 16 | /// HTTP rate limits as parsed from header values 17 | #[derive(Copy, Clone, Debug, PartialEq)] 18 | pub struct RateLimit { 19 | /// Time at which the rate limit will be reset 20 | pub reset: ResetTime, 21 | } 22 | 23 | impl RateLimit { 24 | /// Rate limit implementation based on `Retry-After` header value 25 | /// 26 | /// See 27 | pub fn new>(headers: T) -> std::result::Result { 28 | let headers = headers.into(); 29 | let reset = match Self::get_retry_after_header(&headers) { 30 | Some(retry_after) => { 31 | if Date::parse(retry_after.to_str()?, &Rfc2822).is_ok() { 32 | ResetTime::new(retry_after, ResetTimeKind::ImfFixdate)? 33 | } else { 34 | ResetTime::new(retry_after, ResetTimeKind::Seconds)? 35 | } 36 | } 37 | None => return Err(Error::MissingRetryAfter), 38 | }; 39 | 40 | Ok(RateLimit { reset }) 41 | } 42 | 43 | /// Get the Retry-After header value 44 | /// 45 | /// This does not need to be case sensitive because the header name is 46 | /// not ambiguous. 47 | fn get_retry_after_header(header_map: &CaseSensitiveHeaderMap) -> Option<&HeaderValue> { 48 | header_map 49 | .get("Retry-After") 50 | .or_else(|| header_map.get("retry-after")) 51 | } 52 | 53 | /// Get the time at which the rate limit will be reset 54 | #[must_use] 55 | pub const fn reset(&self) -> ResetTime { 56 | self.reset 57 | } 58 | } 59 | 60 | impl FromStr for RateLimit { 61 | type Err = Error; 62 | 63 | fn from_str(map: &str) -> Result { 64 | RateLimit::new(CaseSensitiveHeaderMap::from_str(map)?) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | 71 | use super::*; 72 | use indoc::indoc; 73 | use time::macros::datetime; 74 | 75 | #[test] 76 | fn parse_retry_after_seconds() { 77 | let map = CaseSensitiveHeaderMap::from_str("Retry-After: 30").unwrap(); 78 | let retry = RateLimit::get_retry_after_header(&map).unwrap(); 79 | 80 | assert_eq!("30", retry); 81 | } 82 | 83 | #[test] 84 | fn retry_after_seconds() { 85 | let headers = indoc! {" 86 | Retry-After: 19 87 | "}; 88 | 89 | let rate = RateLimit::from_str(headers).unwrap(); 90 | assert_eq!(rate.reset(), ResetTime::Seconds(19)); 91 | } 92 | 93 | #[test] 94 | fn retry_after_seconds_case_sensitive() { 95 | let headers = indoc! {" 96 | retry-after: 19 97 | "}; 98 | 99 | let rate = RateLimit::from_str(headers).unwrap(); 100 | assert_eq!(rate.reset(), ResetTime::Seconds(19)); 101 | } 102 | 103 | #[test] 104 | fn retry_after_imf_fixdate() { 105 | let headers = indoc! {" 106 | Retry-After: Fri, 31 Dec 1999 23:59:59 GMT 107 | "}; 108 | 109 | let rate = RateLimit::from_str(headers).unwrap(); 110 | assert_eq!( 111 | rate.reset(), 112 | ResetTime::DateTime(datetime!(1999-12-31 23:59:59 UTC)) 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/headers/types.rs: -------------------------------------------------------------------------------- 1 | use crate::convert; 2 | use crate::error::Result; 3 | use crate::reset_time::ResetTimeKind; 4 | use time::Duration; 5 | 6 | /// Known vendors of rate limit headers 7 | /// 8 | /// Vendors use different rate limit header formats, 9 | /// which define how to parse them. 10 | #[derive(Copy, Clone, Debug, PartialEq)] 11 | pub enum Vendor { 12 | /// Rate limit headers as defined in the `polli-ratelimit-headers-00` draft 13 | Standard, 14 | /// Reddit rate limit headers 15 | Reddit, 16 | /// Github API rate limit headers 17 | Github, 18 | /// Twitter API rate limit headers 19 | Twitter, 20 | /// Vimeo rate limit headers 21 | Vimeo, 22 | /// Gitlab rate limit headers 23 | Gitlab, 24 | /// Akamai rate limit headers 25 | Akamai, 26 | } 27 | 28 | /// A variant defines all relevant fields for parsing headers from a given vendor 29 | #[derive(Clone, Debug, PartialEq)] 30 | pub(crate) struct RateLimitVariant { 31 | /// Vendor of the rate limit headers (e.g. Github, Twitter, etc.) 32 | pub(crate) vendor: Vendor, 33 | /// Duration of the rate limit interval 34 | pub(crate) duration: Option, 35 | /// Header name for the maximum number of requests 36 | pub(crate) limit_header: Option, 37 | /// Header name for the number of used requests 38 | pub(crate) used_header: Option, 39 | /// Header name for the number of remaining requests 40 | pub(crate) remaining_header: String, 41 | /// Header name for the reset time 42 | pub(crate) reset_header: String, 43 | /// Kind of reset time 44 | pub(crate) reset_kind: ResetTimeKind, 45 | } 46 | 47 | impl RateLimitVariant { 48 | /// Create a new rate limit variant 49 | #[must_use] 50 | pub(crate) const fn new( 51 | vendor: Vendor, 52 | duration: Option, 53 | limit_header: Option, 54 | used_header: Option, 55 | remaining_header: String, 56 | reset_header: String, 57 | reset_kind: ResetTimeKind, 58 | ) -> Self { 59 | Self { 60 | vendor, 61 | duration, 62 | limit_header, 63 | used_header, 64 | remaining_header, 65 | reset_header, 66 | reset_kind, 67 | } 68 | } 69 | } 70 | 71 | /// A rate limit header 72 | #[derive(Clone, Copy, Debug, PartialEq)] 73 | pub(crate) struct Limit { 74 | /// Maximum number of requests for the given interval 75 | pub(crate) count: usize, 76 | } 77 | 78 | impl Limit { 79 | /// Create a new limit header 80 | /// 81 | /// # Errors 82 | /// 83 | /// This function returns an error if the header value cannot be parsed 84 | pub(crate) fn new>(value: T) -> Result { 85 | Ok(Self { 86 | count: convert::to_usize(value.as_ref())?, 87 | }) 88 | } 89 | } 90 | 91 | impl From for Limit { 92 | fn from(count: usize) -> Self { 93 | Self { count } 94 | } 95 | } 96 | 97 | /// A rate limit header for the number of used requests 98 | #[derive(Clone, Copy, Debug, PartialEq)] 99 | pub(crate) struct Used { 100 | /// Number of used requests for the given interval 101 | pub(crate) count: usize, 102 | } 103 | 104 | impl Used { 105 | pub(crate) fn new(value: &str) -> Result { 106 | Ok(Self { 107 | count: convert::to_usize(value)?, 108 | }) 109 | } 110 | } 111 | 112 | /// A rate limit header for the number of remaining requests 113 | #[derive(Clone, Copy, Debug, PartialEq)] 114 | pub(crate) struct Remaining { 115 | /// Number of remaining requests for the given interval 116 | pub(crate) count: usize, 117 | } 118 | 119 | impl Remaining { 120 | /// Create a new remaining header 121 | /// 122 | /// # Errors 123 | /// 124 | /// This function returns an error if the header value cannot be parsed 125 | pub(crate) fn new(value: &str) -> Result { 126 | Ok(Self { 127 | count: convert::to_usize(value)?, 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | #![warn(clippy::all)] 3 | #![warn( 4 | absolute_paths_not_starting_with_crate, 5 | rustdoc::invalid_html_tags, 6 | missing_copy_implementations, 7 | missing_debug_implementations, 8 | semicolon_in_expressions_from_macros, 9 | unreachable_pub, 10 | unused_extern_crates, 11 | variant_size_differences, 12 | clippy::missing_const_for_fn 13 | )] 14 | #![deny(anonymous_parameters, macro_use_extern_crate, pointer_structural_match)] 15 | #![deny(missing_docs)] 16 | #![allow(clippy::module_name_repetitions)] 17 | 18 | mod casesensitive_headermap; 19 | mod convert; 20 | mod error; 21 | mod reset_time; 22 | 23 | pub mod headers; 24 | pub mod retryafter; 25 | 26 | use std::str::FromStr; 27 | 28 | use casesensitive_headermap::CaseSensitiveHeaderMap; 29 | use error::{Error, Result}; 30 | 31 | pub use headers::{Headers, Vendor}; 32 | pub use reset_time::ResetTime; 33 | 34 | /// Rate Limit information, parsed from HTTP headers. 35 | /// 36 | /// There are multiple ways to represent rate limit information in HTTP headers. 37 | /// The following variants are supported: 38 | /// 39 | /// - [IETF "Polly" draft][ietf] 40 | /// - [Retry-After][retryafter] 41 | /// 42 | /// [ietf]: https://datatracker.ietf.org/doc/html/draft-polli-ratelimit-headers-00 43 | /// [retryafter]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After 44 | /// 45 | #[derive(Debug, Copy, Clone, PartialEq)] 46 | pub enum RateLimit { 47 | /// Rate limit information as per the [IETF "Polly" draft][ietf]. 48 | Rfc6585(headers::Headers), 49 | /// Rate limit information as per the [Retry-After][retryafter] header. 50 | RetryAfter(retryafter::RateLimit), 51 | } 52 | 53 | impl RateLimit { 54 | /// Create a new `RateLimit` from a `http::HeaderMap`. 55 | pub fn new>(headers: T) -> std::result::Result { 56 | let headers = headers.into(); 57 | let rfc6585 = headers::Headers::new(headers.clone()); 58 | let retryafter = retryafter::RateLimit::new(headers); 59 | 60 | match (rfc6585, retryafter) { 61 | (Ok(rfc6585), Ok(retryafter)) => { 62 | if rfc6585.reset > retryafter.reset { 63 | Ok(Self::Rfc6585(rfc6585)) 64 | } else { 65 | Ok(Self::RetryAfter(retryafter)) 66 | } 67 | } 68 | (Ok(rfc6585), Err(_)) => Ok(Self::Rfc6585(rfc6585)), 69 | (Err(_), Ok(retryafter)) => Ok(Self::RetryAfter(retryafter)), 70 | (Err(e), Err(_)) => Err(e), 71 | } 72 | } 73 | 74 | /// Get `reset` time. 75 | /// This is the time when the rate limit will be reset. 76 | pub const fn reset(&self) -> ResetTime { 77 | match self { 78 | Self::Rfc6585(rfc6585) => rfc6585.reset, 79 | Self::RetryAfter(retryafter) => retryafter.reset, 80 | } 81 | } 82 | 83 | /// Get `limit` value. 84 | /// 85 | /// This is the maximum number of requests that can be made in a given time window. 86 | pub const fn limit(&self) -> Option { 87 | match self { 88 | Self::Rfc6585(rfc6585) => Some(rfc6585.limit), 89 | Self::RetryAfter(_) => None, 90 | } 91 | } 92 | 93 | /// Get `remaining` value. 94 | /// 95 | /// This is the number of requests remaining in the current time window. 96 | pub const fn remaining(&self) -> Option { 97 | match self { 98 | Self::Rfc6585(rfc6585) => Some(rfc6585.remaining), 99 | Self::RetryAfter(_) => None, 100 | } 101 | } 102 | } 103 | 104 | impl FromStr for RateLimit { 105 | type Err = Error; 106 | 107 | fn from_str(map: &str) -> Result { 108 | RateLimit::new(map) 109 | } 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use super::*; 115 | use indoc::indoc; 116 | use std::str::FromStr; 117 | use time::macros::datetime; 118 | 119 | use crate::reset_time::ResetTime; 120 | 121 | #[test] 122 | fn use_later_reset_time_date() { 123 | let headers = indoc! {" 124 | X-Ratelimit-Used: 100 125 | X-Ratelimit-Remaining: 22 126 | X-Ratelimit-Reset: 30 127 | Retry-After: Wed, 21 Oct 2015 07:28:00 GMT 128 | "}; 129 | 130 | let rate = RateLimit::from_str(headers).unwrap(); 131 | assert_eq!( 132 | rate.reset(), 133 | ResetTime::DateTime(datetime!(2015-10-21 7:28:00.0 UTC)) 134 | ); 135 | } 136 | 137 | #[test] 138 | fn use_later_reset_time_seconds() { 139 | let headers = indoc! {" 140 | X-Ratelimit-Used: 100 141 | X-Ratelimit-Remaining: 22 142 | X-Ratelimit-Reset: 30 143 | Retry-After: 20 144 | "}; 145 | 146 | let rate = RateLimit::from_str(headers).unwrap(); 147 | assert_eq!(rate.reset(), ResetTime::Seconds(30)); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/casesensitive_headermap.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::str::FromStr; 3 | 4 | use crate::error::{Error, Result}; 5 | use headers::{HeaderMap, HeaderName, HeaderValue}; 6 | 7 | const HEADER_SEPARATOR: &str = ":"; 8 | 9 | /// A case-sensitive header map. 10 | /// 11 | /// This is a wrapper around `std::collections::HashMap` that is used to store 12 | /// HTTP headers. The difference is that this map is case-sensitive. 13 | /// 14 | /// This is required because some vendors use the same headers 15 | /// and the only way to differentiate them is by the case. 16 | #[derive(Clone, Debug, PartialEq, Eq)] 17 | pub struct CaseSensitiveHeaderMap { 18 | inner: HashMap, 19 | } 20 | 21 | impl Default for CaseSensitiveHeaderMap { 22 | fn default() -> Self { 23 | Self::new() 24 | } 25 | } 26 | 27 | impl CaseSensitiveHeaderMap { 28 | /// Create a new `CaseSensitiveHeaderMap`. 29 | pub fn new() -> Self { 30 | Self { 31 | inner: HashMap::new(), 32 | } 33 | } 34 | 35 | /// Insert a new header. 36 | pub fn insert(&mut self, name: String, value: HeaderValue) -> Option { 37 | self.inner.insert(name, value) 38 | } 39 | 40 | /// Get a header. 41 | pub fn get(&self, k: &str) -> Option<&HeaderValue> { 42 | self.inner.get(k) 43 | } 44 | } 45 | 46 | impl FromStr for CaseSensitiveHeaderMap { 47 | type Err = Error; 48 | 49 | fn from_str(headers: &str) -> Result { 50 | Ok(CaseSensitiveHeaderMap { 51 | inner: headers 52 | .lines() 53 | .filter_map(|line| line.split_once(HEADER_SEPARATOR)) 54 | .map(|(header, value)| { 55 | ( 56 | header.to_string(), 57 | HeaderValue::from_str(value.trim()).unwrap(), 58 | ) 59 | }) 60 | .collect(), 61 | }) 62 | } 63 | } 64 | 65 | impl From<&str> for CaseSensitiveHeaderMap { 66 | fn from(headers: &str) -> Self { 67 | CaseSensitiveHeaderMap::from_str(headers).unwrap() 68 | } 69 | } 70 | 71 | impl From for CaseSensitiveHeaderMap { 72 | fn from(headers: HeaderMap) -> Self { 73 | let mut cs_map = CaseSensitiveHeaderMap::new(); 74 | for (name, value) in headers.iter() { 75 | cs_map.insert(name.as_str().to_string(), value.clone()); 76 | } 77 | cs_map 78 | } 79 | } 80 | 81 | impl From<&HeaderMap> for CaseSensitiveHeaderMap { 82 | fn from(headers: &HeaderMap) -> Self { 83 | let mut cs_map = CaseSensitiveHeaderMap::new(); 84 | for (name, value) in headers.iter() { 85 | cs_map.insert(name.as_str().to_string(), value.clone()); 86 | } 87 | cs_map 88 | } 89 | } 90 | 91 | /// Extension trait for `HeaderMap` to convert from raw string. 92 | pub(crate) trait HeaderMapExt { 93 | /// Convert from raw string. 94 | fn from_raw(raw: &str) -> Result; 95 | } 96 | 97 | impl HeaderMapExt for HeaderMap { 98 | fn from_raw(raw: &str) -> Result { 99 | let mut headers = HeaderMap::new(); 100 | 101 | for line in raw.lines() { 102 | if !line.contains(HEADER_SEPARATOR) { 103 | return Err(Error::HeaderWithoutColon(line.to_string())); 104 | } 105 | if let Some((name, value)) = line.split_once(HEADER_SEPARATOR) { 106 | headers.insert( 107 | HeaderName::from_str(name)?, 108 | HeaderValue::from_str(value.trim())?, 109 | ); 110 | } 111 | } 112 | Ok(headers) 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | use super::*; 119 | 120 | #[test] 121 | fn test_convert_from_header_map() { 122 | let mut headers = HeaderMap::new(); 123 | headers.insert("X-RateLimit-Limit", "100".parse().unwrap()); 124 | headers.insert("X-RateLimit-Remaining", "99".parse().unwrap()); 125 | headers.insert("X-RateLimit-Reset", "1234567890".parse().unwrap()); 126 | 127 | let cs_headers = CaseSensitiveHeaderMap::from(&headers); 128 | assert_eq!( 129 | cs_headers, 130 | CaseSensitiveHeaderMap { 131 | inner: vec![ 132 | ( 133 | "x-ratelimit-limit".to_string(), 134 | HeaderValue::from_static("100") 135 | ), 136 | ( 137 | "x-ratelimit-remaining".to_string(), 138 | HeaderValue::from_static("99") 139 | ), 140 | ( 141 | "x-ratelimit-reset".to_string(), 142 | HeaderValue::from_static("1234567890") 143 | ) 144 | ] 145 | .into_iter() 146 | .collect() 147 | } 148 | ); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/headers/variants.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | 3 | use crate::reset_time::ResetTimeKind; 4 | 5 | use super::types::{RateLimitVariant, Vendor}; 6 | use time::Duration; 7 | 8 | /// Different types of rate-limit headers 9 | /// 10 | /// Variants will be checked in order. 11 | /// The casing of header names is significant to separate between different 12 | /// vendors 13 | pub(crate) static RATE_LIMIT_HEADERS: Lazy> = Lazy::new(|| { 14 | vec![ 15 | // Headers as defined in https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html 16 | // RateLimit-Limit: Holds the requests quota in the time window; 17 | // RateLimit-Remaining: Holds the remaining requests quota in the current window; 18 | // RateLimit-Reset: Holds the time remaining in the current window, specified in seconds or as a timestamp; 19 | RateLimitVariant::new( 20 | Vendor::Standard, 21 | None, 22 | Some("RateLimit-Limit".to_string()), 23 | None, 24 | "Ratelimit-Remaining".to_string(), 25 | "Ratelimit-Reset".to_string(), 26 | ResetTimeKind::Seconds, 27 | ), 28 | // Reddit (https://www.reddit.com/r/redditdev/comments/1yxrp7/formal_ratelimiting_headers/) 29 | // X-Ratelimit-Used Approximate number of requests used in this period 30 | // X-Ratelimit-Remaining Approximate number of requests left to use 31 | // X-Ratelimit-Reset Approximate number of seconds to end of period 32 | RateLimitVariant::new( 33 | Vendor::Reddit, 34 | Some(Duration::minutes(10)), 35 | None, 36 | Some("X-Ratelimit-Used".to_string()), 37 | "X-Ratelimit-Remaining".to_string(), 38 | "X-Ratelimit-Reset".to_string(), 39 | ResetTimeKind::Seconds, 40 | ), 41 | // Github (https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limit-http-headers) 42 | // x-ratelimit-limit The maximum number of requests you're permitted to make per hour. 43 | // x-ratelimit-remaining The number of requests remaining in the current rate limit window. 44 | // x-ratelimit-reset The time at which the current rate limit window resets in UTC epoch seconds. 45 | RateLimitVariant::new( 46 | Vendor::Github, 47 | Some(Duration::HOUR), 48 | Some("x-ratelimit-limit".to_string()), 49 | None, 50 | "x-ratelimit-remaining".to_string(), 51 | "x-ratelimit-reset".to_string(), 52 | ResetTimeKind::Timestamp, 53 | ), 54 | // Twitter (https://developer.twitter.com/en/docs/twitter-api/rate-limits) 55 | // x-rate-limit-limit: the rate limit ceiling for that given endpoint 56 | // x-rate-limit-remaining: the number of requests left for the 15-minute window 57 | // x-rate-limit-reset: the remaining window before the rate limit resets, in UTC epoch seconds 58 | RateLimitVariant::new( 59 | Vendor::Twitter, 60 | Some(Duration::minutes(15)), 61 | Some("x-rate-limit-limit".to_string()), 62 | None, 63 | "x-rate-limit-remaining".to_string(), 64 | "x-rate-limit-reset".to_string(), 65 | ResetTimeKind::Timestamp, 66 | ), 67 | // Vimeo (https://developer.vimeo.com/guidelines/rate-limiting) 68 | // X-RateLimit-Limit The maximum number of API responses that the requester can make through your app in any given 60-second period.* 69 | // X-RateLimit-Remaining The remaining number of API responses that the requester can make through your app in the current 60-second period.* 70 | // X-RateLimit-Reset A datetime value indicating when the next 60-second period begins. 71 | RateLimitVariant::new( 72 | Vendor::Vimeo, 73 | Some(Duration::seconds(60)), 74 | Some("X-RateLimit-Limit".to_string()), 75 | None, 76 | "X-RateLimit-Remaining".to_string(), 77 | "X-RateLimit-Reset".to_string(), 78 | ResetTimeKind::ImfFixdate, 79 | ), 80 | // Gitlab (https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#response-headers) 81 | // RateLimit-Limit: The request quota for the client each minute. 82 | // RateLimit-Observed Number of requests associated to the client in the time window. 83 | // RateLimit-Remaining: Remaining quota in the time window. The result of RateLimit-Limit - RateLimit-Observed. 84 | // RateLimit-Reset: Unix time-formatted time when the request quota is reset. 85 | RateLimitVariant::new( 86 | Vendor::Gitlab, 87 | Some(Duration::seconds(60)), 88 | Some("RateLimit-Limit".to_string()), 89 | Some("RateLimit-Observed".to_string()), 90 | "RateLimit-Remaining".to_string(), 91 | "RateLimit-Reset".to_string(), 92 | ResetTimeKind::Timestamp, 93 | ), 94 | // Akamai (https://techdocs.akamai.com/adaptive-media-delivery/reference/rate-limiting) 95 | // X-RateLimit-Limit: 60 requests per minute. 96 | // X-RateLimit-Remaining: Number of remaining requests allowed during the period. 97 | // X-RateLimit-Next: Once the X-RateLimit-Limit has been reached, this represents the time you can issue another individual request. The X-RateLimit-Remaining gradually increases and becomes equal to X-RateLimit-Limit again. 98 | RateLimitVariant::new( 99 | Vendor::Akamai, 100 | Some(Duration::seconds(60)), 101 | Some("X-RateLimit-Limit".to_string()), 102 | None, 103 | "X-RateLimit-Remaining".to_string(), 104 | "X-RateLimit-Next".to_string(), 105 | ResetTimeKind::Iso8601, 106 | ), 107 | ] 108 | }); 109 | -------------------------------------------------------------------------------- /src/headers/mod.rs: -------------------------------------------------------------------------------- 1 | //! Rate limit headers as defined in [RFC 6585](https://tools.ietf.org/html/rfc6585) 2 | //! and [draft-polli-ratelimit-headers-00][draft]. 3 | mod types; 4 | mod variants; 5 | 6 | use std::str::FromStr; 7 | 8 | use crate::{ 9 | casesensitive_headermap::CaseSensitiveHeaderMap, 10 | reset_time::{ResetTime, ResetTimeKind}, 11 | }; 12 | 13 | use super::error::{Error, Result}; 14 | use headers::HeaderValue; 15 | use variants::RATE_LIMIT_HEADERS; 16 | 17 | use time::Duration; 18 | use types::Used; 19 | pub use types::Vendor; 20 | pub(crate) use types::{Limit, RateLimitVariant, Remaining}; 21 | 22 | /// HTTP rate limits as parsed from header values 23 | #[derive(Copy, Clone, Debug, PartialEq)] 24 | pub struct Headers { 25 | /// The maximum number of requests allowed in the time window 26 | pub limit: usize, 27 | /// The number of requests remaining in the time window 28 | pub remaining: usize, 29 | /// The time at which the rate limit will be reset 30 | pub reset: ResetTime, 31 | /// The time window until the rate limit is lifted. 32 | /// It is optional, because it might not be given, 33 | /// in which case it needs to be inferred from the environment 34 | pub window: Option, 35 | /// Predicted vendor based on rate limit header 36 | pub vendor: Vendor, 37 | } 38 | 39 | impl Headers { 40 | /// Extracts rate limits from `Rate-Limit-...` HTTP headers separated by 41 | /// newlines. These rate limits are commonly used by APIs. 42 | /// 43 | /// There are different header names for various websites 44 | /// Github, Vimeo, Twitter, Imgur, etc have their own headers. 45 | /// Without additional context, the parsing is done on a best-effort basis. 46 | /// 47 | /// # Errors 48 | /// 49 | /// This function returns an error if the given header map does not contain 50 | /// all required headers or if the header values cannot be parsed. 51 | pub fn new>(headers: T) -> std::result::Result { 52 | let headers = headers.into(); 53 | let value = Self::get_remaining(&headers)?; 54 | let remaining = Remaining::new(value.to_str()?)?; 55 | 56 | let (limit, variant) = if let Ok((limit, variant)) = Self::get_rate_limit(&headers) { 57 | (Limit::new(limit.to_str()?)?, variant) 58 | } else if let Ok((used, variant)) = Self::get_used(&headers) { 59 | // The site provides a `used` header, but no `limit` header. 60 | // Therefore we have to calculate the limit from used and remaining. 61 | let used = Used::new(used.to_str()?)?; 62 | let limit = used.count + remaining.count; 63 | (Limit::from(limit), variant) 64 | } else { 65 | return Err(Error::MissingUsed); 66 | }; 67 | 68 | let (value, kind) = Self::get_reset(&headers)?; 69 | let reset = ResetTime::new(value, kind)?; 70 | 71 | Ok(Headers { 72 | limit: limit.count, 73 | remaining: remaining.count, 74 | reset, 75 | window: variant.duration, 76 | vendor: variant.vendor, 77 | }) 78 | } 79 | 80 | /// Get the number of requests allowed in the time window 81 | /// from the given header map 82 | fn get_rate_limit( 83 | header_map: &CaseSensitiveHeaderMap, 84 | ) -> Result<(&HeaderValue, RateLimitVariant)> { 85 | let variants = &RATE_LIMIT_HEADERS; 86 | 87 | for variant in variants.iter() { 88 | if let Some(limit) = &variant.limit_header { 89 | if let Some(value) = header_map.get(limit) { 90 | return Ok((value, variant.clone())); 91 | } 92 | } 93 | } 94 | Err(Error::MissingLimit) 95 | } 96 | 97 | /// Get the number of requests used in the time window 98 | /// from the given header map 99 | fn get_used(header_map: &CaseSensitiveHeaderMap) -> Result<(&HeaderValue, RateLimitVariant)> { 100 | let variants = &RATE_LIMIT_HEADERS; 101 | 102 | for variant in variants.iter() { 103 | if let Some(used) = &variant.used_header { 104 | if let Some(value) = header_map.get(used) { 105 | return Ok((value, variant.clone())); 106 | } 107 | } 108 | } 109 | Err(Error::MissingUsed) 110 | } 111 | 112 | /// Get the number of requests remaining in the time window 113 | /// from the given header map 114 | fn get_remaining(header_map: &CaseSensitiveHeaderMap) -> Result<&HeaderValue> { 115 | let variants = &RATE_LIMIT_HEADERS; 116 | 117 | for variant in variants.iter() { 118 | if let Some(value) = header_map.get(&variant.remaining_header) { 119 | return Ok(value); 120 | } 121 | } 122 | Err(Error::MissingRemaining) 123 | } 124 | 125 | /// Get the time at which the rate limit will be reset 126 | /// from the given header map 127 | fn get_reset(header_map: &CaseSensitiveHeaderMap) -> Result<(&HeaderValue, ResetTimeKind)> { 128 | let variants = &RATE_LIMIT_HEADERS; 129 | 130 | for variant in variants.iter() { 131 | if let Some(value) = header_map.get(&variant.reset_header) { 132 | return Ok((value, variant.reset_kind)); 133 | } 134 | } 135 | Err(Error::MissingReset) 136 | } 137 | 138 | /// Get the number of requests allowed in the time window 139 | #[must_use] 140 | pub const fn limit(&self) -> usize { 141 | self.limit 142 | } 143 | 144 | /// Get the number of requests remaining in the time window 145 | #[must_use] 146 | pub const fn remaining(&self) -> usize { 147 | self.remaining 148 | } 149 | 150 | /// Get the time at which the rate limit will be reset 151 | #[must_use] 152 | pub const fn reset(&self) -> ResetTime { 153 | self.reset 154 | } 155 | } 156 | 157 | impl FromStr for Headers { 158 | type Err = Error; 159 | 160 | fn from_str(map: &str) -> Result { 161 | Headers::new(CaseSensitiveHeaderMap::from_str(map)?) 162 | } 163 | } 164 | 165 | #[cfg(test)] 166 | mod tests { 167 | use super::*; 168 | use crate::casesensitive_headermap::HeaderMapExt; 169 | use headers::HeaderMap; 170 | use indoc::indoc; 171 | use time::{macros::datetime, OffsetDateTime}; 172 | 173 | #[test] 174 | fn parse_limit_value() { 175 | let limit = Limit::new(" 23 ").unwrap(); 176 | assert_eq!(limit.count, 23); 177 | } 178 | 179 | #[test] 180 | fn parse_invalid_limit_value() { 181 | assert!(Limit::new("foo").is_err()); 182 | assert!(Limit::new("0 foo").is_err()); 183 | assert!(Limit::new("bar 0").is_err()); 184 | } 185 | 186 | #[test] 187 | fn parse_vendor() { 188 | let map = CaseSensitiveHeaderMap::from_str("x-ratelimit-limit: 5000").unwrap(); 189 | let (_, variant) = Headers::get_rate_limit(&map).unwrap(); 190 | assert_eq!(variant.vendor, Vendor::Github); 191 | 192 | let map = CaseSensitiveHeaderMap::from_str("RateLimit-Limit: 5000").unwrap(); 193 | let (_, variant) = Headers::get_rate_limit(&map).unwrap(); 194 | assert_eq!(variant.vendor, Vendor::Standard); 195 | } 196 | 197 | #[test] 198 | fn parse_remaining_value() { 199 | let remaining = Remaining::new(" 23 ").unwrap(); 200 | assert_eq!(remaining.count, 23); 201 | } 202 | 203 | #[test] 204 | fn parse_invalid_remaining_value() { 205 | assert!(Remaining::new("foo").is_err()); 206 | assert!(Remaining::new("0 foo").is_err()); 207 | assert!(Remaining::new("bar 0").is_err()); 208 | } 209 | 210 | #[test] 211 | fn parse_reset_timestamp() { 212 | let v = HeaderValue::from_str("1350085394").unwrap(); 213 | assert_eq!( 214 | ResetTime::new(&v, ResetTimeKind::Timestamp).unwrap(), 215 | ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_350_085_394).unwrap()) 216 | ); 217 | } 218 | 219 | #[test] 220 | fn parse_reset_seconds() { 221 | let v = HeaderValue::from_str("100").unwrap(); 222 | assert_eq!( 223 | ResetTime::new(&v, ResetTimeKind::Seconds).unwrap(), 224 | ResetTime::Seconds(100) 225 | ); 226 | } 227 | 228 | #[test] 229 | fn parse_reset_datetime() { 230 | let v = HeaderValue::from_str("Tue, 15 Nov 1994 08:12:31 GMT").unwrap(); 231 | let d = ResetTime::new(&v, ResetTimeKind::ImfFixdate); 232 | assert_eq!( 233 | d.unwrap(), 234 | ResetTime::DateTime(datetime!(1994-11-15 8:12:31 UTC)) 235 | ); 236 | } 237 | 238 | #[test] 239 | fn parse_header_map_newlines() { 240 | let map = HeaderMap::from_raw( 241 | "x-ratelimit-limit: 5000 242 | x-ratelimit-remaining: 4987 243 | x-ratelimit-reset: 1350085394 244 | ", 245 | ) 246 | .unwrap(); 247 | 248 | assert_eq!(map.len(), 3); 249 | assert_eq!( 250 | map.get("x-ratelimit-limit"), 251 | Some(&HeaderValue::from_str("5000").unwrap()) 252 | ); 253 | assert_eq!( 254 | map.get("x-ratelimit-remaining"), 255 | Some(&HeaderValue::from_str("4987").unwrap()) 256 | ); 257 | assert_eq!( 258 | map.get("x-ratelimit-reset"), 259 | Some(&HeaderValue::from_str("1350085394").unwrap()) 260 | ); 261 | } 262 | 263 | #[test] 264 | fn parse_github_headers() { 265 | let headers = indoc! {" 266 | x-ratelimit-limit: 5000 267 | x-ratelimit-remaining: 4987 268 | x-ratelimit-reset: 1350085394 269 | "}; 270 | 271 | let rate = Headers::from_str(headers).unwrap(); 272 | assert_eq!(rate.limit(), 5000); 273 | assert_eq!(rate.remaining(), 4987); 274 | assert_eq!( 275 | rate.reset(), 276 | ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_350_085_394).unwrap()) 277 | ); 278 | } 279 | 280 | #[test] 281 | fn parse_reddit_headers() { 282 | let headers = indoc! {" 283 | X-Ratelimit-Used: 100 284 | X-Ratelimit-Remaining: 22 285 | X-Ratelimit-Reset: 30 286 | "}; 287 | 288 | let rate = Headers::from_str(headers).unwrap(); 289 | assert_eq!(rate.limit(), 122); 290 | assert_eq!(rate.remaining(), 22); 291 | assert_eq!(rate.reset(), ResetTime::Seconds(30)); 292 | } 293 | 294 | #[test] 295 | fn parse_gitlab_headers() { 296 | let headers = indoc! {" 297 | RateLimit-Limit: 60 298 | RateLimit-Observed: 67 299 | RateLimit-Remaining: 0 300 | RateLimit-Reset: 1609844400 301 | "}; 302 | 303 | let rate = Headers::from_str(headers).unwrap(); 304 | assert_eq!(rate.limit(), 60); 305 | assert_eq!(rate.remaining(), 0); 306 | assert_eq!( 307 | rate.reset(), 308 | ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_609_844_400).unwrap()) 309 | ); 310 | } 311 | } 312 | --------------------------------------------------------------------------------