eve_esi 0.4.9

Thread-safe, asynchronous client for EVE Online's ESI & OAuth2
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! JWT Key Utility Functions
//!
//! This module provides utility functions for JWT key management, including:
//!
//! - Cache expiry calculations
//! - Backoff period enforcement
//! - Error handling and reporting
//!
//! These utilities support the core JWT key operations with helper functions
//! that implement common patterns and checks used throughout the JWT key system.
//!
//! See the [module-level documentation](super) for a more detailed overview and usage.

use std::time::Instant;

use crate::oauth2::jwk::cache::JwtKeyCache;

/// Checks if refresh is still in cooldown due to recent failure.
///
/// This method determines whether enough time has passed since the last
/// JWT key refresh failure to attempt another refresh. It implements a
/// simple backoff mechanism to prevent excessive API calls when the
/// authentication service is experiencing issues.
///
/// # Thread Safety
/// This method acquires a read lock on the failure timestamp, allowing
/// multiple threads to check the backoff status concurrently.
/// - Reads from the shared [`Client::jwt_keys_last_refresh_failure`](crate::Client::jwt_keys_last_refresh_failure)
///   timestamp
///
/// # Arguments
/// - `jwt_key_cache` (&[`JwtKeyCache`]): cache which contains the config for JWT key caching &
///   refreshing policies.
///
/// # Returns
/// - Some([`u64`]): Indicating the JWT key refresh cooldown remaining
/// - None: If there is no remaining JWT key refresh cooldown.
pub(super) async fn check_refresh_cooldown(jwt_key_cache: &JwtKeyCache) -> Option<u64> {
    let config = &jwt_key_cache.config;

    // Check for last background refresh failure
    let last_refresh_failure = &jwt_key_cache.last_refresh_failure;
    if let Some(last_failure) = *last_refresh_failure.read().await {
        // Check if last refresh failure is within backoff period
        let elapsed_secs = last_failure.elapsed().as_secs();
        let is_cooldown = elapsed_secs < config.refresh_cooldown.as_secs();

        if is_cooldown {
            debug!(
                "Respecting background refresh cooldown: {}s elapsed of {}s required",
                elapsed_secs,
                config.refresh_cooldown.as_secs()
            );

            // Return Some with the remaining cooldown in seconds
            let remaining_cooldown = config.refresh_cooldown.as_secs() - elapsed_secs;

            return Some(remaining_cooldown);
        } else {
            trace!(
                "Background cooldown period elapsed: {}s passed (required {}s)",
                elapsed_secs,
                config.refresh_cooldown.as_secs()
            );

            // Return None indicating there is no active cooldown
            return None;
        }
    }

    // No previous JWT key refresh failure
    trace!("No previous JWT key refresh failures recorded, no backoff needed");

    // Return None indicating there is no active cooldown
    None
}

/// Determines if the cache is approaching expiry based on elapsed time
///
/// Checks whether the elapsed time since the last cache update has crossed
/// the nearing expiration threshold percentage of the cache lifetime, indicating that a proactive
/// refresh should be triggered.
///
/// # Parameters
/// - `jwt_key_cache` (&[`JwtKeyCache`]): cache which contains the config for JWT key caching &
///   refreshing policies.
/// - `timestamp` ([`Instant`]): Timestamp of when the keys stored in cache were last updated.
///
/// # Returns
/// - `true` if the elapsed time exceeds the threshold percentage of the TTL
/// - `false` if the cache is still well within its valid period
pub(super) fn is_cache_approaching_expiry(jwt_key_cache: &JwtKeyCache, timestamp: Instant) -> bool {
    let config = &jwt_key_cache.config;

    // Calculate elasped milliseconds
    let elapsed_millis = timestamp.elapsed().as_millis();

    // Determine how many seconds need to pass for the keys to be considered nearing expiration
    // By default, 80% of 3600 second TTL must have elapsed, 2880 seconds.
    let threshold_percentage = config.background_refresh_threshold as f64 / 100.0;
    let threshold_millis = (config.cache_ttl.as_millis() as f64 * threshold_percentage) as u128;

    // By default, if more than 2880 seconds have elapsed then the keys are nearing expiration.
    let is_approaching_expiry = elapsed_millis > threshold_millis;

    // Return result
    let elapsed_seconds = timestamp.elapsed().as_secs();
    let threshold_seconds = (config.cache_ttl.as_secs() as f64 * threshold_percentage) as u64;

    if is_approaching_expiry {
        debug!(
            "JWT keys cache approaching expiry: elapsed={}s, threshold={}s ({}% of ttl={}s)",
            elapsed_seconds,
            threshold_seconds,
            config.background_refresh_threshold,
            config.cache_ttl.as_secs()
        );

        // Return true if cache is approaching expiry
        true
    } else {
        trace!(            "JWT keys cache not yet approaching expiry: elapsed={}s, threshold={}s ({}% of ttl={}s)",
        elapsed_seconds,
        threshold_seconds,
        config.background_refresh_threshold,
        config.cache_ttl.as_secs());

        // Return false if cache is not yet approaching expiry
        false
    }
}

