Skip to main content

pdk_contracts_lib/legacy/api/
validator.rs

1// Copyright (c) 2026, Salesforce, Inc.,
2// All rights reserved.
3// For full license text, see the LICENSE.txt file
4
5#![allow(deprecated)]
6
7use crate::api::credentials::{ClientId, ClientSecret};
8use crate::api::error::UpdateError;
9use crate::api::error::{AuthenticationError, AuthorizationError};
10use crate::api::ClientData;
11use crate::legacy::api::authentication::authenticate;
12use crate::legacy::api::authorization::authorize;
13use crate::legacy::api::tracker::AttemptTracker;
14use crate::legacy::implementation::constants::{
15    ACCEPT_HASH_ALGORITHM_VALUE, CONTRACTS_CACHE_EXPIRATION, CONTRACTS_FETCHING_INTERVAL,
16    LOCK_TIMEOUT_DURATION, PRIMARY_BACKUP_INTERVAL, PRIMARY_INACTIVE_TIMEOUT,
17    RETRY_GUARD_DEFAULT_BACKOFF, RETRY_GUARD_MAX_BACKOFF,
18};
19use crate::legacy::implementation::model::contracts_storage::{
20    ContractsLocalStorage, ContractsStorage,
21};
22use crate::legacy::implementation::model::distributed_storage::ContractsCache;
23use crate::legacy::implementation::model::session_storage::{
24    SessionSharedDataStorage, SessionStorage,
25};
26use crate::legacy::implementation::platform::client::HttpPlatformClient;
27use crate::legacy::implementation::platform::responses::{
28    contract_from_event, ContractsResponse, LoginResponse,
29};
30use crate::legacy::implementation::platform::shared::{AccessToken, ContractsRequestParams};
31use data_storage_lib::DataStorageBuilder;
32use lock_lib::{Lock, LockBuilder, TryLock};
33use pdk_core::classy::extract::context::ConfigureContext;
34use pdk_core::classy::extract::{Extract, FromContext};
35use pdk_core::classy::hl::HttpClientError;
36use pdk_core::classy::proxy_wasm::types::Status;
37use pdk_core::classy::{Clock, SharedData};
38use pdk_core::logger;
39use pdk_core::logger::debug;
40use pdk_core::policy_context::api::Metadata;
41use std::rc::Rc;
42use std::time::Duration;
43use thiserror::Error;
44
45/// The object that will collect the contracts and provide the functionality to validate incoming
46/// requests.
47// TODO(experimental_contracts_v2): deprecation notice temporarily disabled while
48// `ApiContracts` is gated behind the `experimental_contracts_v2` feature. Restore
49// the `#[deprecated(...)]` attribute below once `ApiContracts` is stabilized.
50// #[deprecated(
51//     note = "For policies that only need to work with Gateways >= 1.14, use `ApiContracts` instead. Use this one if you need compatibility with versions < 1.14"
52// )]
53pub struct ContractValidator {
54    api_id: String,
55    client: HttpPlatformClient,
56    session_lock: TryLock,
57    api_lock: TryLock,
58    session_storage: SessionSharedDataStorage,
59    contract_storage: Rc<ContractsLocalStorage>,
60    contracts_cache: ContractsCache,
61    clock: Rc<dyn Clock>,
62    tracker: AttemptTracker,
63}
64
65#[derive(Error, Debug)]
66enum InternalUpdateError {
67    #[error("Http client error: {0}")]
68    HttpClientError(#[from] HttpClientError),
69    #[error("Parsing error: {0}")]
70    Serde(#[from] serde_json::error::Error),
71    #[error("Lost the lock while executing async function.")]
72    LostLock,
73    #[error("Upstream returned unexpected status code {0}")]
74    UnexpectedResponse(u32),
75}
76
77enum PollContractsResponse {
78    Continue,
79    Renegotiate,
80    Finish,
81}
82
83pub(crate) enum PollerType {
84    Primary,
85    Secondary,
86}
87
88pub(crate) enum PollerError {
89    LostLock,
90    DataStorageError,
91}
92
93impl ContractValidator {
94    fn new(
95        client: HttpPlatformClient,
96        api_id: String,
97        clock: Rc<dyn Clock>,
98        shared_data: Rc<dyn SharedData>,
99        lock_builder: LockBuilder,
100        data_storage_builder: DataStorageBuilder,
101    ) -> Self {
102        // The session storage and the session lock only use two entries in total despite the amount
103        // of Apis/policies deployed. We can safely not remove them without fear of a leak.
104        let session_storage =
105            SessionSharedDataStorage::new(Rc::clone(&clock), Rc::clone(&shared_data));
106
107        let session_lock = lock_builder
108            .new(session_storage.session_lock_key())
109            .expiration(LOCK_TIMEOUT_DURATION)
110            .shared()
111            .build();
112
113        let contract_storage = Rc::new(ContractsLocalStorage::new(
114            &api_id,
115            Rc::clone(&clock),
116            shared_data,
117        ));
118        let api_lock = lock_builder
119            .new(contract_storage.api_lock_key())
120            .expiration(LOCK_TIMEOUT_DURATION)
121            .shared()
122            .build();
123
124        let contracts_cache = ContractsCache::new(
125            api_id.clone(),
126            Rc::clone(&clock),
127            data_storage_builder
128                .shared()
129                .remote("CONTRACTS-V2", CONTRACTS_CACHE_EXPIRATION),
130            Rc::clone(&contract_storage),
131        );
132
133        Self {
134            api_id,
135            client,
136            session_lock,
137            api_lock,
138            session_storage,
139            contract_storage,
140            clock: Rc::clone(&clock),
141            contracts_cache,
142            tracker: AttemptTracker::new(clock, CONTRACTS_FETCHING_INTERVAL),
143        }
144    }
145
146    /// Initial updating interval for calling [ContractValidator::update_contracts()].
147    pub const INITIALIZATION_PERIOD: Duration = Duration::from_millis(100);
148
149    /// Remaining updating interval for calling [ContractValidator::update_contracts()].
150    pub const UPDATE_PERIOD: Duration = CONTRACTS_FETCHING_INTERVAL;
151
152    /// Validates if `client_id` credential can be authorized in the current API contract.
153    /// If the validation is succesful, this method returns the [ClientData] of `client_id`.
154    /// Otherwise an [AuthorizationError] is returned.
155    pub fn authorize(&self, client_id: &ClientId) -> Result<ClientData, AuthorizationError> {
156        authorize(self.contract_storage.as_ref(), client_id)
157    }
158
159    /// Validates if `client_id` and `client_secret` credentials can be authenticated
160    /// in the current API contract.
161    /// If the validation is succesful, this method returns the [ClientData] of `client_id`.
162    /// Otherwise an [AuthenticationError] is returned.
163    pub fn authenticate(
164        &self,
165        client_id: &ClientId,
166        client_secret: &ClientSecret,
167    ) -> Result<ClientData, AuthenticationError> {
168        authenticate(self.contract_storage.as_ref(), client_id, client_secret)
169    }
170
171    /// Returns `true` if contracts have been pulled and are available locally.
172    /// This method can be used to determine if the [`ContractValidator`] is ready
173    /// for authorization and authentication operations.
174    pub fn is_ready(&self) -> bool {
175        self.contract_storage.last_update().is_some()
176    }
177
178    /// Updates local contracts database.
179    /// This method is intended to be called periodically in order to keep
180    /// the local contracts database up to date.
181    /// During initialization time, this method should be invoked in a period of
182    /// [ContractValidator::INITIALIZATION_PERIOD].
183    /// After initialization, this method should be invoked in a period of
184    /// [ContractValidator::UPDATE_PERIOD].
185    pub async fn update_contracts(&self) -> Result<(), UpdateError> {
186        // Hack to avoid logging too much when the update period is set to milliseconds times.
187        // - avoid spam of logs stating "update period hasn't elapsed".
188        // - avoid bursts of logs when the update period is reached and a workers has the lock.
189        if self.tracker.expired() {
190            self.tracker.track();
191        } else {
192            return Ok(());
193        }
194
195        if !self.should_update() {
196            debug!("Contracts update skipped since update period hasn't elapsed.");
197            return Ok(());
198        }
199
200        debug!("Fetching contracts for API {}", self.api_id);
201        let Some(api_lock) = self.api_lock.try_lock() else {
202            debug!(
203                "Other worker has the lock for API {}. Skipping update.",
204                self.api_id
205            );
206            return Ok(());
207        };
208
209        if self.first_cycle() {
210            let _ = self.cache_contracts_poll(&api_lock).await;
211            if !api_lock.refresh_lock() {
212                return Ok(());
213            }
214        }
215
216        // Re-check if the update period has elapsed after backup restore
217        if !self.should_update() {
218            debug!("Contracts update skipped since update period hasn't elapsed.");
219            return Ok(());
220        }
221
222        // Failure backoff guard. Coordinated per-API across workers via shared data.
223        // Distinct from `tracker` (log-throttling only): this halts the platform poll after
224        // any failed poll until the backoff elapses (dynamic length on `429`, default
225        // otherwise). Placed after the `first_cycle` cache restore so a fresh worker can still
226        // load contracts from the distributed cache while the platform poll is backing off.
227        if let Some(until) = self.contract_storage.retry_after() {
228            if self.clock.get_current_time() < until {
229                debug!(
230                    "Contracts update skipped for API {}; retry guard active.",
231                    self.api_id
232                );
233                return Ok(());
234            }
235        }
236
237        let result = self.platform_contracts_poll(&api_lock).await;
238
239        if self.contract_storage.last_update().is_none() {
240            debug!("No successfully poll registered will not try to backup contracts");
241            return result.map(|_| ());
242        }
243
244        match self.poller_type(&api_lock).await {
245            Ok(PollerType::Primary) => {
246                self.backup_contracts(&api_lock, result.as_ref().map(|r| *r).unwrap_or_default())
247                    .await;
248            }
249            Ok(PollerType::Secondary) => {
250                debug!("No update backup since we are a secondary node.");
251            }
252            Err(PollerError::LostLock) => {
253                debug!("Lost the api_lock while trying to become primary, skipping update.");
254            }
255            Err(PollerError::DataStorageError) => {
256                debug!("Unexpected error communicating with the data storage.");
257            }
258        };
259
260        result.map(|_| ())
261    }
262
263    async fn platform_contracts_poll(&self, api_lock: &'_ Lock<'_>) -> Result<bool, UpdateError> {
264        let mut updates = false;
265        debug!(
266            "Fetching contracts for API {} from contracts service",
267            self.api_id
268        );
269
270        let Some(token) = self.session_token().await else {
271            return Ok(updates);
272        };
273
274        if !api_lock.refresh_lock() {
275            debug!("Lost the api lock while fetching session token.");
276            return Ok(updates);
277        }
278
279        let mut token_data = token;
280        loop {
281            match self.poll_contracts(&token_data, api_lock).await {
282                Ok(PollContractsResponse::Continue) => {
283                    debug!("Contract polling request successful. Chaining next request");
284                    updates = true;
285                }
286                Ok(PollContractsResponse::Renegotiate) => {
287                    let Some(token) = self.renegotiate_token(token_data).await else {
288                        // Could not renegotiate the token
289                        return Ok(updates);
290                    };
291                    token_data = token;
292                    if !api_lock.refresh_lock() {
293                        debug!("Lost the api lock while refreshing the session lock.");
294                        return Ok(updates);
295                    }
296                }
297                Ok(PollContractsResponse::Finish) => {
298                    return Ok(updates);
299                }
300                Err(error) => {
301                    debug!("Error while polling contracts: {error}");
302                    return Ok(updates);
303                }
304            }
305        }
306    }
307
308    async fn cache_contracts_poll(&self, api_lock: &'_ Lock<'_>) -> Result<(), UpdateError> {
309        debug!("Fetching contracts for API {} from cache.", self.api_id);
310
311        let result = self.contracts_cache.get_state().await;
312
313        if !api_lock.refresh_lock() {
314            debug!("Lost the api lock while recovering state from remote storage.");
315            return Ok(());
316        }
317
318        result
319            .into_iter()
320            .for_each(|state| self.contract_storage.set_state(state));
321
322        Ok(())
323    }
324
325    async fn poller_type(&self, api_lock: &'_ Lock<'_>) -> Result<PollerType, PollerError> {
326        let Some(primary) = self.contract_storage.is_primary() else {
327            debug!("No information regarding primary node.");
328            return self.contracts_cache.try_primary(api_lock).await;
329        };
330
331        let primary_expired = self
332            .contract_storage
333            .last_primary_update()
334            .map(|last| last + PRIMARY_INACTIVE_TIMEOUT < self.clock.get_current_time())
335            .unwrap_or(true);
336
337        if primary_expired && !primary {
338            debug!("Secondary node trying to become primary due to timeout.");
339            return self.contracts_cache.try_primary(api_lock).await;
340        } else if primary_expired {
341            debug!("We lost the primary status. We'll become secondary for at least one polling cycle.");
342            self.contract_storage.set_primary(false);
343            return Ok(PollerType::Secondary);
344        }
345
346        match primary {
347            true => Ok(PollerType::Primary),
348            false => Ok(PollerType::Secondary),
349        }
350    }
351
352    fn first_cycle(&self) -> bool {
353        self.contract_storage.last_update().is_none()
354    }
355
356    fn should_update(&self) -> bool {
357        self.contract_storage
358            .last_update()
359            .map(|last| last + CONTRACTS_FETCHING_INTERVAL < self.clock.get_current_time())
360            .unwrap_or(true)
361    }
362
363    async fn backup_contracts(&self, api_lock: &'_ Lock<'_>, has_updates: bool) {
364        if !self.should_update_backup(has_updates) {
365            return;
366        }
367
368        let time = self.clock.get_current_time();
369        let mut update = self.contract_storage.get_state();
370        update.update_primary(time);
371        if self.contracts_cache.save_state(update).await {
372            self.contract_storage.set_primary_update(time);
373        }
374
375        if !api_lock.refresh_lock() {
376            debug!("Lost the api lock while backing data to cache.");
377        }
378    }
379
380    fn should_update_backup(&self, has_updates: bool) -> bool {
381        let Some(last_update) = self.contract_storage.last_update() else {
382            debug!("Skipping cache backup since no data to backup.");
383            return false;
384        };
385
386        if has_updates {
387            debug!("Will backup contracts since new updates are available.");
388            return true;
389        }
390
391        let Some(last_primary_update) = self.contract_storage.last_primary_update() else {
392            debug!("No local records of a primary node.");
393            return true;
394        };
395
396        if last_update < last_primary_update {
397            debug!("Skipping cache backup since no updates since last save.");
398            return false; // No data to back up. This can happen when connection to the platform fails.
399        }
400
401        let result = last_primary_update + PRIMARY_BACKUP_INTERVAL < self.clock.get_current_time();
402        if !result {
403            debug!("Skipping cache backup since the elapsed time is less than the refresh rate.");
404        }
405        result
406    }
407
408    async fn session_token(&self) -> Option<AccessToken> {
409        match self.session_storage.get_token() {
410            Some(token) => Some(token),
411            None => {
412                let Some(session_lock) = self.session_lock.try_lock() else {
413                    debug!("Other worker has the session lock. Skipping update.");
414                    return None;
415                };
416
417                // Re check if the token was already set before obtaining the lock.
418                // Since the lock is "not blocking" the changes are quite slim, but not null.
419                if let Some(token) = self.session_storage.get_token() {
420                    return Some(token);
421                }
422
423                self.fetch_session_token(&session_lock).await
424            }
425        }
426    }
427
428    async fn renegotiate_token(&self, old_token: AccessToken) -> Option<AccessToken> {
429        // Validate that the token was not renegotiated by someone else.
430        if let Some(token) = self.session_storage.get_token() {
431            if token != old_token {
432                return Some(token);
433            }
434        };
435
436        // Acquire the lock
437        let Some(session_lock) = self.session_lock.try_lock() else {
438            debug!("Other worker has the session lock. Aborting token renegotiation");
439            return None;
440        };
441
442        // Validate Again that the token was not renegotiated by someone else.
443        // Since the lock is "not blocking" the changes are quite slim, but not null.
444        if let Some(token) = self.session_storage.get_token() {
445            if token != old_token {
446                return Some(token);
447            }
448        };
449
450        self.fetch_session_token(&session_lock).await
451    }
452
453    async fn fetch_session_token(&self, session_lock: &'_ Lock<'_>) -> Option<AccessToken> {
454        match self.perform_login_request().await {
455            Ok(login) => {
456                if !session_lock.refresh_lock() {
457                    debug!("Lost the session lock. Aborting update.");
458                    return None;
459                }
460                let token = login.get_token();
461                let token_data = AccessToken::new(token.to_string(), login.get_type().to_string());
462                debug!("Obtained the session token.");
463                self.session_storage.save_token(token_data.clone());
464                Some(token_data)
465            }
466            Err(e) => {
467                self.set_retry_backoff(RETRY_GUARD_DEFAULT_BACKOFF);
468                logger::warn!(
469                    "Unexpected error while performing login request {e}. Skipping update."
470                );
471                None
472            }
473        }
474    }
475
476    async fn perform_login_request(&self) -> Result<LoginResponse, InternalUpdateError> {
477        debug!("Getting platform token...");
478        match self.client.login().await? {
479            r if r.status_code() == 200 => Ok(serde_json::from_slice::<LoginResponse>(r.body())?),
480            r => {
481                debug!(
482                    "Fetching contracts failed with status code: {} and body:\n {}",
483                    r.status_code(),
484                    String::from_utf8_lossy(r.body())
485                );
486                Err(InternalUpdateError::UnexpectedResponse(r.status_code()))
487            }
488        }
489    }
490
491    async fn poll_contracts(
492        &self,
493        access_token: &AccessToken,
494        api_lock: &'_ Lock<'_>,
495    ) -> Result<PollContractsResponse, InternalUpdateError> {
496        let token = access_token.get_access_token();
497        let response = match self
498            .client
499            .contracts(
500                token,
501                self.api_id.as_str(),
502                ACCEPT_HASH_ALGORITHM_VALUE,
503                self.next_url(),
504            )
505            .await
506        {
507            Ok(response) => response,
508            Err(error) => {
509                self.set_retry_backoff(RETRY_GUARD_DEFAULT_BACKOFF);
510                return Err(error.into());
511            }
512        };
513
514        if !api_lock.refresh_lock() {
515            return Err(InternalUpdateError::LostLock);
516        }
517
518        match response.status_code() {
519            200 => {
520                let contracts: ContractsResponse = match serde_json::from_slice(response.body()) {
521                    Ok(contracts) => contracts,
522                    Err(_) => {
523                        self.set_retry_backoff(RETRY_GUARD_DEFAULT_BACKOFF);
524                        return Err(HttpClientError::Status(Status::InternalFailure).into());
525                    }
526                };
527
528                if self.no_updates(&contracts) {
529                    self.finish_polling();
530                    Ok(PollContractsResponse::Finish)
531                } else {
532                    self.log_invalid_contracts(&contracts);
533                    self.update_data(&contracts);
534                    self.update_links(&contracts);
535                    Ok(PollContractsResponse::Continue)
536                }
537            }
538            401 => Ok(PollContractsResponse::Renegotiate),
539            429 => {
540                // Rate limited: honor the service-provided backoff length.
541                let backoff = Self::retry_after_backoff(response.header("Retry-After"));
542                self.set_retry_backoff(backoff);
543                logger::warn!(
544                    "Contracts service rate limited API {} (429). Backing off {}s.",
545                    self.api_id,
546                    backoff.as_secs()
547                );
548                Err(InternalUpdateError::UnexpectedResponse(429))
549            }
550            n => {
551                self.set_retry_backoff(RETRY_GUARD_DEFAULT_BACKOFF);
552                debug!(
553                    "Fetching contracts failed with status code: {} and body:\n {}",
554                    n,
555                    String::from_utf8_lossy(response.body())
556                );
557                Err(InternalUpdateError::UnexpectedResponse(n))
558            }
559        }
560    }
561
562    /// Records a coordinated, per-API poll backoff of `backoff` starting now, shared across all
563    /// workers of this host. Applied after every failed poll so a struggling contracts service
564    /// is not hammered: the `429` path supplies a dynamic length from `Retry-After`, all other
565    /// failures use [`RETRY_GUARD_DEFAULT_BACKOFF`].
566    fn set_retry_backoff(&self, backoff: Duration) {
567        let until = self.clock.get_current_time() + backoff;
568        self.contract_storage.set_retry_after(until);
569    }
570
571    /// Resolves the backoff duration for a `429` response from a `Retry-After` header.
572    /// Honors a delta-seconds value clamped to [`RETRY_GUARD_DEFAULT_BACKOFF`, `RETRY_GUARD_MAX_BACKOFF`];
573    /// falls back to [`RETRY_GUARD_DEFAULT_BACKOFF`] when the header is absent or not parseable.
574    fn retry_after_backoff(header: Option<&String>) -> Duration {
575        match header.and_then(|value| value.trim().parse::<u64>().ok()) {
576            Some(seconds) => Duration::from_secs(seconds)
577                .clamp(RETRY_GUARD_DEFAULT_BACKOFF, RETRY_GUARD_MAX_BACKOFF),
578            None => RETRY_GUARD_DEFAULT_BACKOFF,
579        }
580    }
581
582    fn log_invalid_contracts(&self, response: &ContractsResponse) {
583        for invalid_contract_error_msg in response.verify_contracts().err().unwrap_or_default() {
584            logger::warn!("{invalid_contract_error_msg}")
585        }
586    }
587
588    fn no_updates(&self, response: &ContractsResponse) -> bool {
589        let links = response.get_links();
590        links.self_link() == links.next_link()
591    }
592
593    fn finish_polling(&self) {
594        self.contract_storage.update_last();
595        debug!(
596            "No more contracts updates for API {}, polling in next tick.",
597            self.api_id
598        );
599    }
600
601    fn update_data(&self, response: &ContractsResponse) {
602        let data = response.get_data();
603        for contract_event in data {
604            match contract_event.removed.unwrap_or(false) {
605                true => self
606                    .contract_storage
607                    .remove_contract(&contract_event.client_id),
608                false => self
609                    .contract_storage
610                    .save_contract(contract_from_event(contract_event)),
611            }
612        }
613        self.contract_storage.update_last();
614        debug!(
615            "{} contract events processed for API {}",
616            data.len(),
617            self.api_id
618        );
619    }
620
621    fn update_links(&self, response: &ContractsResponse) {
622        let links = response.get_links();
623        let params = ContractsRequestParams::new(
624            Some(links.next_link().to_string()),
625            ACCEPT_HASH_ALGORITHM_VALUE.to_string(),
626        );
627        self.contract_storage.save_contracts_request_params(params);
628    }
629
630    fn next_url(&self) -> Option<String> {
631        self.contract_storage
632            .get_contracts_request_params()
633            .and_then(|x| x.next_url)
634    }
635}
636
637#[derive(thiserror::Error, Debug)]
638pub enum ExtractionError {
639    #[error("Api metadata is unavailable.")]
640    ApiMetadata,
641
642    #[error("Environment Context is unavailable.")]
643    EnvironmentContext,
644
645    #[error("Anypoint Context is unavailable.")]
646    AnypointContext,
647}
648
649impl FromContext<ConfigureContext> for ContractValidator {
650    type Error = ExtractionError;
651
652    fn from_context(context: &ConfigureContext) -> Result<Self, Self::Error> {
653        let metadata: Metadata = context.extract_always();
654        let api_id = metadata
655            .api_metadata
656            .id
657            .ok_or(ExtractionError::ApiMetadata)?;
658        let client = context.extract()?;
659        let clock = context.extract_always();
660        let shared_data = context.extract_always();
661        let lock_builder = context.extract_always();
662        let storage_builder: DataStorageBuilder = context
663            .extract()
664            .map_err(|_| ExtractionError::EnvironmentContext)?;
665
666        Ok(Self::new(
667            client,
668            api_id,
669            clock,
670            shared_data,
671            lock_builder,
672            storage_builder,
673        ))
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn retry_after_absent_uses_default() {
683        assert_eq!(
684            ContractValidator::retry_after_backoff(None),
685            RETRY_GUARD_DEFAULT_BACKOFF
686        );
687    }
688
689    #[test]
690    fn retry_after_unparseable_uses_default() {
691        let header = "Fri, 31 Dec 1999 23:59:59 GMT".to_string();
692        assert_eq!(
693            ContractValidator::retry_after_backoff(Some(&header)),
694            RETRY_GUARD_DEFAULT_BACKOFF
695        );
696    }
697
698    #[test]
699    fn retry_after_delta_seconds_is_honored() {
700        let header = "42".to_string();
701        assert_eq!(
702            ContractValidator::retry_after_backoff(Some(&header)),
703            Duration::from_secs(42)
704        );
705    }
706
707    #[test]
708    fn retry_after_trims_whitespace() {
709        let header = "  30 ".to_string();
710        assert_eq!(
711            ContractValidator::retry_after_backoff(Some(&header)),
712            Duration::from_secs(30)
713        );
714    }
715
716    #[test]
717    fn retry_after_is_clamped_to_max() {
718        let header = "100000".to_string();
719        assert_eq!(
720            ContractValidator::retry_after_backoff(Some(&header)),
721            RETRY_GUARD_MAX_BACKOFF
722        );
723    }
724
725    #[test]
726    fn retry_after_below_default_is_floored() {
727        // A `Retry-After` shorter than the default must not shorten the guard below it.
728        let header = "1".to_string();
729        assert_eq!(
730            ContractValidator::retry_after_backoff(Some(&header)),
731            RETRY_GUARD_DEFAULT_BACKOFF
732        );
733    }
734}