1use std::collections::HashMap;
16
17use prost::Message;
18
19#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
25#[derive(Debug, Clone, PartialEq, Default)]
26pub struct ExportRequest {
27 pub url: String,
29 pub headers: HashMap<String, String>,
31 pub body: Vec<u8>,
33}
34
35#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
39#[derive(Debug, Clone, PartialEq, Default)]
40pub struct ExportResponse {
41 pub status: u16,
43 pub headers: HashMap<String, String>,
45 pub body: Vec<u8>,
47}
48
49impl ExportResponse {
50 pub fn accepted() -> Self {
52 Self { status: 200, ..Self::default() }
53 }
54}
55
56const DISABLED_ANSWER: &str = "data recording is disabled by owner";
59
60const MAX_REASON_CHARS: usize = 200;
63
64const MAX_RESPONSE_BYTES: usize = 65_536;
66
67fn response_overflow<'a>(headers: impl Iterator<Item = (&'a str, &'a str)>) -> ExportError {
68 let retry_after_ms = headers
69 .filter(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
70 .find_map(|(_, value)| retry_after_ms(value));
71 ExportError::Retryable {
72 reason: "collector response exceeds 65536 decoded bytes".into(),
73 retry_after_ms,
74 }
75}
76
77const THROTTLE_DEFAULT_MS: u64 = 60_000;
79
80#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
83#[derive(Debug, Clone, PartialEq, thiserror::Error)]
84pub enum ExportError {
85 #[error("retryable export error: {reason}")]
88 Retryable {
89 reason: String,
91 retry_after_ms: Option<u64>,
93 },
94 #[error("export rejected: {reason}")]
96 Rejected {
97 reason: String,
99 },
100}
101
102#[cfg(feature = "uniffi")]
105impl From<uniffi::UnexpectedUniFFICallbackError> for ExportError {
106 fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self {
107 Self::Retryable {
108 reason: format!("transport threw: {}", error.reason),
109 retry_after_ms: None,
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq)]
117pub(crate) enum Verdict {
118 Accepted { rejected: u64, reason: String },
120 Rejected(String),
123 TooLarge,
125 Unauthorized(String),
128 NotFound,
130 Disabled,
133 Throttled { delay_ms: u64, reason: String },
135 Retry { delay_ms: Option<u64>, reason: String },
138}
139
140impl Verdict {
141 pub(crate) fn of(response: &ExportResponse) -> Self {
143 if response.body.len() > MAX_RESPONSE_BYTES {
144 return match response_overflow(
145 response.headers.iter().map(|(n, v)| (n.as_str(), v.as_str())),
146 ) {
147 ExportError::Retryable { reason, retry_after_ms } => {
148 Self::Retry { delay_ms: retry_after_ms, reason }
149 }
150 ExportError::Rejected { .. } => unreachable!("overflow is retryable"),
151 };
152 }
153 let status = response.status;
154 if (200..300).contains(&status) {
155 return match PartialSuccessResponse::decode(response.body.as_slice()) {
156 Ok(PartialSuccessResponse { partial_success: Some(p) }) if p.rejected > 0 => {
157 Self::Accepted { rejected: p.rejected as u64, reason: p.error_message }
158 }
159 _ => Self::Accepted { rejected: 0, reason: String::new() },
160 };
161 }
162 let rpc = RpcStatus::decode(response.body.as_slice()).ok();
166 let message = rpc.as_ref().map(|s| s.message.trim()).filter(|m| !m.is_empty());
167 let answer = message.map_or(response.body.as_slice(), str::as_bytes);
168 if matches!(status, 401 | 403) && contains_ignore_ascii_case(answer, DISABLED_ANSWER) {
169 return Self::Disabled;
170 }
171 let head = &answer[..answer.len().min(MAX_REASON_CHARS * 4)];
172 let text: String =
173 String::from_utf8_lossy(head).trim().chars().take(MAX_REASON_CHARS).collect();
174 let reason = if text.is_empty() {
177 format!("HTTP {status}")
178 } else {
179 format!("HTTP {status}: {text}")
180 };
181 if matches!(status, 401 | 403) {
182 return Self::Unauthorized(reason);
183 }
184 let header = response
185 .headers
186 .iter()
187 .find(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
188 .and_then(|(_, value)| retry_after_ms(value));
189 let retry_info = rpc.as_ref().and_then(retry_info_ms);
190 let delay = header.or(retry_info);
191 match status {
194 404 => Self::NotFound,
195 413 => Self::TooLarge,
196 429 => Self::Throttled { delay_ms: delay.unwrap_or(THROTTLE_DEFAULT_MS), reason },
201 503 if delay.is_some() => {
202 Self::Throttled { delay_ms: delay.unwrap_or_default(), reason }
203 }
204 502..=504 => Self::Retry { delay_ms: delay, reason },
205 500 if retry_info.is_some() => Self::Retry { delay_ms: retry_info, reason },
208 _ => Self::Rejected(reason),
209 }
210 }
211}
212
213fn contains_ignore_ascii_case(haystack: &[u8], needle: &str) -> bool {
215 needle.is_empty()
216 || haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
217}
218
219#[derive(Clone, PartialEq, prost::Message)]
222struct PartialSuccessResponse {
223 #[prost(message, optional, tag = "1")]
224 partial_success: Option<PartialSuccess>,
225}
226
227#[derive(Clone, PartialEq, prost::Message)]
228struct PartialSuccess {
229 #[prost(int64, tag = "1")]
230 rejected: i64,
231 #[prost(string, tag = "2")]
232 error_message: String,
233}
234
235#[derive(Clone, PartialEq, prost::Message)]
238struct RpcStatus {
239 #[prost(int32, tag = "1")]
240 code: i32,
241 #[prost(string, tag = "2")]
242 message: String,
243 #[prost(message, repeated, tag = "3")]
244 details: Vec<prost_types::Any>,
245}
246
247#[derive(Clone, PartialEq, prost::Message)]
249struct RetryInfo {
250 #[prost(message, optional, tag = "1")]
251 retry_delay: Option<prost_types::Duration>,
252}
253
254fn retry_after_ms(value: &str) -> Option<u64> {
257 let value = value.trim();
258 if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) {
260 return Some(value.parse::<u64>().unwrap_or(u64::MAX).saturating_mul(1000));
261 }
262 let at = imf_fixdate_secs(value)?;
263 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
264 Some(at.saturating_sub(now).saturating_mul(1000))
265}
266
267fn imf_fixdate_secs(value: &str) -> Option<u64> {
270 let mut parts = value.split_ascii_whitespace();
271 let (_weekday, day, month, year, time, zone) =
272 (parts.next()?, parts.next()?, parts.next()?, parts.next()?, parts.next()?, parts.next()?);
273 if zone != "GMT" || parts.next().is_some() {
274 return None;
275 }
276 const MONTHS: [&str; 12] =
277 ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
278 let month = MONTHS.iter().position(|m| *m == month)? as i64 + 1;
279 let (day, year): (i64, i64) = (day.parse().ok()?, year.parse().ok()?);
280 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
281 let month_days = match month {
282 2 if leap => 29,
283 2 => 28,
284 4 | 6 | 9 | 11 => 30,
285 _ => 31,
286 };
287 let mut hms = time.split(':').map(|n| n.parse::<i64>().ok());
288 let (h, m, s) = (hms.next()??, hms.next()??, hms.next()??);
289 if !(1970..=9999).contains(&year)
291 || hms.next().is_some()
292 || !(1..=month_days).contains(&day)
293 || !(0..24).contains(&h)
294 || !(0..60).contains(&m)
295 || !(0..61).contains(&s)
296 {
297 return None;
298 }
299 let y = if month <= 2 { year - 1 } else { year };
301 let era = y.div_euclid(400);
302 let yoe = y - era * 400;
303 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
304 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
305 let days = era * 146_097 + doe - 719_468;
306 u64::try_from(days * 86_400 + h * 3600 + m * 60 + s).ok()
307}
308
309fn retry_info_ms(status: &RpcStatus) -> Option<u64> {
310 status
311 .details
312 .iter()
313 .filter(|detail| detail.type_url.ends_with("google.rpc.RetryInfo"))
314 .find_map(|detail| RetryInfo::decode(detail.value.as_slice()).ok()?.retry_delay)
315 .map(|delay| {
316 (delay.seconds.max(0) as u64)
317 .saturating_mul(1000)
318 .saturating_add(delay.nanos.clamp(0, 999_999_999) as u64 / 1_000_000)
319 })
320}
321
322#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
344#[async_trait::async_trait]
345pub trait TelemetryTransport: Send + Sync {
346 async fn send(&self, request: ExportRequest) -> Result<ExportResponse, ExportError>;
348}
349
350#[cfg(feature = "net")]
351mod net {
352 use std::sync::Arc;
353
354 use livekit_net::{Header, HttpClient, HttpMethod, HttpResponse, TransportError};
355
356 use super::{
357 response_overflow, ExportError, ExportRequest, ExportResponse, TelemetryTransport,
358 MAX_RESPONSE_BYTES,
359 };
360
361 pub struct NetTransport(Arc<dyn HttpClient>);
364
365 impl NetTransport {
366 pub(crate) fn new(client: Arc<dyn HttpClient>) -> Self {
368 Self(client)
369 }
370
371 pub fn from_registry() -> Option<Self> {
382 livekit_net::http_client_with_response_limit(MAX_RESPONSE_BYTES as u64).map(Self::new)
383 }
384 }
385
386 impl From<HttpResponse> for ExportResponse {
387 fn from(response: HttpResponse) -> Self {
388 Self {
389 status: response.status,
390 headers: response.headers.into_iter().map(|h| (h.name, h.value)).collect(),
391 body: response.body,
392 }
393 }
394 }
395
396 #[async_trait::async_trait]
397 impl TelemetryTransport for NetTransport {
398 async fn send(&self, request: ExportRequest) -> Result<ExportResponse, ExportError> {
399 match url::Url::parse(&request.url) {
403 Err(error) => {
404 return Err(ExportError::Rejected {
405 reason: format!("{}: {error}", request.url),
406 })
407 }
408 Ok(url) if !matches!(url.scheme(), "http" | "https") => {
409 let reason = format!("{}: not an HTTP URL", request.url);
410 return Err(ExportError::Rejected { reason });
411 }
412 Ok(_) => {}
413 }
414 let headers =
415 request.headers.into_iter().map(|(name, value)| Header { name, value }).collect();
416 match self.0.request(HttpMethod::Post, request.url, headers, Some(request.body)).await {
417 Ok(response) if response.body.len() > MAX_RESPONSE_BYTES => Err(response_overflow(
418 response.headers.iter().map(|h| (h.name.as_str(), h.value.as_str())),
419 )),
420 Ok(response) => Ok(response.into()),
421 Err(TransportError::ResponseTooLarge { headers }) => Err(response_overflow(
422 headers.iter().map(|h| (h.name.as_str(), h.value.as_str())),
423 )),
424 Err(TransportError::Http { status }) => {
429 Ok(ExportResponse { status, ..Default::default() })
430 }
431 Err(other) => {
432 Err(ExportError::Retryable { reason: other.to_string(), retry_after_ms: None })
433 }
434 }
435 }
436 }
437}
438
439#[cfg(feature = "net")]
440pub use net::NetTransport;
441
442#[cfg(all(test, feature = "net"))]
443mod net_tests {
444 use std::sync::Arc;
445
446 use livekit_net::{Header, HttpClient, HttpMethod, HttpResponse, TransportError};
447
448 use super::*;
449
450 struct Unreachable;
452
453 #[async_trait::async_trait]
454 impl HttpClient for Unreachable {
455 async fn request(
456 &self,
457 _: HttpMethod,
458 url: String,
459 _: Vec<Header>,
460 _: Option<Vec<u8>>,
461 ) -> Result<HttpResponse, TransportError> {
462 panic!("{url} reached the client")
463 }
464 }
465
466 struct BufferedHost;
467
468 #[async_trait::async_trait]
469 impl HttpClient for BufferedHost {
470 async fn request(
471 &self,
472 _: HttpMethod,
473 _: String,
474 _: Vec<Header>,
475 _: Option<Vec<u8>>,
476 ) -> Result<HttpResponse, TransportError> {
477 Ok(HttpResponse {
478 status: 403,
479 headers: vec![Header { name: "Retry-After".into(), value: "17".into() }],
480 body: vec![b'x'; MAX_RESPONSE_BYTES + 1],
481 })
482 }
483 }
484
485 #[tokio::test]
486 async fn already_buffered_host_overflow_is_retryable_and_preserves_headers() {
487 let host = Arc::new(BufferedHost);
488 let weak = Arc::downgrade(&host);
489 let transport = NetTransport::new(host);
490 let result = transport
491 .send(ExportRequest { url: "http://collector/v1/logs".into(), ..Default::default() })
492 .await;
493 drop(transport);
494 assert!(weak.upgrade().is_none());
495 assert!(matches!(result, Err(ExportError::Retryable { retry_after_ms: Some(17_000), .. })));
496 }
497
498 #[tokio::test]
502 async fn a_malformed_url_is_rejected_before_the_client_sees_it() {
503 let transport = NetTransport::new(Arc::new(Unreachable));
504 for url in ["not a url", "ftp://collector/v1/logs", "file:///tmp/collector", "mailto:x@y"] {
505 let request = ExportRequest { url: url.into(), ..Default::default() };
506 assert!(
507 matches!(
508 transport.send(request).await,
509 Err(ExportError::Rejected { ref reason }) if reason.contains(url)
510 ),
511 "{url}"
512 );
513 }
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use prost::Message;
520
521 use super::*;
522
523 #[test]
524 fn oversized_host_answers_retry_before_status_or_disabled_classification() {
525 for status in [200, 400, 401, 403, 413, 503] {
526 let mut body = vec![b'x'; MAX_RESPONSE_BYTES + 1];
527 body.extend_from_slice(DISABLED_ANSWER.as_bytes());
528 let response = ExportResponse {
529 status,
530 body,
531 headers: HashMap::from([("Retry-After".into(), "17".into())]),
532 };
533 assert!(matches!(
534 Verdict::of(&response),
535 Verdict::Retry { delay_ms: Some(17_000), .. }
536 ));
537 }
538 for size in [MAX_RESPONSE_BYTES - 1, MAX_RESPONSE_BYTES] {
539 let response =
540 ExportResponse { status: 403, body: vec![b'x'; size], ..Default::default() };
541 assert!(matches!(Verdict::of(&response), Verdict::Unauthorized(_)));
542 }
543 let response = ExportResponse {
544 status: 403,
545 body: DISABLED_ANSWER.as_bytes().to_vec(),
546 ..Default::default()
547 };
548 assert_eq!(Verdict::of(&response), Verdict::Disabled);
549 }
550
551 fn status_body(message: &str, retry: Option<(i64, i32)>) -> Vec<u8> {
552 let details = retry
553 .map(|(seconds, nanos)| prost_types::Any {
554 type_url: "type.googleapis.com/google.rpc.RetryInfo".into(),
555 value: RetryInfo { retry_delay: Some(prost_types::Duration { seconds, nanos }) }
556 .encode_to_vec(),
557 })
558 .into_iter()
559 .collect();
560 RpcStatus { code: 8, message: message.into(), details }.encode_to_vec()
561 }
562
563 fn response(status: u16, headers: &[(&str, &str)], body: Vec<u8>) -> ExportResponse {
564 ExportResponse {
565 status,
566 headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
567 body,
568 }
569 }
570
571 fn verdict(status: u16, headers: &[(&str, &str)], body: Vec<u8>) -> Verdict {
572 Verdict::of(&response(status, headers, body))
573 }
574
575 #[test]
576 fn success_and_partial_success() {
577 assert_eq!(
578 Verdict::of(&ExportResponse::accepted()),
579 Verdict::Accepted { rejected: 0, reason: String::new() }
580 );
581 assert!(matches!(verdict(204, &[], vec![]), Verdict::Accepted { rejected: 0, .. }));
582 let partial = PartialSuccessResponse {
583 partial_success: Some(PartialSuccess { rejected: 3, error_message: "too old".into() }),
584 }
585 .encode_to_vec();
586 assert_eq!(
587 verdict(200, &[], partial),
588 Verdict::Accepted { rejected: 3, reason: "too old".into() }
589 );
590 assert!(matches!(verdict(200, &[], b"not protobuf".to_vec()), Verdict::Accepted { .. }));
591 }
592
593 #[test]
594 fn client_errors() {
595 assert_eq!(
596 verdict(400, &[], status_body("bad batch", None)),
597 Verdict::Rejected("HTTP 400: bad batch".into())
598 );
599 assert_eq!(verdict(413, &[], vec![]), Verdict::TooLarge);
600 assert!(matches!(verdict(307, &[], vec![]), Verdict::Rejected(_)), "never follow");
601 assert!(matches!(verdict(422, &[], vec![]), Verdict::Rejected(_)));
602 assert_eq!(verdict(404, &[], vec![]), Verdict::NotFound);
603 for body in ["invalid token", "operation requires observability write grant"] {
604 assert_eq!(
605 verdict(401, &[], body.as_bytes().to_vec()),
606 Verdict::Unauthorized(format!("HTTP 401: {body}"))
607 );
608 }
609 assert!(matches!(verdict(403, &[], vec![]), Verdict::Unauthorized(_)));
610 }
611
612 #[test]
613 fn disabled_by_owner() {
614 let text = b"project data recording is disabled by owner".to_vec();
615 assert_eq!(verdict(401, &[], text), Verdict::Disabled);
616 let rpc = status_body("Project data recording is disabled by owner", None);
617 assert_eq!(verdict(403, &[], rpc), Verdict::Disabled);
618 let long = format!("{} data recording is disabled by owner", "x".repeat(250));
620 assert_eq!(verdict(401, &[], long.into_bytes()), Verdict::Disabled);
621 let Verdict::Unauthorized(reason) = verdict(401, &[], "y".repeat(500).into_bytes()) else {
622 panic!("a credential answer")
623 };
624 assert_eq!(reason.len(), "HTTP 401: ".len() + MAX_REASON_CHARS);
625 let mixed_case = b"Project Data Recording Is DISABLED By Owner".to_vec();
626 assert_eq!(verdict(403, &[], mixed_case), Verdict::Disabled);
627 assert!(contains_ignore_ascii_case(b"ab", ""));
628 assert!(!contains_ignore_ascii_case(b"a", "ab"));
629 let mut page = vec![0xFF; 5_000];
631 page.extend_from_slice(b"data recording is disabled by owner");
632 assert_eq!(verdict(401, &[], page), Verdict::Disabled);
633 let Verdict::Unauthorized(reason) = verdict(401, &[], vec![0xFF; 5_000]) else {
634 panic!("a credential answer")
635 };
636 assert_eq!(reason.chars().count(), "HTTP 401: ".len() + MAX_REASON_CHARS);
637 for body in ["account disabled", "token disabled for this room"] {
640 assert!(matches!(
641 verdict(401, &[], body.as_bytes().to_vec()),
642 Verdict::Unauthorized(_)
643 ));
644 }
645 }
646
647 #[test]
650 fn delay_hints_never_make_a_final_status_retryable() {
651 type Hint<'a> = (&'a [(&'a str, &'a str)], Vec<u8>);
652 let hints: [Hint; 3] = [
653 (&[("Retry-After", "5")], vec![]),
654 (&[], status_body("later", Some((5, 0)))),
655 (&[("Retry-After", "5")], status_body("later", Some((5, 0)))),
656 ];
657 for (headers, body) in &hints {
658 for status in [300, 302, 307, 400, 405, 409, 410, 422, 501, 505, 599] {
659 assert!(
660 matches!(verdict(status, headers, body.clone()), Verdict::Rejected(_)),
661 "{status} with {headers:?}"
662 );
663 }
664 assert_eq!(verdict(413, headers, body.clone()), Verdict::TooLarge);
665 assert_eq!(verdict(404, headers, body.clone()), Verdict::NotFound);
666 assert!(matches!(verdict(429, headers, body.clone()), Verdict::Throttled { .. }));
667 assert!(matches!(verdict(503, headers, body.clone()), Verdict::Throttled { .. }));
668 for status in [502, 504] {
669 assert!(matches!(
670 verdict(status, headers, body.clone()),
671 Verdict::Retry { delay_ms: Some(5_000), .. }
672 ));
673 }
674 }
675 assert!(matches!(verdict(500, &[("Retry-After", "5")], vec![]), Verdict::Rejected(_)));
677 assert!(matches!(
678 verdict(500, &[], status_body("later", Some((5, 0)))),
679 Verdict::Retry { delay_ms: Some(5_000), .. }
680 ));
681 }
682
683 #[test]
685 fn untrusted_time_values_are_bounded() {
686 assert_eq!(retry_after_ms("99999999999999999999999"), Some(u64::MAX));
687 assert_eq!(retry_after_ms("18446744073709551615"), Some(u64::MAX));
688 assert_eq!(retry_after_ms("-5"), None);
689 assert_eq!(retry_after_ms(""), None);
690 for date in [
691 "Sun, 06 Nov 99999999999999 08:49:37 GMT",
692 "Sun, 06 Nov -9223372036854775808 08:49:37 GMT",
693 "Sun, 06 Nov 1969 08:49:37 GMT",
694 "Sun, 06 Nov 10000 08:49:37 GMT",
695 "Sun, 32 Nov 2026 08:49:37 GMT",
696 "Sun, 06 Nov 2026 08:49:37:99 GMT",
697 "Sun, 06 Nov 2026 99:49:37 GMT",
698 ] {
699 assert_eq!(imf_fixdate_secs(date), None, "{date}");
700 }
701 assert_eq!(imf_fixdate_secs("Fri, 31 Dec 9999 23:59:59 GMT"), Some(253_402_300_799));
702 let huge = verdict(429, &[("Retry-After", "99999999999999999999")], vec![]);
703 assert!(matches!(huge, Verdict::Throttled { delay_ms: u64::MAX, .. }), "clamped later");
704 let far = verdict(429, &[], status_body("q", Some((i64::MAX, 999_999_999))));
705 assert!(matches!(far, Verdict::Throttled { delay_ms: u64::MAX, .. }));
706 }
707
708 #[test]
709 fn throttling_takes_the_header_then_the_body_then_the_default() {
710 let both = verdict(429, &[("Retry-After", "7")], status_body("quota", Some((30, 0))));
711 assert!(matches!(both, Verdict::Throttled { delay_ms: 7_000, .. }));
712 let body = verdict(429, &[], status_body("quota", Some((30, 500_000_000))));
713 assert!(matches!(body, Verdict::Throttled { delay_ms: 30_500, .. }));
714 assert_eq!(
716 verdict(429, &[], status_body("QuotaStatusExceeded", None)),
717 Verdict::Throttled { delay_ms: 60_000, reason: "HTTP 429: QuotaStatusExceeded".into() }
718 );
719 let unavailable = verdict(503, &[("retry-after", " 12 ")], vec![]);
720 assert!(matches!(unavailable, Verdict::Throttled { delay_ms: 12_000, .. }));
721 let past = verdict(429, &[("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")], vec![]);
722 assert!(matches!(past, Verdict::Throttled { delay_ms: 0, .. }), "a past date: now");
723 let garbage = verdict(429, &[("Retry-After", "soon-ish")], vec![]);
724 assert!(matches!(garbage, Verdict::Throttled { delay_ms: 60_000, .. }), "ignored");
725 let negative = verdict(429, &[], status_body("q", Some((-5, -1))));
726 assert!(matches!(negative, Verdict::Throttled { delay_ms: 0, .. }), "clamped");
727 }
728
729 #[test]
730 fn impossible_calendar_dates_are_ignored() {
731 assert_eq!(imf_fixdate_secs("Tue, 30 Feb 2027 12:00:00 GMT"), None);
732 assert_eq!(imf_fixdate_secs("Mon, 29 Feb 2027 12:00:00 GMT"), None, "not a leap year");
733 assert_eq!(imf_fixdate_secs("Thu, 31 Apr 2027 12:00:00 GMT"), None);
734 assert_eq!(imf_fixdate_secs("Tue, 29 Feb 2100 12:00:00 GMT"), None, "a century");
735 assert!(imf_fixdate_secs("Tue, 29 Feb 2000 12:00:00 GMT").is_some(), "every 400 years");
736 let ignored = verdict(429, &[("Retry-After", "Tue, 30 Feb 2027 12:00:00 GMT")], vec![]);
737 assert!(matches!(ignored, Verdict::Throttled { delay_ms: 60_000, .. }), "the default");
738 }
739
740 #[test]
741 fn retry_after_http_dates() {
742 assert_eq!(imf_fixdate_secs("Sun, 06 Nov 1994 08:49:37 GMT"), Some(784_111_777));
743 assert_eq!(imf_fixdate_secs("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0));
744 assert_eq!(imf_fixdate_secs("Tue, 29 Feb 2028 12:00:00 GMT"), Some(1_835_438_400));
745 assert_eq!(imf_fixdate_secs("Sunday, 06-Nov-94 08:49:37 GMT"), None, "obsolete form");
746 assert_eq!(imf_fixdate_secs("Sun, 06 Nov 1994 08:49:37 PST"), None);
747 let now = std::time::SystemTime::now()
749 .duration_since(std::time::UNIX_EPOCH)
750 .expect("clock")
751 .as_secs();
752 let expected = (253_402_300_799 - now) * 1000;
753 let ms = retry_after_ms("Fri, 31 Dec 9999 23:59:59 GMT").expect("date");
754 assert!(expected.abs_diff(ms) <= 2_000, "{ms} vs {expected}");
755 }
756
757 #[test]
758 fn server_errors() {
759 for status in [502, 503, 504] {
760 assert!(matches!(verdict(status, &[], vec![]), Verdict::Retry { delay_ms: None, .. }));
761 }
762 assert!(matches!(
763 verdict(502, &[("Retry-After", "3")], vec![]),
764 Verdict::Retry { delay_ms: Some(3_000), .. }
765 ));
766 let internal = verdict(500, &[], status_body("try later", Some((5, 0))));
768 assert!(matches!(internal, Verdict::Retry { delay_ms: Some(5_000), .. }));
769 assert!(matches!(verdict(500, &[], vec![]), Verdict::Rejected(_)));
770 assert!(matches!(verdict(501, &[], vec![]), Verdict::Rejected(_)));
771 }
772
773 #[cfg(feature = "uniffi")]
776 #[test]
777 fn an_undeclared_foreign_exception_is_a_retryable_failure() {
778 let lifted = <Result<ExportResponse, ExportError> as uniffi::LiftReturn<
779 crate::UniFfiTag,
780 >>::handle_callback_unexpected_error(
781 uniffi::UnexpectedUniFFICallbackError::new("java.lang.IllegalStateException: boom"),
782 );
783 assert!(matches!(
784 lifted,
785 Err(ExportError::Retryable { retry_after_ms: None, ref reason }) if reason.contains("boom")
786 ));
787 }
788}