1use std::sync::Arc;
17use std::time::Duration;
18
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Platform {
29 Ios,
31 Android,
33 Web,
35}
36
37impl Platform {
38 pub fn name(&self) -> &'static str {
40 match self {
41 Platform::Ios => "ios",
42 Platform::Android => "android",
43 Platform::Web => "web",
44 }
45 }
46}
47
48impl std::fmt::Display for Platform {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.write_str(self.name())
51 }
52}
53
54#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum Recipient {
74 Apns { device_token: String },
76 Fcm { registration_token: String },
78 WebPush {
81 endpoint: String,
82 p256dh: String,
83 auth: String,
84 },
85}
86
87impl Recipient {
88 pub fn apns(device_token: impl Into<String>) -> Self {
90 Recipient::Apns {
91 device_token: device_token.into(),
92 }
93 }
94
95 pub fn fcm(registration_token: impl Into<String>) -> Self {
97 Recipient::Fcm {
98 registration_token: registration_token.into(),
99 }
100 }
101
102 pub fn web_push(
104 endpoint: impl Into<String>,
105 p256dh: impl Into<String>,
106 auth: impl Into<String>,
107 ) -> Self {
108 Recipient::WebPush {
109 endpoint: endpoint.into(),
110 p256dh: p256dh.into(),
111 auth: auth.into(),
112 }
113 }
114
115 pub fn platform(&self) -> Platform {
118 match self {
119 Recipient::Apns { .. } => Platform::Ios,
120 Recipient::Fcm { .. } => Platform::Android,
121 Recipient::WebPush { .. } => Platform::Web,
122 }
123 }
124}
125
126fn fingerprint(value: &str) -> String {
132 use sha2::{Digest as _, Sha256};
133 use std::fmt::Write as _;
134
135 let digest = Sha256::digest(value.as_bytes());
136 let mut hex = String::with_capacity(12);
137 for byte in digest.iter().take(6) {
138 let _ = write!(hex, "{byte:02x}");
140 }
141 hex
142}
143
144impl std::fmt::Debug for Recipient {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 match self {
151 Recipient::Apns { device_token } => write!(
152 f,
153 "Recipient::Apns {{ device_token: fp:{} }}",
154 fingerprint(device_token)
155 ),
156 Recipient::Fcm { registration_token } => write!(
157 f,
158 "Recipient::Fcm {{ registration_token: fp:{} }}",
159 fingerprint(registration_token)
160 ),
161 Recipient::WebPush {
164 endpoint, p256dh, ..
165 } => write!(
166 f,
167 "Recipient::WebPush {{ endpoint: fp:{}, p256dh: fp:{}, auth: [redacted] }}",
168 fingerprint(endpoint),
169 fingerprint(p256dh)
170 ),
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum Priority {
182 #[default]
184 Immediate,
185 Conserve,
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
194pub struct LocKeys {
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub title_loc_key: Option<String>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub title_loc_args: Vec<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub body_loc_key: Option<String>,
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub body_loc_args: Vec<String>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Notification {
218 pub title: String,
219 pub body: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub category: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub thread_id: Option<String>,
226 #[serde(default)]
228 pub data: Value,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub collapse_id: Option<String>,
235 #[serde(default)]
236 pub priority: Priority,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub icon: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub url: Option<String>,
245 #[serde(
260 default,
261 skip_serializing_if = "Option::is_none",
262 with = "duration_secs"
263 )]
264 pub ttl: Option<Duration>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub badge: Option<u32>,
268 #[serde(default, skip_serializing_if = "is_false")]
271 pub silent: bool,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub loc: Option<LocKeys>,
276}
277
278#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(value: &bool) -> bool {
280 !*value
281}
282
283#[must_use]
292pub fn ttl_secs(ttl: Duration) -> u64 {
293 match ttl.as_secs() {
294 0 if !ttl.is_zero() => 1,
295 secs => secs,
296 }
297}
298
299const RETRY_AFTER: &str = "retry-after";
301
302static IMF_FIXDATE: std::sync::LazyLock<
313 Vec<time::format_description::BorrowedFormatItem<'static>>,
314> = std::sync::LazyLock::new(|| {
315 time::format_description::parse_borrowed::<2>(
316 "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT",
317 )
318 .expect("a format description that is a literal in this file")
319});
320
321fn parse_http_date(value: &str) -> Option<time::OffsetDateTime> {
329 time::PrimitiveDateTime::parse(value, IMF_FIXDATE.as_slice())
330 .ok()
331 .map(time::PrimitiveDateTime::assume_utc)
332}
333
334#[must_use]
359pub fn retry_after(headers: &http::HeaderMap, clock: &dyn crate::Clock) -> Option<Duration> {
360 let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
361 if let Ok(seconds) = value.parse::<u64>() {
362 return Some(Duration::from_secs(seconds));
363 }
364 let when = parse_http_date(value)?;
365 let delta = when - clock.now();
366 if delta.is_negative() {
367 return Some(Duration::ZERO);
368 }
369 Duration::try_from(delta).ok()
370}
371
372mod duration_secs {
375 use serde::{Deserialize, Deserializer, Serializer};
376 use std::time::Duration;
377
378 #[allow(clippy::ref_option)]
381 pub fn serialize<S: Serializer>(
382 value: &Option<Duration>,
383 serializer: S,
384 ) -> Result<S::Ok, S::Error> {
385 match value {
386 Some(duration) => serializer.serialize_some(&super::ttl_secs(*duration)),
389 None => serializer.serialize_none(),
390 }
391 }
392
393 pub fn deserialize<'de, D: Deserializer<'de>>(
394 deserializer: D,
395 ) -> Result<Option<Duration>, D::Error> {
396 Ok(Option::<u64>::deserialize(deserializer)?.map(Duration::from_secs))
397 }
398}
399
400impl Notification {
401 #[must_use]
403 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
404 Self {
405 title: title.into(),
406 body: body.into(),
407 category: None,
408 thread_id: None,
409 data: Value::Null,
410 collapse_id: None,
411 priority: Priority::Immediate,
412 icon: None,
413 url: None,
414 ttl: None,
415 badge: None,
416 silent: false,
417 loc: None,
418 }
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum PushOutcome {
425 Delivered { id: Option<String> },
427 NotConfigured,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq)]
446pub enum PushError {
447 Unregistered,
459 Rejected(String),
463 Transient {
468 message: String,
469 retry_after: Option<Duration>,
470 },
471}
472
473impl std::fmt::Display for PushError {
474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475 let scrub = crate::logging::scrub_text;
476 match self {
477 Self::Unregistered => f.write_str("device token is no longer registered; delete it"),
478 Self::Rejected(message) => write!(f, "push rejected: {}", scrub(message)),
479 Self::Transient { message, .. } => {
480 write!(f, "push failed, retryable: {}", scrub(message))
481 }
482 }
483 }
484}
485
486impl std::error::Error for PushError {}
487
488impl PushError {
489 pub fn transient(message: impl Into<String>) -> Self {
491 PushError::Transient {
492 message: message.into(),
493 retry_after: None,
494 }
495 }
496
497 pub fn transient_after(message: impl Into<String>, retry_after: Option<Duration>) -> Self {
499 PushError::Transient {
500 message: message.into(),
501 retry_after,
502 }
503 }
504
505 pub fn unsupported_recipient(recipient: &Recipient) -> Self {
508 PushError::Rejected(format!(
509 "unsupported recipient: this adapter does not serve {}",
510 recipient.platform()
511 ))
512 }
513
514 pub fn retry_after(&self) -> Option<Duration> {
516 match self {
517 PushError::Transient { retry_after, .. } => *retry_after,
518 _ => None,
519 }
520 }
521}
522
523#[async_trait]
528pub trait Push: Send + Sync {
529 async fn send(
531 &self,
532 to: &Recipient,
533 notification: &Notification,
534 ) -> Result<PushOutcome, PushError>;
535}
536
537#[derive(Default, Clone)]
546pub struct RoutingPush {
547 apns: Option<Arc<dyn Push>>,
548 fcm: Option<Arc<dyn Push>>,
549 web_push: Option<Arc<dyn Push>>,
550}
551
552impl RoutingPush {
553 #[must_use]
555 pub fn new() -> Self {
556 Self::default()
557 }
558
559 #[must_use]
561 pub fn apns(mut self, push: Arc<dyn Push>) -> Self {
562 self.apns = Some(push);
563 self
564 }
565
566 #[must_use]
568 pub fn fcm(mut self, push: Arc<dyn Push>) -> Self {
569 self.fcm = Some(push);
570 self
571 }
572
573 #[must_use]
575 pub fn web_push(mut self, push: Arc<dyn Push>) -> Self {
576 self.web_push = Some(push);
577 self
578 }
579
580 #[must_use]
582 pub fn route_for(&self, recipient: &Recipient) -> Option<&Arc<dyn Push>> {
583 match recipient {
584 Recipient::Apns { .. } => self.apns.as_ref(),
585 Recipient::Fcm { .. } => self.fcm.as_ref(),
586 Recipient::WebPush { .. } => self.web_push.as_ref(),
587 }
588 }
589}
590
591impl std::fmt::Debug for RoutingPush {
592 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593 f.debug_struct("RoutingPush")
594 .field("apns", &self.apns.is_some())
595 .field("fcm", &self.fcm.is_some())
596 .field("web_push", &self.web_push.is_some())
597 .finish()
598 }
599}
600
601#[async_trait]
602impl Push for RoutingPush {
603 async fn send(
604 &self,
605 to: &Recipient,
606 notification: &Notification,
607 ) -> Result<PushOutcome, PushError> {
608 match self.route_for(to) {
609 Some(push) => push.send(to, notification).await,
610 None => Ok(PushOutcome::NotConfigured),
611 }
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn a_recipient_round_trips_through_json() {
621 for recipient in [
622 Recipient::apns("devicetoken"),
623 Recipient::fcm("registration"),
624 Recipient::web_push("https://fcm.googleapis.com/wp/x", "p256dh-key", "auth-key"),
625 ] {
626 let json = serde_json::to_string(&recipient).expect("serialises");
627 let back: Recipient = serde_json::from_str(&json).expect("deserialises");
628 assert_eq!(recipient, back);
629 }
630 }
631
632 #[test]
633 fn the_recipient_tag_is_the_transport_name() {
634 let json = serde_json::to_value(Recipient::web_push("https://e", "p", "a")).unwrap();
635 assert_eq!(json["web_push"]["endpoint"], "https://e");
636 assert_eq!(json["web_push"]["p256dh"], "p");
637 assert_eq!(json["web_push"]["auth"], "a");
638 }
639
640 #[test]
641 fn debug_prints_no_credential_material() {
642 let web = Recipient::web_push(
646 "https://fcm.googleapis.com/wp/cAPABILITYtokenPATH",
647 "BP256dhPublicKeyValue",
648 "AuthSecretValue",
649 );
650 let printed = format!("{web:?}");
651 assert!(printed.contains("WebPush"), "{printed}");
652 assert!(!printed.contains("AuthSecretValue"), "{printed}");
653 assert!(!printed.contains("cAPABILITYtokenPATH"), "{printed}");
654 assert!(!printed.contains("fcm.googleapis.com"), "{printed}");
655 assert!(!printed.contains("BP256dhPublicKeyValue"), "{printed}");
656
657 let apns = format!("{:?}", Recipient::apns("deadbeefDEVICEtoken"));
658 assert!(apns.contains("Apns"), "{apns}");
659 assert!(!apns.contains("deadbeefDEVICEtoken"), "{apns}");
660
661 let fcm = format!("{:?}", Recipient::fcm("REGISTRATIONtokenValue"));
662 assert!(fcm.contains("Fcm"), "{fcm}");
663 assert!(!fcm.contains("REGISTRATIONtokenValue"), "{fcm}");
664
665 assert_eq!(printed, format!("{web:?}"));
667 assert_ne!(
668 printed,
669 format!("{:?}", Recipient::web_push("https://other", "p", "a"))
670 );
671 }
672
673 #[test]
674 fn a_recipient_inside_a_struct_is_redacted_too() {
675 #[derive(Debug)]
678 struct Row {
679 recipient: Recipient,
680 }
681 let row = Row {
682 recipient: Recipient::apns("deadbeefDEVICEtoken"),
683 };
684 let printed = format!("{row:?}");
685 assert!(!printed.contains("deadbeefDEVICEtoken"), "{printed}");
686 assert_eq!(row.recipient.platform(), Platform::Ios);
687 }
688
689 #[test]
690 fn platform_is_a_transport_fact() {
691 assert_eq!(Recipient::apns("t").platform(), Platform::Ios);
692 assert_eq!(Recipient::fcm("t").platform(), Platform::Android);
693 assert_eq!(
695 Recipient::web_push("https://ntfy.sh/x", "p", "a").platform(),
696 Platform::Web
697 );
698 }
699
700 #[test]
701 fn a_minimal_notification_serialises_to_title_body_data_priority() {
702 let json = serde_json::to_value(Notification::new("Hi", "there")).unwrap();
703 let object = json.as_object().expect("object");
704 let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
705 keys.sort_unstable();
706 assert_eq!(keys, ["body", "data", "priority", "title"]);
707 }
708
709 #[test]
710 fn a_full_notification_round_trips_and_ttl_is_seconds() {
711 let mut notification = Notification::new("Room starting", "Yoga in 10 min");
712 notification.category = Some("SESSION".to_owned());
713 notification.thread_id = Some("room-42".to_owned());
714 notification.data = serde_json::json!({ "room_id": "42" });
715 notification.collapse_id = Some("room-42".to_owned());
716 notification.priority = Priority::Conserve;
717 notification.icon = Some("https://example.test/icon.png".to_owned());
718 notification.url = Some("https://example.test/rooms/42".to_owned());
719 notification.ttl = Some(Duration::from_secs(3_600));
720 notification.badge = Some(3);
721 notification.silent = true;
722 notification.loc = Some(LocKeys {
723 title_loc_key: Some("ROOM_STARTING".to_owned()),
724 title_loc_args: vec!["Yoga".to_owned()],
725 body_loc_key: Some("ROOM_BODY".to_owned()),
726 body_loc_args: vec!["10".to_owned()],
727 });
728
729 let json = serde_json::to_value(¬ification).unwrap();
730 assert_eq!(json["ttl"], 3_600, "a TTL is whole seconds on the wire");
731 assert_eq!(json["silent"], true);
732 assert_eq!(json["loc"]["title_loc_key"], "ROOM_STARTING");
733
734 let back: Notification = serde_json::from_value(json).unwrap();
735 assert_eq!(back, notification);
736 }
737
738 #[test]
739 fn a_notification_from_the_pre_recipient_wire_form_still_parses() {
740 let old = serde_json::json!({
743 "title": "Hi",
744 "body": "there",
745 "data": {},
746 "priority": "immediate"
747 });
748 let notification: Notification = serde_json::from_value(old).unwrap();
749 assert_eq!(notification.title, "Hi");
750 assert_eq!(notification.ttl, None);
751 assert!(!notification.silent);
752 }
753
754 #[test]
755 fn a_sub_second_ttl_rounds_up_and_never_becomes_drop_now() {
756 assert_eq!(ttl_secs(Duration::from_millis(900)), 1);
760 assert_eq!(ttl_secs(Duration::from_nanos(1)), 1);
761 assert_eq!(ttl_secs(Duration::ZERO), 0, "zero is the deliberate drop");
762 assert_eq!(ttl_secs(Duration::from_secs(3_600)), 3_600);
763 assert_eq!(
764 ttl_secs(Duration::from_millis(1_900)),
765 1,
766 "whole seconds otherwise truncate, as every protocol does"
767 );
768
769 let mut notification = Notification::new("a", "b");
770 notification.ttl = Some(Duration::from_millis(900));
771 let json = serde_json::to_value(¬ification).unwrap();
772 assert_eq!(json["ttl"], 1, "sub-second serialises as one second");
773 let back: Notification = serde_json::from_value(json).unwrap();
774 assert_eq!(
775 back.ttl,
776 Some(Duration::from_secs(1)),
777 "and comes back meaning one second, not Duration::ZERO"
778 );
779
780 let mut zero = Notification::new("a", "b");
781 zero.ttl = Some(Duration::ZERO);
782 let json = serde_json::to_value(&zero).unwrap();
783 assert_eq!(json["ttl"], 0);
784 let back: Notification = serde_json::from_value(json).unwrap();
785 assert_eq!(back.ttl, Some(Duration::ZERO));
786 }
787
788 #[test]
789 fn transient_carries_an_optional_retry_after() {
790 let plain = PushError::transient("apns 503");
791 assert_eq!(plain.retry_after(), None);
792 let throttled = PushError::transient_after("apns 429", Some(Duration::from_secs(30)));
793 assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
794 assert_eq!(PushError::Unregistered.retry_after(), None);
795 }
796
797 #[test]
798 fn an_unsupported_recipient_names_the_transport() {
799 let error = PushError::unsupported_recipient(&Recipient::fcm("t"));
800 let message = error.to_string();
801 assert!(message.contains("unsupported recipient"), "{message}");
802 assert!(message.contains("android"), "{message}");
803 }
804
805 struct Recording {
809 label: &'static str,
810 seen: std::sync::atomic::AtomicUsize,
811 }
812
813 impl Recording {
814 fn new(label: &'static str) -> Arc<Self> {
815 Arc::new(Self {
816 label,
817 seen: std::sync::atomic::AtomicUsize::new(0),
818 })
819 }
820 fn count(&self) -> usize {
821 self.seen.load(std::sync::atomic::Ordering::Relaxed)
822 }
823 }
824
825 #[async_trait]
826 impl Push for Recording {
827 async fn send(
828 &self,
829 _to: &Recipient,
830 _notification: &Notification,
831 ) -> Result<PushOutcome, PushError> {
832 self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
833 Ok(PushOutcome::Delivered {
834 id: Some(self.label.to_owned()),
835 })
836 }
837 }
838
839 #[test]
840 fn the_router_dispatches_by_variant() {
841 let apns = Recording::new("apns");
842 let web = Recording::new("web");
843 let router = RoutingPush::new().apns(apns.clone()).web_push(web.clone());
844 let notification = Notification::new("a", "b");
845
846 let outcome =
847 pollster::block_on(router.send(&Recipient::apns("t"), ¬ification)).unwrap();
848 assert_eq!(
849 outcome,
850 PushOutcome::Delivered {
851 id: Some("apns".to_owned())
852 }
853 );
854 let outcome = pollster::block_on(
855 router.send(&Recipient::web_push("https://e", "p", "a"), ¬ification),
856 )
857 .unwrap();
858 assert_eq!(
859 outcome,
860 PushOutcome::Delivered {
861 id: Some("web".to_owned())
862 }
863 );
864 assert_eq!(apns.count(), 1);
865 assert_eq!(web.count(), 1);
866 }
867
868 #[test]
869 fn a_transport_with_no_adapter_is_not_configured_not_rejected() {
870 let router = RoutingPush::new().apns(Recording::new("apns"));
871 let outcome =
872 pollster::block_on(router.send(&Recipient::fcm("t"), &Notification::new("a", "b")))
873 .unwrap();
874 assert_eq!(outcome, PushOutcome::NotConfigured);
875 }
876
877 #[test]
878 fn an_empty_router_is_not_configured_for_every_transport() {
879 let router = RoutingPush::new();
880 for recipient in [
881 Recipient::apns("t"),
882 Recipient::fcm("t"),
883 Recipient::web_push("https://e", "p", "a"),
884 ] {
885 let outcome =
886 pollster::block_on(router.send(&recipient, &Notification::new("a", "b"))).unwrap();
887 assert_eq!(outcome, PushOutcome::NotConfigured);
888 assert!(router.route_for(&recipient).is_none());
889 }
890 }
891
892 #[test]
893 fn display_sanitizes_the_provider_text() {
894 let error = PushError::Rejected(
899 "web push 400 for https://push.example.test/wp/alice?auth=cap-abcdef".to_owned(),
900 );
901 let text = error.to_string();
902 assert!(!text.contains("cap-abcdef"), "{text}");
903 assert!(text.contains("?[redacted]"), "{text}");
904
905 let error = PushError::transient("fcm 503 for alice@example.test");
909 let text = error.to_string();
910 assert!(!text.contains('@'), "{text}");
911 assert!(text.contains("[subject_hash:"), "{text}");
912
913 assert_eq!(
914 PushError::Unregistered.to_string(),
915 "device token is no longer registered; delete it"
916 );
917
918 assert!(format!("{error:?}").contains("alice@example.test"));
920 }
921}
922
923#[cfg(test)]
924mod retry_after_tests {
925 use super::{parse_http_date, retry_after};
926 use crate::Clock;
927 use std::time::Duration;
928
929 struct FixedClock(time::OffsetDateTime);
932
933 #[async_trait::async_trait]
934 impl Clock for FixedClock {
935 fn now(&self) -> time::OffsetDateTime {
936 self.0
937 }
938 }
939
940 fn at(unix: i64) -> FixedClock {
941 FixedClock(time::OffsetDateTime::from_unix_timestamp(unix).expect("a valid instant"))
942 }
943
944 fn headers(value: &str) -> http::HeaderMap {
945 let mut headers = http::HeaderMap::new();
946 headers.insert("retry-after", value.parse().expect("a header value"));
947 headers
948 }
949
950 #[test]
951 fn an_http_date_parses_and_a_nonsense_one_does_not() {
952 let parsed = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").expect("IMF-fixdate");
955 assert_eq!(parsed.unix_timestamp(), 784_111_777);
956 assert!(parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").is_none());
957 assert!(parse_http_date("tomorrow").is_none());
958 }
959
960 #[test]
961 fn the_delta_seconds_form_is_read() {
962 assert_eq!(
963 retry_after(&headers("120"), &at(0)),
964 Some(Duration::from_secs(120))
965 );
966 assert_eq!(
967 retry_after(&headers(" 30 "), &at(0)),
968 Some(Duration::from_secs(30)),
969 "surrounding space is not a parse failure"
970 );
971 }
972
973 #[test]
974 fn the_http_date_form_is_read_too() {
975 let an_hour_before = 784_111_777 - 3_600;
979 assert_eq!(
980 retry_after(
981 &headers("Sun, 06 Nov 1994 08:49:37 GMT"),
982 &at(an_hour_before)
983 ),
984 Some(Duration::from_secs(3_600))
985 );
986 }
987
988 #[test]
989 fn a_date_already_past_means_retry_now_not_no_delay() {
990 assert_eq!(
993 retry_after(
994 &headers("Sun, 06 Nov 1994 08:49:37 GMT"),
995 &at(784_111_777 + 60)
996 ),
997 Some(Duration::ZERO)
998 );
999 }
1000
1001 #[test]
1002 fn an_absent_or_unparseable_header_states_no_delay() {
1003 assert_eq!(retry_after(&http::HeaderMap::new(), &at(0)), None);
1004 assert_eq!(retry_after(&headers("soon"), &at(0)), None);
1005 assert_eq!(
1006 retry_after(&headers("-5"), &at(0)),
1007 None,
1008 "a negative delta is not a duration"
1009 );
1010 }
1011}