Skip to main content

aws_runtime/
service_clock_skew.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::auth::HttpSignatureType;
7use aws_smithy_runtime_api::box_error::BoxError;
8use aws_smithy_runtime_api::client::interceptors::context::{
9    BeforeDeserializationInterceptorContextMut, BeforeTransmitInterceptorContextMut,
10    BeforeTransmitInterceptorContextRef, InterceptorContext,
11};
12use aws_smithy_runtime_api::client::interceptors::{dyn_dispatch_hint, Intercept};
13use aws_smithy_runtime_api::client::orchestrator::{HttpResponse, OrchestratorError};
14use aws_smithy_runtime_api::client::retries::classifiers::{
15    ClassifyRetry, RetryAction, RetryClassifierPriority,
16};
17use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
18use aws_smithy_types::config_bag::{ConfigBag, Storable, StoreReplace};
19use aws_smithy_types::date_time::Format;
20use aws_smithy_types::error::metadata::ProvideErrorMetadata;
21use aws_smithy_types::retry::ErrorKind;
22use aws_smithy_types::DateTime;
23use std::error::Error as StdError;
24use std::marker::PhantomData;
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, SystemTime};
27
28// Below this absolute skew, a possible clock-skew error is treated as a genuine signature
29// error rather than retried as a skew error.
30const SKEW_DETECTION_THRESHOLD: Duration = Duration::from_secs(4 * 60);
31// If a request's round trip exceeds this, the skew measurement is discarded as unreliable.
32const MAX_TRUSTED_REQUEST_DURATION: Duration = Duration::from_secs(15 * 60);
33
34// Error codes that indicate a possible clock-skew signing problem.
35pub(crate) const CLOCK_SKEW_ERROR_CODES: &[&str] = &[
36    "InvalidSignatureException",
37    "SignatureDoesNotMatch",
38    "AuthFailure",
39    "RequestTimeTooSkewed",
40    "AccessDeniedException",
41];
42
43// A signed clock skew, in milliseconds. Positive means the service clock is ahead of the
44// client clock. `Default` is zero.
45//
46// Why i64 milliseconds: skew is directional (the client can be behind or ahead of the
47// service), so the unsigned `std::time::Duration` cannot represent it. Milliseconds are ample
48// precision (the HTTP `Date` header is second-resolution and SigV4 signing is second-granular),
49// and i64 spans far more range than any real clock drift.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub(crate) struct ClockSkew(i64);
52
53impl ClockSkew {
54    // The absolute magnitude of the skew.
55    fn abs(self) -> Duration {
56        Duration::from_millis(self.0.unsigned_abs())
57    }
58
59    // Returns `t` adjusted by this (signed) skew, saturating if the shift is unrepresentable.
60    pub(crate) fn apply(self, t: SystemTime) -> SystemTime {
61        if self.0 >= 0 {
62            t.checked_add(Duration::from_millis(self.0 as u64))
63        } else {
64            t.checked_sub(Duration::from_millis(self.0.unsigned_abs()))
65        }
66        .unwrap_or(t)
67    }
68}
69
70// Operation-scoped skew, seeded from the client skew at operation start and updated on each
71// response. Read by the signer to sign at `now() + AttemptSkew`.
72#[derive(Clone, Copy, Debug, Default)]
73pub(crate) struct AttemptSkew(pub(crate) ClockSkew);
74impl Storable for AttemptSkew {
75    type Storer = StoreReplace<Self>;
76}
77
78// The timestamp to sign with: `now` shifted by the operation's `AttemptSkew`.
79//
80// Presigned requests are never shifted. A presigned URL is a function of its inputs, and
81// presigning stops before transmit, so it never reads a response: it can neither contribute to the
82// skew nor be corrected by one.
83pub(crate) fn signing_time(
84    now: SystemTime,
85    signature_type: HttpSignatureType,
86    cfg: &ConfigBag,
87) -> SystemTime {
88    if signature_type == HttpSignatureType::HttpRequestQueryParams {
89        return now;
90    }
91    cfg.load::<AttemptSkew>()
92        .map_or(now, |skew| skew.0.apply(now))
93}
94
95// The client's raw local time when the request was sent (no skew applied), used to compute
96// the midpoint for the candidate skew.
97#[derive(Clone, Copy, Debug)]
98struct TimeRequestSent(SystemTime);
99impl Storable for TimeRequestSent {
100    type Storer = StoreReplace<Self>;
101}
102
103// The surviving candidate skew for a response, attached as a response extension so the retry
104// classifier can read it (a classifier has no config bag access).
105#[derive(Clone, Copy, Debug)]
106pub(crate) struct ResponseClockSkew(pub(crate) ClockSkew);
107
108/// When present and `true`, clock skew correction is disabled.
109#[derive(Clone, Copy, Debug)]
110pub struct DisableClockSkewCorrection(bool);
111impl DisableClockSkewCorrection {
112    /// Returns whether clock skew correction is disabled.
113    pub fn is_disabled(&self) -> bool {
114        self.0
115    }
116}
117impl From<bool> for DisableClockSkewCorrection {
118    fn from(disable: bool) -> Self {
119        Self(disable)
120    }
121}
122impl Storable for DisableClockSkewCorrection {
123    type Storer = StoreReplace<Self>;
124}
125
126fn disabled(cfg: &ConfigBag) -> bool {
127    cfg.load::<DisableClockSkewCorrection>()
128        .map(|d| d.0)
129        .unwrap_or(false)
130}
131
132fn server_time(response: &HttpResponse) -> Option<SystemTime> {
133    let date = response.headers().get("date")?;
134    let date_time = DateTime::from_str(date, Format::HttpDate).ok()?;
135    SystemTime::try_from(date_time).ok()
136}
137
138// `serverTime - midpoint`, as a signed skew. The midpoint assumes the server generated its
139// timestamp halfway through the round trip (the NTP clock offset calculation, RFC 5905 §8).
140fn signed_skew(server: SystemTime, midpoint: SystemTime) -> ClockSkew {
141    match server.duration_since(midpoint) {
142        Ok(d) => ClockSkew(d.as_millis() as i64),
143        Err(e) => ClockSkew(-(e.duration().as_millis() as i64)),
144    }
145}
146
147/// Interceptor that tracks the clock skew between the client and service, corrects the
148/// signing timestamp on retries, and records the skew for retry classification.
149///
150/// Holds the client-level clock skew, which persists across operations on this client.
151#[non_exhaustive]
152#[derive(Debug, Default)]
153pub struct ServiceClockSkewInterceptor {
154    client_skew: Arc<Mutex<ClockSkew>>,
155}
156
157impl ServiceClockSkewInterceptor {
158    /// Creates a new `ServiceClockSkewInterceptor`.
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    // Seeds the client-level skew (for conformance tests that start from a non-zero skew).
164    #[cfg(test)]
165    fn with_client_skew(client_skew: ClockSkew) -> Self {
166        Self {
167            client_skew: Arc::new(Mutex::new(client_skew)),
168        }
169    }
170
171    // Reads the current client-level skew (to assert `expectedClientSkew`).
172    #[cfg(test)]
173    fn client_skew(&self) -> ClockSkew {
174        *self.client_skew.lock().unwrap()
175    }
176}
177
178#[dyn_dispatch_hint]
179impl Intercept for ServiceClockSkewInterceptor {
180    fn name(&self) -> &'static str {
181        "ServiceClockSkewInterceptor"
182    }
183
184    fn modify_before_retry_loop(
185        &self,
186        _ctx: &mut BeforeTransmitInterceptorContextMut<'_>,
187        _runtime_components: &RuntimeComponents,
188        cfg: &mut ConfigBag,
189    ) -> Result<(), BoxError> {
190        if disabled(cfg) {
191            return Ok(());
192        }
193        // Seed the attempt skew from the persisted client skew so the first attempt of this
194        // operation signs with any offset learned by previous operations.
195        let seed = *self.client_skew.lock().unwrap();
196        cfg.interceptor_state().store_put(AttemptSkew(seed));
197        Ok(())
198    }
199
200    fn read_before_transmit(
201        &self,
202        _ctx: &BeforeTransmitInterceptorContextRef<'_>,
203        runtime_components: &RuntimeComponents,
204        cfg: &mut ConfigBag,
205    ) -> Result<(), BoxError> {
206        if disabled(cfg) {
207            return Ok(());
208        }
209        let now = runtime_components
210            .time_source()
211            .ok_or("a time source is required (clock skew)")?
212            .now();
213        cfg.interceptor_state().store_put(TimeRequestSent(now));
214        Ok(())
215    }
216
217    fn modify_before_deserialization(
218        &self,
219        ctx: &mut BeforeDeserializationInterceptorContextMut<'_>,
220        runtime_components: &RuntimeComponents,
221        cfg: &mut ConfigBag,
222    ) -> Result<(), BoxError> {
223        if disabled(cfg) {
224            return Ok(());
225        }
226        let time_received = runtime_components
227            .time_source()
228            .ok_or("a time source is required (clock skew)")?
229            .now();
230        let Some(time_sent) = cfg.load::<TimeRequestSent>().map(|t| t.0) else {
231            tracing::debug!("no recorded request send time; skipping clock skew measurement");
232            return Ok(());
233        };
234        // Cached response (RFC 7234 §5.1): the `Date` is stale, so don't trust it.
235        if ctx.response().headers().get("age").is_some() {
236            tracing::debug!("response came from a cache; skipping clock skew measurement");
237            return Ok(());
238        }
239        let Some(server) = server_time(ctx.response()) else {
240            tracing::debug!("no usable `Date` response header; skipping clock skew measurement");
241            return Ok(());
242        };
243        let elapsed = time_received.duration_since(time_sent).unwrap_or_default();
244        if elapsed > MAX_TRUSTED_REQUEST_DURATION {
245            tracing::debug!(
246                ?elapsed,
247                "request too slow to measure clock skew reliably; skipping"
248            );
249            return Ok(());
250        }
251        let midpoint = time_sent + elapsed / 2;
252        let candidate = signed_skew(server, midpoint);
253        // Record unconditionally: every response refreshes the skew, so a stale value is
254        // corrected without any special-case healing logic.
255        cfg.interceptor_state().store_put(AttemptSkew(candidate));
256        *self.client_skew.lock().unwrap() = candidate;
257        tracing::trace!(skew_ms = candidate.0, "recorded clock skew");
258        // Hand the surviving candidate to the retry classifier via the response.
259        ctx.response_mut()
260            .add_extension(ResponseClockSkew(candidate));
261        Ok(())
262    }
263}
264
265/// Retry classifier for clock-skew errors.
266///
267/// A response is retryable when its error code is a known clock-skew code and the skew
268/// measured from the response exceeds the detection threshold.
269#[derive(Debug)]
270pub struct ServiceClockSkewClassifier<E> {
271    _inner: PhantomData<E>,
272}
273
274impl<E> ServiceClockSkewClassifier<E> {
275    /// Creates a new `ServiceClockSkewClassifier`.
276    pub fn new() -> Self {
277        Self {
278            _inner: PhantomData,
279        }
280    }
281}
282
283impl<E> Default for ServiceClockSkewClassifier<E> {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289impl<E> ClassifyRetry for ServiceClockSkewClassifier<E>
290where
291    E: StdError + ProvideErrorMetadata + Send + Sync + 'static,
292{
293    // Only `classify_retry` is implemented; the default `classify_retry_v2` delegates to it. This
294    // classifier makes a self-contained determination from the response alone (the attached skew
295    // and the error code), so it has no use for the verdict accumulated by earlier classifiers
296    // that `classify_retry_v2` exposes.
297    fn classify_retry(&self, ctx: &InterceptorContext) -> RetryAction {
298        // Only a surviving candidate (one that passed the Age/duration/Date checks) is
299        // attached; its absence means no skew could be computed for this response.
300        let Some(skew) = ctx
301            .response()
302            .and_then(|r| r.extension::<ResponseClockSkew>())
303        else {
304            return RetryAction::NoActionIndicated;
305        };
306        if skew.0.abs() <= SKEW_DETECTION_THRESHOLD {
307            return RetryAction::NoActionIndicated;
308        }
309        let error_code = match ctx.output_or_error() {
310            Some(Err(err)) => OrchestratorError::as_operation_error(err)
311                .and_then(|err| err.downcast_ref::<E>())
312                .and_then(|err| err.code()),
313            _ => return RetryAction::NoActionIndicated,
314        };
315        match error_code {
316            // Non-throttling, so it draws the standard retry cost and counts toward max attempts.
317            Some(code) if CLOCK_SKEW_ERROR_CODES.contains(&code) => {
318                RetryAction::retryable_error(ErrorKind::ServerError)
319            }
320            _ => RetryAction::NoActionIndicated,
321        }
322    }
323
324    fn name(&self) -> &'static str {
325        "ServiceClockSkew"
326    }
327
328    fn priority(&self) -> RetryClassifierPriority {
329        RetryClassifierPriority::run_after(RetryClassifierPriority::http_status_code_classifier())
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use aws_smithy_async::test_util::ManualTimeSource;
337    use aws_smithy_runtime_api::client::interceptors::context::{Error, Input, Output};
338    use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
339    use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
340    use aws_smithy_types::body::SdkBody;
341    use aws_smithy_types::error::ErrorMetadata;
342    use serde::Deserialize;
343    use std::collections::HashMap;
344    use std::fmt;
345
346    #[derive(Debug)]
347    struct CodedError {
348        metadata: ErrorMetadata,
349    }
350
351    impl CodedError {
352        fn new(code: &str) -> Self {
353            Self {
354                metadata: ErrorMetadata::builder().code(code).build(),
355            }
356        }
357    }
358
359    impl fmt::Display for CodedError {
360        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361            write!(f, "coded error")
362        }
363    }
364
365    impl std::error::Error for CodedError {}
366
367    impl ProvideErrorMetadata for CodedError {
368        fn meta(&self) -> &ErrorMetadata {
369            &self.metadata
370        }
371    }
372
373    #[test]
374    fn apply_adjusts_by_sign() {
375        let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
376        assert_eq!(ClockSkew(0).apply(base), base);
377        assert_eq!(ClockSkew(5000).apply(base), base + Duration::from_secs(5));
378        assert_eq!(ClockSkew(-5000).apply(base), base - Duration::from_secs(5));
379    }
380
381    #[test]
382    fn signed_skew_tracks_direction() {
383        let midpoint = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
384        // Server ahead of the client.
385        assert_eq!(
386            signed_skew(midpoint + Duration::from_secs(10), midpoint),
387            ClockSkew(10_000)
388        );
389        // Client ahead of the server.
390        assert_eq!(
391            signed_skew(midpoint - Duration::from_secs(10), midpoint),
392            ClockSkew(-10_000)
393        );
394    }
395
396    #[test]
397    fn presigning_is_not_shifted_by_skew() {
398        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
399        let mut cfg = ConfigBag::base();
400
401        assert_eq!(
402            signing_time(now, HttpSignatureType::HttpRequestHeaders, &cfg),
403            now,
404            "nothing to apply before a skew is recorded",
405        );
406
407        cfg.interceptor_state().store_put(AttemptSkew(TEN_MIN));
408        assert_eq!(
409            signing_time(now, HttpSignatureType::HttpRequestHeaders, &cfg),
410            now + Duration::from_secs(10 * 60),
411        );
412        assert_eq!(
413            signing_time(now, HttpSignatureType::HttpRequestQueryParams, &cfg),
414            now,
415            "a presigned URL must not move with the client's skew",
416        );
417    }
418
419    // Build a failed-response context with an optional attached skew and error code.
420    fn ctx(code: Option<&'static str>, skew: Option<ClockSkew>) -> InterceptorContext {
421        let mut ctx = InterceptorContext::new(Input::doesnt_matter());
422        let http = http_1x::Response::builder()
423            .status(403)
424            .body(SdkBody::empty())
425            .unwrap();
426        let mut resp: HttpResponse = http.try_into().unwrap();
427        if let Some(s) = skew {
428            resp.add_extension(ResponseClockSkew(s));
429        }
430        ctx.set_response(resp);
431        match code {
432            Some(c) => ctx.set_output_or_error(Err(OrchestratorError::operation(Error::erase(
433                CodedError::new(c),
434            )))),
435            None => ctx.set_output_or_error(Ok(Output::erase("ok"))),
436        }
437        ctx
438    }
439
440    const TEN_MIN: ClockSkew = ClockSkew(10 * 60 * 1000);
441    const ONE_MIN: ClockSkew = ClockSkew(60 * 1000);
442
443    #[test]
444    fn no_attached_skew_is_not_retried() {
445        let classifier = ServiceClockSkewClassifier::<CodedError>::new();
446        assert_eq!(
447            classifier.classify_retry(&ctx(Some("InvalidSignatureException"), None)),
448            RetryAction::NoActionIndicated
449        );
450    }
451
452    #[test]
453    fn skew_below_threshold_is_not_retried() {
454        let classifier = ServiceClockSkewClassifier::<CodedError>::new();
455        assert_eq!(
456            classifier.classify_retry(&ctx(Some("InvalidSignatureException"), Some(ONE_MIN))),
457            RetryAction::NoActionIndicated
458        );
459    }
460
461    #[test]
462    fn skew_above_threshold_with_known_code_is_retried() {
463        let classifier = ServiceClockSkewClassifier::<CodedError>::new();
464        assert_eq!(
465            classifier.classify_retry(&ctx(Some("RequestTimeTooSkewed"), Some(TEN_MIN))),
466            RetryAction::retryable_error(ErrorKind::ServerError)
467        );
468    }
469
470    #[test]
471    fn skew_above_threshold_with_unknown_code_is_not_retried() {
472        let classifier = ServiceClockSkewClassifier::<CodedError>::new();
473        assert_eq!(
474            classifier.classify_retry(&ctx(Some("SomeOtherError"), Some(TEN_MIN))),
475            RetryAction::NoActionIndicated
476        );
477    }
478
479    #[test]
480    fn success_is_not_retried() {
481        let classifier = ServiceClockSkewClassifier::<CodedError>::new();
482        assert_eq!(
483            classifier.classify_retry(&ctx(None, Some(TEN_MIN))),
484            RetryAction::NoActionIndicated
485        );
486    }
487
488    // ---- Data-driven conformance suite ----
489    //
490    // Drives the real interceptor (skew measurement, discards, recording), the real classifier
491    // (code set + detection threshold), and the signing offset (`AttemptSkew::apply`, exactly what
492    // the signer runs) against the shared clock-skew test cases. The attempt/retry sequencing is
493    // modeled here (the JSON lists the attempts that occur); the orchestrator wiring itself is
494    // covered by codegen tests. Time is injected via a settable `ManualTimeSource`.
495
496    #[derive(Deserialize)]
497    struct Suite {
498        tests: Vec<TestCase>,
499    }
500
501    #[derive(Deserialize)]
502    #[serde(rename_all = "camelCase")]
503    struct TestCase {
504        description: String,
505        operations: Vec<Operation>,
506    }
507
508    #[derive(Deserialize)]
509    #[serde(rename_all = "camelCase")]
510    struct Operation {
511        initial_client_skew: i64,
512        #[serde(default)]
513        max_attempts: Option<u32>,
514        attempts: Vec<Attempt>,
515        expected_client_skew: i64,
516        expected_outcome: String,
517    }
518
519    #[derive(Deserialize)]
520    #[serde(rename_all = "camelCase")]
521    struct Attempt {
522        client_time_at_send: String,
523        client_time_at_receive: String,
524        expected_signing_time: String,
525        response: ResponseSpec,
526    }
527
528    #[derive(Deserialize)]
529    #[serde(rename_all = "camelCase")]
530    struct ResponseSpec {
531        status_code: u16,
532        #[serde(default)]
533        headers: HashMap<String, String>,
534        #[serde(default)]
535        error_code: Option<String>,
536    }
537
538    const SUITE: &str = include_str!("../test-data/clock-skew-test-cases.json");
539
540    fn parse_time(s: &str) -> SystemTime {
541        SystemTime::try_from(DateTime::from_str(s, Format::DateTime).expect("valid timestamp"))
542            .expect("representable time")
543    }
544
545    // The suite expresses skews in whole seconds.
546    fn skew_secs(secs: i64) -> ClockSkew {
547        ClockSkew(secs * 1000)
548    }
549
550    fn build_response(spec: &ResponseSpec) -> HttpResponse {
551        let mut builder = http_1x::Response::builder().status(spec.status_code);
552        for (name, value) in &spec.headers {
553            builder = builder.header(name.as_str(), value.as_str());
554        }
555        builder.body(SdkBody::empty()).unwrap().try_into().unwrap()
556    }
557
558    fn response_output_or_error(spec: &ResponseSpec) -> Result<Output, OrchestratorError<Error>> {
559        match &spec.error_code {
560            Some(code) => Err(OrchestratorError::operation(Error::erase(CodedError::new(
561                code,
562            )))),
563            None => Ok(Output::erase("ok")),
564        }
565    }
566
567    // A context advanced to the `BeforeTransmit` phase with a placeholder request.
568    fn before_transmit_context() -> InterceptorContext {
569        let mut context = InterceptorContext::new(Input::doesnt_matter());
570        context.enter_serialization_phase();
571        context.set_request(HttpRequest::empty());
572        let _ = context.take_input();
573        context.enter_before_transmit_phase();
574        context
575    }
576
577    fn run_operation(interceptor: &ServiceClockSkewInterceptor, op: &Operation, desc: &str) {
578        let time = ManualTimeSource::new(SystemTime::UNIX_EPOCH);
579        let rc = RuntimeComponentsBuilder::for_tests()
580            .with_time_source(Some(time.clone()))
581            .build()
582            .unwrap();
583        let mut cfg = ConfigBag::base();
584
585        // Once per operation: seed AttemptSkew from the persisted ClientSkew.
586        {
587            let mut seed = before_transmit_context();
588            let mut seed_ctx = (&mut seed).into();
589            interceptor
590                .modify_before_retry_loop(&mut seed_ctx, &rc, &mut cfg)
591                .unwrap();
592        }
593
594        for (j, attempt) in op.attempts.iter().enumerate() {
595            let send = parse_time(&attempt.client_time_at_send);
596            let receive = parse_time(&attempt.client_time_at_receive);
597
598            // The signer signs at now() + AttemptSkew.
599            time.set_time(send);
600            let attempt_skew = cfg.load::<AttemptSkew>().map(|s| s.0).unwrap_or_default();
601            assert_eq!(
602                attempt_skew.apply(send),
603                parse_time(&attempt.expected_signing_time),
604                "{desc}: attempt {j} signing time",
605            );
606
607            let mut context = before_transmit_context();
608            {
609                let ref_ctx = (&context).into();
610                interceptor
611                    .read_before_transmit(&ref_ctx, &rc, &mut cfg)
612                    .unwrap();
613            }
614
615            context.enter_transmit_phase();
616            let _ = context.take_request();
617            context.set_response(build_response(&attempt.response));
618            context.enter_before_deserialization_phase();
619            time.set_time(receive);
620            {
621                let mut mut_ctx = (&mut context).into();
622                interceptor
623                    .modify_before_deserialization(&mut mut_ctx, &rc, &mut cfg)
624                    .unwrap();
625            }
626
627            context.enter_deserialization_phase();
628            context.set_output_or_error(response_output_or_error(&attempt.response));
629            let action = ServiceClockSkewClassifier::<CodedError>::new().classify_retry(&context);
630
631            let is_last = j + 1 == op.attempts.len();
632            if !is_last {
633                // A retry followed, so the classifier must have indicated one.
634                assert!(action.should_retry(), "{desc}: attempt {j} should retry");
635            } else if op.expected_outcome == "error"
636                && op
637                    .max_attempts
638                    .is_none_or(|m| (op.attempts.len() as u32) < m)
639            {
640                // No further attempt despite remaining budget => the classifier declined to retry.
641                assert!(
642                    !action.should_retry(),
643                    "{desc}: final attempt should not be retried as clock skew",
644                );
645            }
646        }
647
648        let final_status = op
649            .attempts
650            .last()
651            .expect("at least one attempt")
652            .response
653            .status_code;
654        let outcome = if (200..300).contains(&final_status) {
655            "success"
656        } else {
657            "error"
658        };
659        assert_eq!(outcome, op.expected_outcome, "{desc}: outcome");
660    }
661
662    #[test]
663    fn clock_skew_conformance() {
664        let suite: Suite = serde_json::from_str(SUITE).expect("valid clock skew suite json");
665        assert_eq!(suite.tests.len(), 10, "expected the full clock skew suite");
666        for case in &suite.tests {
667            // ClientSkew starts at the first operation's initial value and persists across the
668            // operations of a case (a single client).
669            let interceptor = ServiceClockSkewInterceptor::with_client_skew(skew_secs(
670                case.operations[0].initial_client_skew,
671            ));
672            for (i, op) in case.operations.iter().enumerate() {
673                assert_eq!(
674                    interceptor.client_skew(),
675                    skew_secs(op.initial_client_skew),
676                    "{}: operation {i} initial ClientSkew",
677                    case.description,
678                );
679                run_operation(&interceptor, op, &case.description);
680                assert_eq!(
681                    interceptor.client_skew(),
682                    skew_secs(op.expected_client_skew),
683                    "{}: operation {i} expected ClientSkew",
684                    case.description,
685                );
686            }
687        }
688    }
689}