/// Determines if the cache has completely expired based on elapsed time
///
/// Checks if the elapsed time since the last cache update has reached or
/// exceeded the configured JWT key cache lifetime which indicates expiration.
///
/// # Parameters
/// - `jwt_key_cache` (&[`JwtKeyCache`]): cache which contains the config for JWT key caching &
///   refreshing policies.
/// - `timestamp` ([`Instant`]): Timestamp of when the keys stored in cache were last updated.
///
/// # Returns
/// - `true` if the elapsed time has reached or exceeded the TTL
/// - `false` if the cache is still within its valid period
pub(super) fn is_cache_expired(jwt_key_cache: &JwtKeyCache, timestamp: Instant) -> bool {
    let cache_ttl = jwt_key_cache.config.cache_ttl;

    let is_expired = timestamp.elapsed().as_millis() >= cache_ttl.as_millis();

    if is_expired {
        debug!(
            "JWT keys cache expired: elapsed={}s, ttl={}s",
            timestamp.elapsed().as_secs(),
            cache_ttl.as_secs()
        );

        // Return true if cache is not yet expired
        true
    } else {
        trace!(
            "JWT keys cache valid: elapsed={}s, ttl={}s",
            timestamp.elapsed().as_secs(),
            cache_ttl.as_secs()
        );

        // Return false if cache is still valid
        false
    }
}

#[cfg(test)]
mod is_refresh_cooldown_tests {
    use crate::Client;

    use super::check_refresh_cooldown;

    /// Refresh cooldown should be active due to recent failure
    ///
    /// When there is a refresh failure within the default of the past (60 seconds),
    /// assert that the function returns true, indicating that we should not attempt
    /// a refresh due to cooldown period.
    ///
    /// # Test Setup
    /// - Create a basic Client
    /// - Set last refresh failure within default cooldown period of past 60 seconds
    ///
    /// # Assertions
    /// - Assert function returns Some indicating we are still in cooldown
    /// - Assert 30 seconds remain in cooldown period
    #[tokio::test]
    async fn test_check_refresh_cooldown_within_cooldown() {
        // Setup Client
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@email.com")
            .build()
            .expect("Failed to build Client");

        let jwt_key_cache = &esi_client.inner.jwt_key_cache;

        // Set the recent failure within cooldown period default of 60 seconds
        {
            let mut failure_time = jwt_key_cache.last_refresh_failure.write().await;
            *failure_time = Some(std::time::Instant::now() - std::time::Duration::from_secs(30));
        }

        // Run function
        let cooldown = check_refresh_cooldown(&jwt_key_cache).await;

        // Assert cooldown is some
        assert!(cooldown.is_some());
        let remaining_cooldown = cooldown.unwrap();

        // Assert cooldown returns expected 30 seconds remaining
        assert_eq!(remaining_cooldown, 30);
    }

    /// Refresh cooldown should be false due to not being in cooldown
    ///
    /// When the back off period is greater than the default of 60 seconds,
    /// assert that the function returns false, indicating that we can attempt a refresh.
    ///
    /// # Test Setup
    /// - Create a basic Client
    /// - Set last refresh failure beyond default cooldown period of past 60 seconds
    ///
    /// # Assertions
    /// - Assert cooldown is None indicating we are not in the cooldown period
    #[tokio::test]
    async fn test_check_refresh_cooldown_recent_failure() {
        // Setup Client
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@email.com")
            .build()
            .expect("Failed to build Client");

        let jwt_key_cache = &esi_client.inner.jwt_key_cache;

        // Set the last refresh failure greater than default of cooldown period of 60 seconds
        {
            let mut failure_time = jwt_key_cache.last_refresh_failure.write().await;
            *failure_time = Some(std::time::Instant::now() - std::time::Duration::from_secs(61));
        }

        // Run function
        let cooldown = check_refresh_cooldown(&jwt_key_cache).await;

        // Assert cooldown is None
        assert!(cooldown.is_none());
    }

