Skip to main content

esi_openapi/
client.rs

1//! Main logic
2
3use crate::{
4    client::ErrorLimitStatus::{Limited, NotLimited},
5    groups::*,
6    legacy,
7    pkce::{self, PkceVerifier},
8    prelude::*,
9    spec::Spec,
10};
11use base64::engine::{general_purpose::STANDARD as base64, Engine};
12use log::{debug, error, warn};
13#[cfg(feature = "random_state")]
14use rand::{distr::Alphanumeric, RngExt};
15use reqwest::{
16    header::{self, HeaderMap, HeaderValue},
17    Client, Method,
18};
19use serde::de::DeserializeOwned;
20use std::{
21    collections::HashMap,
22    str::FromStr,
23    sync::Arc,
24    time::{SystemTime, UNIX_EPOCH},
25};
26use tokio::sync::RwLock;
27
28const BASE_URL: &str = "https://esi.evetech.net/";
29const AUTHORIZE_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
30const TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
31const SPEC_URL: &str = "https://esi.evetech.net/meta/openapi.json";
32const ERROR_LIMIT_REMAIN_HEADER: &str = "x-esi-error-limit-remain";
33const ERROR_LIMIT_RESET_HEADER: &str = "x-esi-error-limit-reset";
34const RATE_LIMIT_GROUP_HEADER: &str = "x-ratelimit-group";
35const RATE_LIMIT_LIMIT_HEADER: &str = "x-ratelimit-limit";
36const RATE_LIMIT_REMAINING_HEADER: &str = "x-ratelimit-remaining";
37const RATE_LIMIT_USED_HEADER: &str = "x-ratelimit-used";
38
39static COMPATIBILITY_HEADER: &str = "X-Compatibility-Date";
40/// The default compatibility date to use if none is specified
41/// in the builder: the latest date listed by
42/// `https://esi.evetech.net/meta/compatibility-dates` when this
43/// version was released.
44pub const COMPATIBILITY_DATE_DEFAULT: &str = "2026-08-18";
45
46/// Response from SSO when exchanging a SSO code for tokens.
47#[derive(Debug, Deserialize)]
48struct AuthenticateResponse {
49    access_token: String,
50    expires_in: u64,
51    refresh_token: Option<String>,
52}
53
54/// Response from SSO when exchanging a refresh token for access token.
55#[derive(Debug, Deserialize)]
56struct RefreshTokenAuthenticateResponse {
57    access_token: String,
58    expires_in: u64,
59    refresh_token: String,
60}
61
62#[derive(Copy, Clone, Debug)]
63struct ErrorLimitState {
64    remaining_limit: i32,
65    expires_at_millis: i64,
66}
67
68/// Whether ESI's legacy error limit (`X-Esi-Error-Limit-*` headers) is
69/// currently blocking requests from this client.
70#[derive(Copy, Clone, Debug)]
71pub enum ErrorLimitStatus {
72    /// Too many errors: requests are refused until the window resets.
73    Limited {
74        /// Milliseconds until the error-limit window resets.
75        for_millis: i64,
76    },
77    /// Requests may be made.
78    NotLimited,
79}
80
81/// Latest rate-limit state ESI reported for one route group.
82///
83/// ESI uses a floating-window token bucket per application/character
84/// pair and route group; see the [ESI rate limiting docs]. The values
85/// are those of the most recent response for the group.
86///
87/// [ESI rate limiting docs]: https://developers.eveonline.com/docs/services/esi/rate-limiting/
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct RateLimitStatus {
90    /// Route group, from `X-Ratelimit-Group`.
91    pub group: String,
92    /// Raw `X-Ratelimit-Limit` value, e.g. `150/15m`.
93    pub limit: String,
94    /// Tokens per window, parsed from `limit` (e.g. `150`).
95    pub max_tokens: Option<u64>,
96    /// Window length in seconds, parsed from `limit` (e.g. `900` for `15m`).
97    pub window_secs: Option<u64>,
98    /// Tokens left in the window, from `X-Ratelimit-Remaining`.
99    pub remaining: i64,
100    /// Tokens consumed by the request that returned these headers,
101    /// from `X-Ratelimit-Used`.
102    pub used: i64,
103    /// Millisecond unix timestamp of the response these values came from.
104    pub updated_at_millis: i64,
105}
106
107/// Parse an `X-Ratelimit-Limit` value such as `150/15m` into
108/// `(tokens, window_secs)`.
109fn parse_rate_limit(value: &str) -> (Option<u64>, Option<u64>) {
110    let Some((tokens, window)) = value.trim().split_once('/') else {
111        return (value.trim().parse().ok(), None);
112    };
113    let tokens = tokens.trim().parse().ok();
114    let window = window.trim();
115    let split = window
116        .find(|c: char| !c.is_ascii_digit())
117        .unwrap_or(window.len());
118    let (amount, unit) = window.split_at(split);
119    let amount: Option<u64> = amount.parse().ok();
120    let multiplier = match unit {
121        "" | "s" => Some(1),
122        "m" => Some(60),
123        "h" => Some(3600),
124        "d" => Some(86_400),
125        _ => None,
126    };
127    let window_secs = amount.zip(multiplier).map(|(a, m)| a * m);
128    (tokens, window_secs)
129}
130
131/// Which base URL to start with - the public URL for unauthenticated
132/// calls, or the authenticated URL for making calls to endpoints that
133/// require an access token.
134#[derive(Debug, PartialEq, Eq)]
135pub enum RequestType {
136    /// Endpoints that do not require authentication
137    Public,
138    /// Endpoints that require acting on behalf of an authenticated character
139    Authenticated,
140}
141
142/// AuthenticationInformation contains data needed to complete the requested authentication flow.
143pub struct AuthenticationInformation {
144    /// URL to call/pass to users to initiate an authentication and get an auth code from ESI.
145    pub authorization_url: String,
146    /// If the default feature "random_state" is enabled, the returned state field string will be
147    /// random; otherwise it'll be "esi_openapi_unused". The ESI docs link to
148    /// [this auth0 page](https://auth0.com/docs/secure/attack-protection/state-parameters)
149    /// to explain. You need to check the state yourself when the response from ESI is received.
150    pub state: String,
151    /// Filled if you've selected PKCE authentication for application.
152    /// You will need it to authenticate using the code received from ESI.
153    pub pkce_verifier: Option<PkceVerifier>,
154}
155
156/// Struct to interact with ESI.
157///
158/// Construct an instance of this struct using [`EsiBuilder`](./struct.EsiBuilder.html).
159///
160/// # Example
161/// ```rust,no_run
162/// use esi_openapi::prelude::EsiBuilder;
163/// // the struct must be mutable for some functionality
164/// let mut esi = EsiBuilder::new()
165///     .user_agent("some user agent")
166///     .client_id("your_client_id")
167///     .client_secret("your_client_secret")
168///     .callback_url("your_callback_url")
169///     .build()
170///     .unwrap();
171/// ```
172#[derive(Clone, Debug)]
173pub struct Esi {
174    pub(crate) compatibility_date: String,
175    pub(crate) client_id: Option<String>,
176    pub(crate) client_secret: Option<String>,
177    pub(crate) callback_url: Option<String>,
178    pub(crate) base_api_url: String,
179    pub(crate) authorize_url: String,
180    pub(crate) token_url: String,
181    pub(crate) spec_url: String,
182    pub(crate) scope: String,
183    pub(crate) application_auth: bool,
184    /// The access token from ESI, if set.
185    pub access_token: Option<String>,
186    /// The millisecond unix timestamp after which the access token expires, if present.
187    pub access_expiration: Option<i64>,
188    /// The refresh token from ESI, if set.
189    pub refresh_token: Option<String>,
190    /// HTTP client
191    pub(crate) client: Client,
192    pub(crate) spec: Option<Spec>,
193    /// `operationId` -> URL path, built from `spec`.
194    op_index: HashMap<String, String>,
195    error_limit_state: Arc<RwLock<Option<ErrorLimitState>>>,
196    rate_limits: Arc<RwLock<HashMap<String, RateLimitStatus>>>,
197}
198
199impl Esi {
200    /// Consume the builder, creating an instance of this struct.
201    pub(crate) fn from_builder(builder: EsiBuilder) -> EsiResult<Self> {
202        let client = builder.construct_client()?;
203        let compatibility_date = builder
204            .compatibility_date
205            .unwrap_or_else(|| COMPATIBILITY_DATE_DEFAULT.to_owned());
206        let op_index = builder
207            .spec
208            .as_ref()
209            .map(Spec::operation_index)
210            .unwrap_or_default();
211        let e = Esi {
212            compatibility_date: compatibility_date.clone(),
213            client_id: builder.client_id,
214            client_secret: builder.client_secret,
215            callback_url: builder.callback_url,
216            base_api_url: builder.base_api_url.unwrap_or(BASE_URL.to_string()),
217            authorize_url: builder.authorize_url.unwrap_or(AUTHORIZE_URL.to_string()),
218            token_url: builder.token_url.unwrap_or(TOKEN_URL.to_string()),
219            spec_url: builder.spec_url.unwrap_or(SPEC_URL.to_string()),
220            scope: builder.scope.unwrap_or_else(|| "".to_owned()),
221            application_auth: builder.application_auth.unwrap_or(false),
222            access_token: builder.access_token,
223            access_expiration: builder.access_expiration,
224            refresh_token: builder.refresh_token,
225            client,
226            spec: builder.spec,
227            op_index,
228            error_limit_state: Arc::new(RwLock::new(None)),
229            rate_limits: Arc::new(RwLock::new(HashMap::new())),
230        };
231        Ok(e)
232    }
233
234    /// Get the OpenAPI spec from ESI and store it in this struct.
235    ///
236    /// The spec is requested with this struct's compatibility date
237    /// (`X-Compatibility-Date`), so the paths match the API version
238    /// that later requests will use.
239    ///
240    /// If you are making use of the `try_get_endpoint_for_op_id`,
241    /// then this function will be called there when needed
242    /// (which should only really be when the struct is
243    /// constructed unless the struct is kept in memory for a very
244    /// long time). When using `get_endpoint_for_op_id` however,
245    /// you are responsible for calling this function beforehand.
246    ///
247    /// # Example
248    /// ```rust,no_run
249    /// # async fn run() {
250    /// # use esi_openapi::prelude::*;
251    /// # let mut esi = EsiBuilder::new()
252    /// #     .user_agent("some user agent")
253    /// #     .client_id("your_client_id")
254    /// #     .client_secret("your_client_secret")
255    /// #     .callback_url("your_callback_url")
256    /// #     .build()
257    /// #     .unwrap();
258    /// esi.update_spec().await.unwrap();
259    /// # }
260    pub async fn update_spec(&mut self) -> EsiResult<()> {
261        debug!(
262            "Updating spec with compatibility date {}",
263            self.compatibility_date
264        );
265        self.assert_not_error_limited().await?;
266        let resp = self
267            .client
268            .get(&self.spec_url)
269            .header(
270                COMPATIBILITY_HEADER,
271                HeaderValue::from_str(&self.compatibility_date)?,
272            )
273            .send()
274            .await?;
275        self.process_response_headers(resp.headers()).await?;
276        if !resp.status().is_success() {
277            error!("Got status {} when requesting spec", resp.status());
278            return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
279        }
280        let data: Spec = resp.json().await?;
281        self.op_index = data.operation_index();
282        self.spec = Some(data);
283        Ok(())
284    }
285
286    /// Ensure the user has specified all required EVE Developer App information.
287    fn check_client_info(&self) -> EsiResult<()> {
288        for (name, value) in &[
289            ("client_id", &self.client_id),
290            ("callback_url", &self.callback_url),
291        ] {
292            if value.is_none() {
293                return Err(EsiError::EmptyClientValue(name.to_string()));
294            }
295        }
296
297        if self.client_secret.is_none() {
298            if !self.application_auth {
299                return Err(EsiError::MissingAuthenticationFlowInformation);
300            }
301        } else if self.application_auth {
302            return Err(EsiError::MissingAuthenticationFlowInformation);
303        }
304
305        Ok(())
306    }
307
308    /// Generate and return the URL required for the user to grant you an auth code, as wells as
309    /// infos for future authentication request.
310    ///
311    /// You can inspect the URL returned by ESI to your web service to ensure it matches.
312    /// No checking is done by `esi-openapi`.
313    ///
314    /// # Example
315    /// ```rust,no_run
316    /// # use esi_openapi::prelude::*;
317    /// # let mut esi = EsiBuilder::new()
318    /// #     .user_agent("some user agent")
319    /// #     .client_id("your_client_id")
320    /// #     .client_secret("your_client_secret")
321    /// #     .callback_url("your_callback_url")
322    /// #     .build()
323    /// #     .unwrap();
324    /// let auth_info = esi.get_authorize_url().unwrap();
325    /// // then send your user to that URL
326    /// let url = auth_info.authorization_url;
327    /// ```
328    ///
329    /// If you opted to not include client information in
330    /// the EsiBuilder flow, then this function will return
331    /// an error instead.
332    ///
333    /// [this auth0 page]: https://auth0.com/docs/secure/attack-protection/state-parameters
334    pub fn get_authorize_url(&self) -> EsiResult<AuthenticationInformation> {
335        self.check_client_info()?;
336        #[cfg(feature = "random_state")]
337        let state = rand::rng()
338            .sample_iter(&Alphanumeric)
339            .take(10)
340            .map(char::from)
341            .collect();
342        #[cfg(not(feature = "random_state"))]
343        let state = "esi_openapi_unused".to_string();
344        let mut url = format!(
345            "{}?response_type=code&redirect_uri={}&client_id={}&scope={}&state={state}",
346            self.authorize_url,
347            self.callback_url.as_ref().unwrap(),
348            self.client_id.as_ref().unwrap(),
349            self.scope
350        );
351        let mut pkce_verifier = None;
352        // PKCE can be theoretically combined with client secret, but not sure if ESI supports it
353        if self.client_secret.is_none() && self.application_auth {
354            let pkce = pkce::generate()?;
355            pkce_verifier = Some(pkce.verifier);
356            url = format!(
357                "{}&code_challenge={}&code_challenge_method=S256",
358                url, pkce.challenge
359            )
360        }
361        Ok(AuthenticationInformation {
362            authorization_url: url,
363            state,
364            pkce_verifier,
365        })
366    }
367
368    fn get_auth_headers(&self) -> EsiResult<HeaderMap> {
369        self.check_client_info()?;
370        let mut map = HeaderMap::new();
371        if let Some(ref secret) = self.client_secret {
372            let value = base64
373                .encode(format!("{}:{secret}", self.client_id.as_ref().unwrap()))
374                .replace(['\n', ' '], "");
375            map.insert(
376                header::AUTHORIZATION,
377                HeaderValue::from_str(&format!("Basic {value}"))?,
378            );
379        }
380        map.insert(
381            header::HOST,
382            HeaderValue::from_static("login.eveonline.com"),
383        );
384        Ok(map)
385    }
386
387    /// Authenticate with ESI, exchanging a code from the authorize flow
388    /// for an access token that is used to make authenticated calls to ESI.
389    ///
390    /// Note that this is one of the functions that requires the struct be
391    /// mutable, as the struct mutates to include the resulting access token.
392    ///
393    /// If the "validate_jwt" feature is enabled (by default), then the access
394    /// token's claims will be returned. If the feature is not enabled, then
395    /// the returned value will be `None`.
396    ///
397    /// # Example (client secret)
398    /// ```rust,no_run
399    /// # async fn run() {
400    /// # use esi_openapi::prelude::*;
401    /// # let mut esi = EsiBuilder::new()
402    /// #     .user_agent("some user agent")
403    /// #     .client_id("your_client_id")
404    /// #     .client_secret("your_client_secret")
405    /// #     .callback_url("your_callback_url")
406    /// #     .build()
407    /// #     .unwrap();
408    /// let claims = esi.authenticate("abcdef...", None).await.unwrap();
409    /// # }
410    /// ```
411    ///
412    /// # Example (PKCE/Application authentication)
413    /// ```rust,no_run
414    /// # use esi_openapi::prelude::*;
415    ///  async fn run() {
416    /// # let mut esi = EsiBuilder::new()
417    /// #     .user_agent("some user agent")
418    /// #     .client_id("your_client_id")
419    /// #     .callback_url("your_callback_url")
420    /// #     .enable_application_authentication(true)
421    /// #     .build()
422    /// #     .unwrap();
423    /// # let auth_infos = esi.get_authorize_url().unwrap();
424    /// # let claims = esi.authenticate("abcdef...", auth_infos.pkce_verifier).await.unwrap();
425    /// # }
426    /// ```
427    pub async fn authenticate(
428        &mut self,
429        code: &str,
430        pkce_verifier: Option<PkceVerifier>,
431    ) -> EsiResult<Option<TokenClaims>> {
432        debug!("Authenticating with code {code}");
433        self.assert_not_error_limited().await?;
434        let mut body = HashMap::from([("grant_type", "authorization_code"), ("code", code)]);
435        if self.application_auth {
436            let option = self.client_id.as_ref();
437            body.insert("client_id", option.unwrap());
438            body.insert("code_verifier", pkce_verifier.as_ref().unwrap());
439        }
440
441        let resp = self
442            .client
443            .post(&self.token_url)
444            .headers(self.get_auth_headers()?)
445            .form(&body)
446            .send()
447            .await?;
448        if resp.status() != 200 {
449            warn!(
450                "Got status {} when making call to authenticate",
451                resp.status()
452            );
453            return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
454        }
455        self.process_error_limit_headers(resp.headers()).await?;
456        let data: AuthenticateResponse = resp.json().await?;
457        #[allow(unused_variables)]
458        let claim_data: Option<TokenClaims> = None;
459        #[cfg(feature = "validate_jwt")]
460        let claim_data = Some(
461            crate::jwt_util::validate_jwt(
462                &self.client,
463                &data.access_token,
464                self.client_id.as_ref().unwrap(),
465            )
466            .await?,
467        );
468        self.access_token = Some(data.access_token);
469        // the response's "expires_in" field is seconds but need millis
470        self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
471        self.refresh_token = data.refresh_token;
472        Ok(claim_data)
473    }
474
475    /// Authenticate via a previously-fetched refresh token.
476    ///
477    /// The functionality of a refresh token allows re-authenticating this struct
478    /// instance without prompting the user to log into EVE SSO again. When the user
479    /// is authenticated in that manner, a refresh token is returned and available
480    /// via the `refresh_token` struct field. Store this securely should you wish
481    /// to later make authenticate calls for that user.
482    ///
483    /// # Example
484    /// ```rust,no_run
485    /// # async fn run() {
486    /// # use esi_openapi::prelude::*;
487    /// # let mut esi = EsiBuilder::new()
488    /// #     .user_agent("some user agent")
489    /// #     .client_id("your_client_id")
490    /// #     .client_secret("your_client_secret")
491    /// #     .callback_url("your_callback_url")
492    /// #     .build()
493    /// #     .unwrap();
494    /// esi.use_refresh_token("abcdef...").await.unwrap();
495    /// # }
496    /// ```
497    pub async fn use_refresh_token(&mut self, refresh_token: &str) -> EsiResult<()> {
498        self.refresh_access_token(Some(refresh_token)).await?;
499        Ok(())
500    }
501
502    /// Authenticate via a refresh token given as input, or using the internal refresh_token if it's available.
503    ///
504    /// The functionality of a refresh token allows re-authenticating this struct
505    /// instance without prompting the user to log into EVE SSO again. When the user
506    /// is authenticated in that manner, a refresh token is returned and available
507    /// via the `refresh_token` struct field. Store this securely should you wish
508    /// to later make authenticate calls for that user.
509    ///
510    /// # Example with internal token
511    /// ```rust,no_run
512    /// # async fn run() {
513    /// # use esi_openapi::prelude::*;
514    /// # let mut esi = EsiBuilder::new()
515    /// #     .user_agent("some user agent")
516    /// #     .refresh_token(Some("MyRefreshToken"))
517    /// #     .build()
518    /// #     .unwrap();
519    /// esi.refresh_access_token(None).await.unwrap();
520    /// # }
521    /// ```
522    /// # Example with input token
523    /// ```rust,no_run
524    /// # async fn run() {
525    /// # use esi_openapi::prelude::*;
526    /// # let mut esi = EsiBuilder::new()
527    /// #     .user_agent("some user agent")
528    /// #     .build()
529    /// #     .unwrap();
530    /// esi.refresh_access_token(Some("MyRefreshToken")).await.unwrap();
531    /// # }
532    /// ```
533    pub async fn refresh_access_token(&mut self, refresh_token: Option<&str>) -> EsiResult<()> {
534        self.assert_not_error_limited().await?;
535        let token = if let Some(token) = refresh_token {
536            token.to_string()
537        } else if let Some(token) = self.refresh_token.clone() {
538            token
539        } else {
540            return Err(EsiError::NoRefreshTokenAvailable);
541        };
542
543        debug!("Authenticating with refresh token");
544        let mut body = HashMap::from([("grant_type", "refresh_token"), ("refresh_token", &token)]);
545        if self.application_auth {
546            let option = self.client_id.as_ref();
547            body.insert("client_id", option.unwrap());
548        }
549        let resp = self
550            .client
551            .post(&self.token_url)
552            .headers(self.get_auth_headers()?)
553            .form(&body)
554            .send()
555            .await?;
556        self.process_error_limit_headers(resp.headers()).await?;
557        if resp.status() != 200 {
558            warn!(
559                "Got status {} when making call to authenticate via a refresh token",
560                resp.status()
561            );
562            return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
563        }
564        let data: RefreshTokenAuthenticateResponse = resp.json().await?;
565        self.access_token = Some(data.access_token);
566        // the response's "expires_in" field is seconds, need millis
567        self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
568        self.refresh_token = Some(data.refresh_token);
569        Ok(())
570    }
571
572    /// Make a request to ESI.
573    ///
574    /// This is mainly used as the underlying function for this
575    /// library when making calls to ESI; the other functions that
576    /// you should primarily be using contain more functionality,
577    /// including matching endpoint with deserialization struct,
578    /// evaluating & replacing URL parameters, etc.
579    ///
580    /// In the event that there is not a wrapper function for the
581    /// endpoint that you want to use, you can use this function
582    /// to make an API call without waiting for the library to
583    /// be updated.
584    ///
585    /// # Example
586    /// ```rust,no_run
587    /// # async fn run() {
588    /// # use serde::Deserialize;
589    /// # use esi_openapi::prelude::*;
590    /// # let mut esi = EsiBuilder::new()
591    /// #     .user_agent("some user agent")
592    /// #     .client_id("your_client_id")
593    /// #     .client_secret("your_client_secret")
594    /// #     .callback_url("your_callback_url")
595    /// #     .build()
596    /// #     .unwrap();
597    /// #[derive(Deserialize)]
598    /// struct ReturnedData {}
599    /// let data: ReturnedData = esi.query("GET", RequestType::Public, "abc", None, None).await.unwrap();
600    /// # }
601    /// ```
602    pub async fn query<T: DeserializeOwned>(
603        &self,
604        method: &str,
605        request_type: RequestType,
606        endpoint: &str,
607        query: Option<&[(&str, &str)]>,
608        body: Option<&str>,
609    ) -> EsiResult<T> {
610        debug!("Making {request_type:?} {method} request to {endpoint} with query: {query:?}");
611        self.assert_not_error_limited().await?;
612        if request_type == RequestType::Authenticated {
613            if self.access_token.is_none() {
614                return Err(EsiError::MissingAuthentication);
615            }
616            if self.access_expiration.unwrap() < current_time_millis()? {
617                return Err(EsiError::AccessTokenExpired);
618            }
619        }
620        let headers = {
621            let mut map = HeaderMap::new();
622            // The 'user-agent' and 'content-type' headers are set in the default headers
623            // from the builder, so all that's required here is to set the authorization
624            // header, if present, and the compatibility date.
625            if request_type == RequestType::Authenticated {
626                if let Some(at) = &self.access_token {
627                    map.insert(
628                        header::AUTHORIZATION,
629                        HeaderValue::from_str(&format!("Bearer {at}"))?,
630                    );
631                }
632            }
633            map.insert(
634                COMPATIBILITY_HEADER,
635                HeaderValue::from_str(&self.compatibility_date)?,
636            );
637            map
638        };
639        let url = format!("{}{endpoint}", self.base_api_url);
640        let mut req_builder = self
641            .client
642            .request(Method::from_str(method)?, &url)
643            .headers(headers)
644            .query(query.unwrap_or(&[]));
645        req_builder = match body {
646            Some(b) => req_builder.body(b.to_owned()),
647            None => req_builder,
648        };
649        let req = req_builder.build()?;
650        let resp = self.client.execute(req).await?;
651        self.process_response_headers(resp.headers()).await?;
652        if !resp.status().is_success() {
653            return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
654        }
655        let text = resp.text().await?;
656        let data: T = serde_json::from_str(&text)?;
657        Ok(data)
658    }
659
660    /// Resolve an `operationId` to a URL path utilizing the OpenAPI spec.
661    ///
662    /// Operation IDs are those of the ESI OpenAPI spec, e.g.
663    /// `GetMarketsRegionIdOrders`. rfesi's legacy snake_case IDs
664    /// (e.g. `get_markets_region_id_orders`) are still accepted with a
665    /// deprecation warning until 0.2.0.
666    ///
667    /// If the spec has not yet been retrieved when calling this function,
668    /// an API call will be made to ESI to fetch that data (thus the
669    /// async signature of this function). If you don't need that help (by
670    /// explicitly making a call to `update_spec` prior) then you can use
671    /// the `get_endpoint_for_op_id` function, which is synchronous.
672    ///
673    /// Note that when making use of this function along with `query`, you
674    /// are responsible for resolving any/all URL parameters that the endpoint
675    /// may contain.
676    ///
677    /// # Example
678    /// ```rust,no_run
679    /// # async fn run() {
680    /// # use esi_openapi::prelude::*;
681    /// # let mut esi = EsiBuilder::new()
682    /// #     .user_agent("some user agent")
683    /// #     .client_id("your_client_id")
684    /// #     .client_secret("your_client_secret")
685    /// #     .callback_url("your_callback_url")
686    /// #     .build()
687    /// #     .unwrap();
688    /// let endpoint = esi
689    ///     .try_get_endpoint_for_op_id("GetAlliancesAllianceIdContactsLabels")
690    ///     .await
691    ///     .unwrap();
692    /// # }
693    /// ```
694    pub async fn try_get_endpoint_for_op_id(&mut self, op_id: &str) -> EsiResult<String> {
695        if self.spec.is_none() {
696            debug!("Spec is `None`; must fetch before looking up op_id");
697            self.update_spec().await?;
698        }
699        self.get_endpoint_for_op_id(op_id)
700    }
701
702    /// Resolve an `operationId` to a URL path utilizing the OpenAPI spec.
703    ///
704    /// Operation IDs are those of the ESI OpenAPI spec, e.g.
705    /// `GetMarketsRegionIdOrders`. rfesi's legacy snake_case IDs
706    /// (e.g. `get_markets_region_id_orders`) are still accepted with a
707    /// deprecation warning until 0.2.0.
708    ///
709    /// If the spec has not yet been retrieved when calling this function,
710    /// this function will return an error.
711    ///
712    /// Note that when making use of this function along with `query`, you
713    /// are responsible for resolving any/all URL parameters that the endpoint
714    /// may contain.
715    ///
716    /// # Example
717    /// ```rust,no_run
718    /// # use esi_openapi::prelude::*;
719    /// # let mut esi = EsiBuilder::new()
720    /// #     .user_agent("some user agent")
721    /// #     .client_id("your_client_id")
722    /// #     .client_secret("your_client_secret")
723    /// #     .callback_url("your_callback_url")
724    /// #     .build()
725    /// #     .unwrap();
726    /// let endpoint = esi.get_endpoint_for_op_id("GetAlliancesAllianceIdContactsLabels").unwrap();
727    /// ```
728    pub fn get_endpoint_for_op_id(&self, op_id: &str) -> EsiResult<String> {
729        if self.spec.is_none() {
730            return Err(EsiError::EmptySpec);
731        }
732        if let Some(path) = self.op_index.get(op_id) {
733            return Ok(path.clone());
734        }
735        if let Some(new_id) = legacy::openapi_id_for(op_id) {
736            warn!(
737                "operationId '{op_id}' is a deprecated Swagger ID; use '{new_id}' instead (legacy IDs will be removed in 0.2.0)"
738            );
739            if let Some(path) = self.op_index.get(new_id) {
740                return Ok(path.clone());
741            }
742        }
743        Err(EsiError::UnknownOperationID(op_id.to_owned()))
744    }
745
746    /// Build the error for a non-success response status.
747    fn status_error(status: u16, headers: &HeaderMap) -> EsiError {
748        if status == 429 {
749            let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
750            let group = header_str(RATE_LIMIT_GROUP_HEADER).map(str::to_owned);
751            let retry_after_secs =
752                header_str(header::RETRY_AFTER.as_str()).and_then(|v| v.trim().parse::<u64>().ok());
753            warn!("Rate limited by ESI (group {group:?}); retry after {retry_after_secs:?}s");
754            return EsiError::RateLimited {
755                group,
756                retry_after_secs,
757            };
758        }
759        EsiError::InvalidStatusCode(status)
760    }
761
762    /// Record the error-limit and rate-limit headers of a response.
763    async fn process_response_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
764        self.process_error_limit_headers(headers).await?;
765        self.process_rate_limit_headers(headers).await
766    }
767
768    async fn process_rate_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
769        let Some(group) = headers.get(RATE_LIMIT_GROUP_HEADER) else {
770            return Ok(());
771        };
772        let group = group.to_str()?.to_owned();
773        let limit = match headers.get(RATE_LIMIT_LIMIT_HEADER) {
774            Some(v) => v.to_str()?.to_owned(),
775            None => String::new(),
776        };
777        let parse_i64 = |name: &str| -> Result<i64, EsiError> {
778            match headers.get(name) {
779                Some(v) => v
780                    .to_str()?
781                    .trim()
782                    .parse::<i64>()
783                    .map_err(|e| EsiError::HeaderParseError(name.into(), e)),
784                None => Ok(0),
785            }
786        };
787        let remaining = parse_i64(RATE_LIMIT_REMAINING_HEADER)?;
788        let used = parse_i64(RATE_LIMIT_USED_HEADER)?;
789        let (max_tokens, window_secs) = parse_rate_limit(&limit);
790        let status = RateLimitStatus {
791            group: group.clone(),
792            limit,
793            max_tokens,
794            window_secs,
795            remaining,
796            used,
797            updated_at_millis: current_time_millis()?,
798        };
799        debug!("Rate limit status: {status:?}");
800        self.rate_limits.write().await.insert(group, status);
801        Ok(())
802    }
803
804    /// Latest rate-limit status ESI reported for a route group
805    /// (the `X-Ratelimit-Group` header value, e.g. `market`).
806    ///
807    /// Returns `None` if no response from that group has been seen yet.
808    /// Routes not yet moved to ESI's rate limiter do not send these
809    /// headers; they are covered by [`Esi::is_error_limited`] instead.
810    pub async fn rate_limit_status(&self, group: &str) -> Option<RateLimitStatus> {
811        self.rate_limits.read().await.get(group).cloned()
812    }
813
814    /// Latest rate-limit status for every route group seen so far.
815    pub async fn rate_limit_statuses(&self) -> HashMap<String, RateLimitStatus> {
816        self.rate_limits.read().await.clone()
817    }
818
819    async fn process_error_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
820        match (
821            headers.get(ERROR_LIMIT_REMAIN_HEADER),
822            headers.get(ERROR_LIMIT_RESET_HEADER),
823        ) {
824            (Some(remain_header), Some(reset_header)) => {
825                let remaining_limit = remain_header
826                    .to_str()?
827                    .parse::<i32>()
828                    .map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_REMAIN_HEADER.into(), e))?;
829                let resets_in = reset_header
830                    .to_str()?
831                    .parse::<i64>()
832                    .map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_RESET_HEADER.into(), e))?;
833
834                let expires_at_millis = current_time_millis()? + resets_in * 1000;
835
836                self.error_limit_state
837                    .write()
838                    .await
839                    .replace(ErrorLimitState {
840                        remaining_limit,
841                        expires_at_millis,
842                    });
843                Ok(())
844            }
845            _ => Ok(()),
846        }
847    }
848
849    async fn assert_not_error_limited(&self) -> Result<(), EsiError> {
850        match self.is_error_limited().await? {
851            Limited { for_millis } => Err(EsiError::ErrorLimited(for_millis)),
852            NotLimited => Ok(()),
853        }
854    }
855
856    /// Returns whether we have temporarily encountered the error limit due to too many failed responses.
857    ///
858    /// If this returns true, then this client will refuse to process further requests.
859    pub async fn is_error_limited(&self) -> Result<ErrorLimitStatus, EsiError> {
860        match &self.error_limit_state.read().await.as_ref() {
861            None => Ok(NotLimited),
862            Some(state) => {
863                if state.remaining_limit > 0 {
864                    return Ok(NotLimited);
865                }
866                let remaining_time = state.expires_at_millis - current_time_millis()?;
867                if remaining_time < 0 {
868                    return Ok(NotLimited);
869                }
870                Ok(Limited {
871                    for_millis: remaining_time,
872                })
873            }
874        }
875    }
876
877    /// Retrieve this struct's OpenAPI specification.
878    ///
879    /// Use in tandem with [EsiBuilder::spec].
880    pub fn get_spec(&self) -> Option<&Spec> {
881        self.spec.as_ref()
882    }
883
884    /// Call endpoints under the "alliance" group in ESI.
885    pub fn group_alliance(&self) -> AllianceGroup<'_> {
886        AllianceGroup { esi: self }
887    }
888
889    /// Call endpoints under the "Assets" group in ESI.
890    pub fn group_assets(&self) -> AssetsGroup<'_> {
891        AssetsGroup { esi: self }
892    }
893
894    /// Call endpoints under the "Bookmarks" group in ESI.
895    pub fn group_bookmarks(&self) -> BookmarksGroup<'_> {
896        BookmarksGroup { esi: self }
897    }
898
899    /// Call endpoints under the "Calendar" group in ESI.
900    pub fn group_calendar(&self) -> CalendarGroup<'_> {
901        CalendarGroup { esi: self }
902    }
903
904    /// Call endpoints under the "Character" group in ESI.
905    pub fn group_character(&self) -> CharacterGroup<'_> {
906        CharacterGroup { esi: self }
907    }
908
909    /// Call endpoints under the "Clones" group in ESI.
910    pub fn group_clones(&self) -> ClonesGroup<'_> {
911        ClonesGroup { esi: self }
912    }
913
914    /// Call endpoints under the "Contacts" group in ESI.
915    pub fn group_contacts(&self) -> ContactsGroup<'_> {
916        ContactsGroup { esi: self }
917    }
918
919    /// Call endpoints under the "Contracts" group in ESI.
920    pub fn group_contracts(&self) -> ContractsGroup<'_> {
921        ContractsGroup { esi: self }
922    }
923
924    /// Call endpoints under the "Corporation" group in ESI.
925    pub fn group_corporation(&self) -> CorporationGroup<'_> {
926        CorporationGroup { esi: self }
927    }
928
929    /// Call endpoints under the "Dogma" group in ESI.
930    pub fn group_dogma(&self) -> DogmaGroup<'_> {
931        DogmaGroup { esi: self }
932    }
933
934    /// Call endpoints under the "FactionWarfare" group in ESI.
935    pub fn group_faction_warfare(&self) -> FactionWarfareGroup<'_> {
936        FactionWarfareGroup { esi: self }
937    }
938
939    /// Call endpoints under the "Fittings" group in ESI.
940    pub fn group_fittings(&self) -> FittingsGroup<'_> {
941        FittingsGroup { esi: self }
942    }
943
944    /// Call endpoints under the "Fleets" group in ESI.
945    pub fn group_fleets(&self) -> FleetsGroup<'_> {
946        FleetsGroup { esi: self }
947    }
948
949    /// Call endpoints under the "Incursions" group in ESI.
950    pub fn group_incursions(&self) -> IncursionsGroup<'_> {
951        IncursionsGroup { esi: self }
952    }
953
954    /// Call endpoints under the "Industry" group in ESI.
955    pub fn group_industry(&self) -> IndustryGroup<'_> {
956        IndustryGroup { esi: self }
957    }
958
959    /// Call endpoints under the "Insurance" group in ESI.
960    pub fn group_insurance(&self) -> InsuranceGroup<'_> {
961        InsuranceGroup { esi: self }
962    }
963
964    /// Call endpoints under the "Killmails" group in ESI.
965    pub fn group_killmails(&self) -> KillmailsGroup<'_> {
966        KillmailsGroup { esi: self }
967    }
968
969    /// Call endpoints under the "Location" group in ESI.
970    pub fn group_location(&self) -> LocationGroup<'_> {
971        LocationGroup { esi: self }
972    }
973
974    /// Call endpoints under the "Loyalty" group in ESI.
975    pub fn group_loyalty(&self) -> LoyaltyGroup<'_> {
976        LoyaltyGroup { esi: self }
977    }
978
979    /// Call endpoints under the "Mail" group in ESI.
980    pub fn group_mail(&self) -> MailGroup<'_> {
981        MailGroup { esi: self }
982    }
983
984    /// Call endpoints under the "Market" group in ESI.
985    pub fn group_market(&self) -> MarketGroup<'_> {
986        MarketGroup { esi: self }
987    }
988
989    /// Call endpoints under the "Opportunities" group in ESI.
990    pub fn group_opportunities(&self) -> OpportunitiesGroup<'_> {
991        OpportunitiesGroup { esi: self }
992    }
993
994    /// Call endpoints under the "PlanetaryInteraction" group in ESI.
995    pub fn group_planetary_interaction(&self) -> PlanetaryInteractionGroup<'_> {
996        PlanetaryInteractionGroup { esi: self }
997    }
998
999    /// Call endpoints under the "Routes" group in ESI.
1000    pub fn group_routes(&self) -> RoutesGroup<'_> {
1001        RoutesGroup { esi: self }
1002    }
1003
1004    /// Call endpoints under the "Search" group in ESI.
1005    pub fn group_search(&self) -> SearchGroup<'_> {
1006        SearchGroup { esi: self }
1007    }
1008
1009    /// Call endpoints under the "Skills" group in ESI.
1010    pub fn group_skills(&self) -> SkillsGroup<'_> {
1011        SkillsGroup { esi: self }
1012    }
1013
1014    /// Call endpoints under the "Sovereignty" group in ESI.
1015    pub fn group_sovereignty(&self) -> SovereigntyGroup<'_> {
1016        SovereigntyGroup { esi: self }
1017    }
1018
1019    /// Call endpoints under the "Status" group in ESI.
1020    pub fn group_status(&self) -> StatusGroup<'_> {
1021        StatusGroup { esi: self }
1022    }
1023
1024    /// Call endpoints under the "Universe" group in ESI.
1025    pub fn group_universe(&self) -> UniverseGroup<'_> {
1026        UniverseGroup { esi: self }
1027    }
1028
1029    /// Call endpoints under the "UserInterface" group in ESI.
1030    pub fn group_user_interface(&self) -> UserInterfaceGroup<'_> {
1031        UserInterfaceGroup { esi: self }
1032    }
1033
1034    /// Call endpoints under the "Wallet" group in ESI.
1035    pub fn group_wallet(&self) -> WalletGroup<'_> {
1036        WalletGroup { esi: self }
1037    }
1038
1039    /// Call endpoints under the "Wars" group in ESI.
1040    pub fn group_wars(&self) -> WarsGroup<'_> {
1041        WarsGroup { esi: self }
1042    }
1043}
1044
1045/// Get the current system timestamp since the epoch.
1046fn current_time_millis() -> Result<i64, EsiError> {
1047    Ok(SystemTime::now()
1048        .duration_since(UNIX_EPOCH)?
1049        .as_millis()
1050        .try_into()
1051        .expect("i64 overflow for time"))
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::{
1057        parse_rate_limit, AuthenticateResponse, Esi, ERROR_LIMIT_REMAIN_HEADER,
1058        ERROR_LIMIT_RESET_HEADER, RATE_LIMIT_GROUP_HEADER, RATE_LIMIT_LIMIT_HEADER,
1059        RATE_LIMIT_REMAINING_HEADER, RATE_LIMIT_USED_HEADER,
1060    };
1061    use crate::errors::EsiError;
1062    use crate::prelude::EsiBuilder;
1063    use crate::spec::Spec;
1064    use http::{HeaderMap, HeaderValue};
1065    use std::time::Duration;
1066
1067    const FIXTURE: &str = include_str!("../resources/test/openapi.json");
1068
1069    fn esi_with_fixture() -> Esi {
1070        let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
1071        EsiBuilder::new()
1072            .user_agent("Client test, not meant to request")
1073            .spec(Some(spec))
1074            .build()
1075            .unwrap()
1076    }
1077
1078    #[test]
1079    fn test_resolve_openapi_op_id() {
1080        let esi = esi_with_fixture();
1081        assert_eq!(
1082            esi.get_endpoint_for_op_id("GetMarketsRegionIdOrders")
1083                .unwrap(),
1084            "markets/{region_id}/orders"
1085        );
1086        assert_eq!(
1087            esi.get_endpoint_for_op_id("PostUniverseIds").unwrap(),
1088            "universe/ids"
1089        );
1090    }
1091
1092    #[test]
1093    fn test_resolve_legacy_op_id() {
1094        let esi = esi_with_fixture();
1095        assert_eq!(
1096            esi.get_endpoint_for_op_id("get_markets_region_id_orders")
1097                .unwrap(),
1098            "markets/{region_id}/orders"
1099        );
1100        assert_eq!(
1101            esi.get_endpoint_for_op_id("get_characters_character_id")
1102                .unwrap(),
1103            "characters/{character_id}"
1104        );
1105    }
1106
1107    #[test]
1108    fn test_all_legacy_ids_resolve() {
1109        let esi = esi_with_fixture();
1110        for (legacy, openapi) in crate::legacy::LEGACY_OP_IDS {
1111            esi.get_endpoint_for_op_id(openapi)
1112                .unwrap_or_else(|_| panic!("{openapi} (from {legacy}) missing from spec"));
1113        }
1114    }
1115
1116    #[test]
1117    fn test_resolve_unknown_op_id() {
1118        let esi = esi_with_fixture();
1119        match esi.get_endpoint_for_op_id("GetNothingHere") {
1120            Err(EsiError::UnknownOperationID(id)) => assert_eq!(id, "GetNothingHere"),
1121            other => panic!("Unexpected result: {other:?}"),
1122        }
1123    }
1124
1125    #[test]
1126    fn test_resolve_without_spec() {
1127        let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1128        assert!(matches!(
1129            esi.get_endpoint_for_op_id("GetMarketsPrices"),
1130            Err(EsiError::EmptySpec)
1131        ));
1132    }
1133
1134    #[test]
1135    fn test_parse_rate_limit() {
1136        assert_eq!(parse_rate_limit("150/15m"), (Some(150), Some(900)));
1137        assert_eq!(parse_rate_limit("20/1h"), (Some(20), Some(3600)));
1138        assert_eq!(parse_rate_limit("300/30s"), (Some(300), Some(30)));
1139        assert_eq!(parse_rate_limit("10/5x"), (Some(10), None));
1140        assert_eq!(parse_rate_limit("garbage"), (None, None));
1141    }
1142
1143    #[tokio::test]
1144    async fn test_rate_limit_headers() {
1145        let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1146        let mut headers = HeaderMap::new();
1147        headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
1148        headers.append(RATE_LIMIT_LIMIT_HEADER, HeaderValue::from_static("150/15m"));
1149        headers.append(RATE_LIMIT_REMAINING_HEADER, HeaderValue::from_static("148"));
1150        headers.append(RATE_LIMIT_USED_HEADER, HeaderValue::from_static("2"));
1151        esi.process_response_headers(&headers)
1152            .await
1153            .expect("Should parse");
1154        let status = esi.rate_limit_status("market").await.expect("recorded");
1155        assert_eq!(status.limit, "150/15m");
1156        assert_eq!(status.max_tokens, Some(150));
1157        assert_eq!(status.window_secs, Some(900));
1158        assert_eq!(status.remaining, 148);
1159        assert_eq!(status.used, 2);
1160        assert!(esi.rate_limit_status("other").await.is_none());
1161        assert_eq!(esi.rate_limit_statuses().await.len(), 1);
1162    }
1163
1164    #[tokio::test]
1165    async fn test_no_rate_limit_headers() {
1166        let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1167        esi.process_response_headers(&HeaderMap::new())
1168            .await
1169            .expect("Should parse");
1170        assert!(esi.rate_limit_statuses().await.is_empty());
1171    }
1172
1173    #[test]
1174    fn test_status_error_429() {
1175        let mut headers = HeaderMap::new();
1176        headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
1177        headers.append(http::header::RETRY_AFTER, HeaderValue::from_static("12"));
1178        match Esi::status_error(429, &headers) {
1179            EsiError::RateLimited {
1180                group,
1181                retry_after_secs,
1182            } => {
1183                assert_eq!(group.as_deref(), Some("market"));
1184                assert_eq!(retry_after_secs, Some(12));
1185            }
1186            other => panic!("Unexpected error: {other}"),
1187        }
1188        assert!(matches!(
1189            Esi::status_error(404, &headers),
1190            EsiError::InvalidStatusCode(404)
1191        ));
1192    }
1193
1194    #[test]
1195    fn test_authenticateresponse_deserialize() {
1196        let source = r#"{
1197            "access_token": "abc",
1198            "expires_in": 1000,
1199            "refresh_token": "def"
1200          }"#;
1201        let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
1202
1203        assert_eq!(data.access_token, "abc");
1204        assert_eq!(data.expires_in, 1000);
1205        assert_eq!(data.refresh_token, Some("def".to_owned()));
1206    }
1207
1208    #[test]
1209    fn test_authenticateresponse_deserialize_no_refresh_token() {
1210        let source = r#"{
1211            "access_token": "abc",
1212            "expires_in": 1000,
1213            "refresh_token": null
1214          }"#;
1215        let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
1216
1217        assert_eq!(data.access_token, "abc");
1218        assert_eq!(data.expires_in, 1000);
1219        assert_eq!(data.refresh_token, None);
1220    }
1221
1222    #[tokio::test]
1223    async fn test_error_limit_header_not_limited() {
1224        let esi = EsiBuilder::default()
1225            .user_agent("Client test, not meant to request")
1226            .build()
1227            .unwrap();
1228        let mut headers = HeaderMap::new();
1229        headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("100"));
1230        headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("5"));
1231        esi.process_error_limit_headers(&headers)
1232            .await
1233            .expect("Should parse");
1234        esi.assert_not_error_limited()
1235            .await
1236            .expect("Should not be error limited");
1237    }
1238
1239    #[tokio::test]
1240    async fn test_error_limit_header_limited() {
1241        let esi = EsiBuilder::default()
1242            .user_agent("Client test, not meant to request")
1243            .build()
1244            .unwrap();
1245        let mut headers = HeaderMap::new();
1246        headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
1247        headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
1248        esi.process_error_limit_headers(&headers)
1249            .await
1250            .expect("Should parse");
1251        let err = esi
1252            .assert_not_error_limited()
1253            .await
1254            .expect_err("Should be limited");
1255        match err {
1256            EsiError::ErrorLimited(millis) => {
1257                assert!(millis <= 2000)
1258            }
1259            _ => panic!("Unexpected error: {}", err),
1260        }
1261    }
1262
1263    #[tokio::test]
1264    #[ignore] // This is a bit slow
1265    async fn test_error_limit_expired_limit() {
1266        let esi = EsiBuilder::default()
1267            .user_agent("Client test, not meant to request")
1268            .build()
1269            .unwrap();
1270        let mut headers = HeaderMap::new();
1271        headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
1272        headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
1273        esi.process_error_limit_headers(&headers)
1274            .await
1275            .expect("Should parse");
1276        println!("Waiting 2 seconds ..");
1277        tokio::time::sleep(Duration::from_millis(2050)).await;
1278        esi.assert_not_error_limited()
1279            .await
1280            .expect("Should not be error limited");
1281    }
1282}