    /// Refresh cooldown should be false due to no past failures recorded
    ///
    /// When there is no previous failure recorded, the function should return false,
    /// indicating that we can attempt a refresh.
    ///
    /// # Test Setup
    /// - Create a basic Client
    /// - Do not set the [`Client::jwt_key_last_refresh_failure`]
    ///
    /// # Assertions
    /// - Assert cooldown is None indicating we are not in the cooldown period
    #[tokio::test]
    async fn test_check_refresh_cooldown_no_failure() {
        // Setup Client
        // Don't set back off period
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@email.com")
            .build()
            .expect("Failed to build Client");

        let jwt_key_cache = &esi_client.inner.jwt_key_cache;

        // Run function
        let cooldown = check_refresh_cooldown(&jwt_key_cache).await;

        // Assert cooldown is None
        assert!(cooldown.is_none());
    }
}

#[cfg(test)]
mod is_cache_approaching_expiry_tests {

    use crate::Client;

    use super::is_cache_approaching_expiry;

    /// Validates function returns true if cache is past 80% expiration
    ///
    /// When the JWT key cache expiration is past 80% expired (2880 seconds of 3600 default expiration),
    /// the function should return true indicating that the cache is almost expired.
    ///
    /// # Test Setup
    /// - Create a basic Client
    /// - Set the JWT key cache to beyond 80% expired
    ///
    /// # Validations
    /// - Verifies the function returns true, cache is almost expired.
    #[test]
    fn test_is_cache_approaching_expiry_true() {
        // Setup Client
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@email.com")
            .build()
            .expect("Failed to build Client");

        // Set the expiration timestamp to psat default expiry of 2880 seconds
        // Default approaching expiry is 2880 seconds (80% of 3600 seconds default)
        let timestamp = std::time::Instant::now() - std::time::Duration::from_secs(2881);

        // Test function
        let result = is_cache_approaching_expiry(&esi_client.inner.jwt_key_cache, timestamp);

        // Assert true
        assert_eq!(result, true)
    }

    /// Validates function returns false if cache is not approaching expiration
    ///
    /// When the JWT key cache expiration is not yet at 80%, the function
    /// should return false indicating we are not yet nearing expiration.
    ///
    /// # Test Setup
    /// - Create a basic Client
    /// - Set the client JWT key cache to less than 80% expired
    ///
    /// # Validations
    /// - Verifies the function returns false, cache is not yet nearing expiration.
    #[test]
    fn test_is_cache_approaching_expiry_false() {
        // Setup Client
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@email.com")
            .build()
            .expect("Failed to build Client");

        // Set the expiration timestamp to represent fresh keys
        let timestamp = std::time::Instant::now();

        // Test function
        let result = is_cache_approaching_expiry(&esi_client.inner.jwt_key_cache, timestamp);

        // Assert false
        assert_eq!(result, false)
    }
}

#[cfg(test)]
mod is_cache_expired_tests {
    use crate::Client;

    use super::is_cache_expired;

    /// Validates function returns true if cache is expired
    ///
    /// When the JWT key cache has been set more than 3600 seconds ago
    /// by default, the cache should be considered fully expired.
    ///
    /// # Setup
    /// - Create a basic Client
    /// - Set the client JWT key cache to past 3600 seconds expiration
    ///
    /// # Assertions
    /// - Verifies the function returns true, the cache is fully expired
    #[test]
    fn test_is_cache_expired_true() {
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@example.com)")
            .build()
            .expect("Failed to build Client");

        // Set expiration timestamp to past default expiration of 3600 seconds
        let timestamp = std::time::Instant::now() - std::time::Duration::from_secs(3601);

        // Test function
        let result = is_cache_expired(&esi_client.inner.jwt_key_cache, timestamp);

        // Assert true
        assert_eq!(result, true)
    }

    /// Validates function returns false if cache is not expired
    ///
    /// When the JWT key cache has been set less than 3600 seconds ago
    /// by default, the cache should be not yet expired.
    ///
    /// # Setup
    /// - Create a basic Client
    /// - Set the client JWT key cache to fresh keys
    ///
    /// # Assertions
    /// - Verifies the function returns true, the cache is fully expired
    #[test]
    fn test_is_cache_expired_false() {
        // Setup basic Client
        let esi_client = Client::builder()
            .user_agent("MyApp/1.0 (contact@example.com)")
            .build()
            .expect("Failed to build Client");

        // Set expiration timestamp to represent fresh keys
        let timestamp = std::time::Instant::now();

        // Test function
        let result = is_cache_expired(&esi_client.inner.jwt_key_cache, timestamp);

        // Assert true
        assert_eq!(result, false)
    }
}