use std::collections::HashMap;
use prost::Message;
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ExportRequest {
pub url: String,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ExportResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl ExportResponse {
pub fn accepted() -> Self {
Self { status: 200, ..Self::default() }
}
}
const DISABLED_ANSWER: &str = "data recording is disabled by owner";
const MAX_REASON_CHARS: usize = 200;
const MAX_RESPONSE_BYTES: usize = 65_536;
fn response_overflow<'a>(headers: impl Iterator<Item = (&'a str, &'a str)>) -> ExportError {
let retry_after_ms = headers
.filter(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
.find_map(|(_, value)| retry_after_ms(value));
ExportError::Retryable {
reason: "collector response exceeds 65536 decoded bytes".into(),
retry_after_ms,
}
}
const THROTTLE_DEFAULT_MS: u64 = 60_000;
#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ExportError {
#[error("retryable export error: {reason}")]
Retryable {
reason: String,
retry_after_ms: Option<u64>,
},
#[error("export rejected: {reason}")]
Rejected {
reason: String,
},
}
#[cfg(feature = "uniffi")]
impl From<uniffi::UnexpectedUniFFICallbackError> for ExportError {
fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self {
Self::Retryable {
reason: format!("transport threw: {}", error.reason),
retry_after_ms: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Verdict {
Accepted { rejected: u64, reason: String },
Rejected(String),
TooLarge,
Unauthorized(String),
NotFound,
Disabled,
Throttled { delay_ms: u64, reason: String },
Retry { delay_ms: Option<u64>, reason: String },
}
impl Verdict {
pub(crate) fn of(response: &ExportResponse) -> Self {
if response.body.len() > MAX_RESPONSE_BYTES {
return match response_overflow(
response.headers.iter().map(|(n, v)| (n.as_str(), v.as_str())),
) {
ExportError::Retryable { reason, retry_after_ms } => {
Self::Retry { delay_ms: retry_after_ms, reason }
}
ExportError::Rejected { .. } => unreachable!("overflow is retryable"),
};
}
let status = response.status;
if (200..300).contains(&status) {
return match PartialSuccessResponse::decode(response.body.as_slice()) {
Ok(PartialSuccessResponse { partial_success: Some(p) }) if p.rejected > 0 => {
Self::Accepted { rejected: p.rejected as u64, reason: p.error_message }
}
_ => Self::Accepted { rejected: 0, reason: String::new() },
};
}
let rpc = RpcStatus::decode(response.body.as_slice()).ok();
let message = rpc.as_ref().map(|s| s.message.trim()).filter(|m| !m.is_empty());
let answer = message.map_or(response.body.as_slice(), str::as_bytes);
if matches!(status, 401 | 403) && contains_ignore_ascii_case(answer, DISABLED_ANSWER) {
return Self::Disabled;
}
let head = &answer[..answer.len().min(MAX_REASON_CHARS * 4)];
let text: String =
String::from_utf8_lossy(head).trim().chars().take(MAX_REASON_CHARS).collect();
let reason = if text.is_empty() {
format!("HTTP {status}")
} else {
format!("HTTP {status}: {text}")
};
if matches!(status, 401 | 403) {
return Self::Unauthorized(reason);
}
let header = response
.headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
.and_then(|(_, value)| retry_after_ms(value));
let retry_info = rpc.as_ref().and_then(retry_info_ms);
let delay = header.or(retry_info);
match status {
404 => Self::NotFound,
413 => Self::TooLarge,
429 => Self::Throttled { delay_ms: delay.unwrap_or(THROTTLE_DEFAULT_MS), reason },
503 if delay.is_some() => {
Self::Throttled { delay_ms: delay.unwrap_or_default(), reason }
}
502..=504 => Self::Retry { delay_ms: delay, reason },
500 if retry_info.is_some() => Self::Retry { delay_ms: retry_info, reason },
_ => Self::Rejected(reason),
}
}
}
fn contains_ignore_ascii_case(haystack: &[u8], needle: &str) -> bool {
needle.is_empty()
|| haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
}
#[derive(Clone, PartialEq, prost::Message)]
struct PartialSuccessResponse {
#[prost(message, optional, tag = "1")]
partial_success: Option<PartialSuccess>,
}
#[derive(Clone, PartialEq, prost::Message)]
struct PartialSuccess {
#[prost(int64, tag = "1")]
rejected: i64,
#[prost(string, tag = "2")]
error_message: String,
}
#[derive(Clone, PartialEq, prost::Message)]
struct RpcStatus {
#[prost(int32, tag = "1")]
code: i32,
#[prost(string, tag = "2")]
message: String,
#[prost(message, repeated, tag = "3")]
details: Vec<prost_types::Any>,
}
#[derive(Clone, PartialEq, prost::Message)]
struct RetryInfo {
#[prost(message, optional, tag = "1")]
retry_delay: Option<prost_types::Duration>,
}
fn retry_after_ms(value: &str) -> Option<u64> {
let value = value.trim();
if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) {
return Some(value.parse::<u64>().unwrap_or(u64::MAX).saturating_mul(1000));
}
let at = imf_fixdate_secs(value)?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
Some(at.saturating_sub(now).saturating_mul(1000))
}
fn imf_fixdate_secs(value: &str) -> Option<u64> {
let mut parts = value.split_ascii_whitespace();
let (_weekday, day, month, year, time, zone) =
(parts.next()?, parts.next()?, parts.next()?, parts.next()?, parts.next()?, parts.next()?);
if zone != "GMT" || parts.next().is_some() {
return None;
}
const MONTHS: [&str; 12] =
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
let month = MONTHS.iter().position(|m| *m == month)? as i64 + 1;
let (day, year): (i64, i64) = (day.parse().ok()?, year.parse().ok()?);
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
let month_days = match month {
2 if leap => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
};
let mut hms = time.split(':').map(|n| n.parse::<i64>().ok());
let (h, m, s) = (hms.next()??, hms.next()??, hms.next()??);
if !(1970..=9999).contains(&year)
|| hms.next().is_some()
|| !(1..=month_days).contains(&day)
|| !(0..24).contains(&h)
|| !(0..60).contains(&m)
|| !(0..61).contains(&s)
{
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
u64::try_from(days * 86_400 + h * 3600 + m * 60 + s).ok()
}
fn retry_info_ms(status: &RpcStatus) -> Option<u64> {
status
.details
.iter()
.filter(|detail| detail.type_url.ends_with("google.rpc.RetryInfo"))
.find_map(|detail| RetryInfo::decode(detail.value.as_slice()).ok()?.retry_delay)
.map(|delay| {
(delay.seconds.max(0) as u64)
.saturating_mul(1000)
.saturating_add(delay.nanos.clamp(0, 999_999_999) as u64 / 1_000_000)
})
}
#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
#[async_trait::async_trait]
pub trait TelemetryTransport: Send + Sync {
async fn send(&self, request: ExportRequest) -> Result<ExportResponse, ExportError>;
}
#[cfg(feature = "net")]
mod net {
use std::sync::Arc;
use livekit_net::{Header, HttpClient, HttpMethod, HttpResponse, TransportError};
use super::{
response_overflow, ExportError, ExportRequest, ExportResponse, TelemetryTransport,
MAX_RESPONSE_BYTES,
};
pub struct NetTransport(Arc<dyn HttpClient>);
impl NetTransport {
pub(crate) fn new(client: Arc<dyn HttpClient>) -> Self {
Self(client)
}
pub fn from_registry() -> Option<Self> {
livekit_net::http_client_with_response_limit(MAX_RESPONSE_BYTES as u64).map(Self::new)
}
}
impl From<HttpResponse> for ExportResponse {
fn from(response: HttpResponse) -> Self {
Self {
status: response.status,
headers: response.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: response.body,
}
}
}
#[async_trait::async_trait]
impl TelemetryTransport for NetTransport {
async fn send(&self, request: ExportRequest) -> Result<ExportResponse, ExportError> {
match url::Url::parse(&request.url) {
Err(error) => {
return Err(ExportError::Rejected {
reason: format!("{}: {error}", request.url),
})
}
Ok(url) if !matches!(url.scheme(), "http" | "https") => {
let reason = format!("{}: not an HTTP URL", request.url);
return Err(ExportError::Rejected { reason });
}
Ok(_) => {}
}
let headers =
request.headers.into_iter().map(|(name, value)| Header { name, value }).collect();
match self.0.request(HttpMethod::Post, request.url, headers, Some(request.body)).await {
Ok(response) if response.body.len() > MAX_RESPONSE_BYTES => Err(response_overflow(
response.headers.iter().map(|h| (h.name.as_str(), h.value.as_str())),
)),
Ok(response) => Ok(response.into()),
Err(TransportError::ResponseTooLarge { headers }) => Err(response_overflow(
headers.iter().map(|h| (h.name.as_str(), h.value.as_str())),
)),
Err(TransportError::Http { status }) => {
Ok(ExportResponse { status, ..Default::default() })
}
Err(other) => {
Err(ExportError::Retryable { reason: other.to_string(), retry_after_ms: None })
}
}
}
}
}
#[cfg(feature = "net")]
pub use net::NetTransport;
#[cfg(all(test, feature = "net"))]
mod net_tests {
use std::sync::Arc;
use livekit_net::{Header, HttpClient, HttpMethod, HttpResponse, TransportError};
use super::*;
struct Unreachable;
#[async_trait::async_trait]
impl HttpClient for Unreachable {
async fn request(
&self,
_: HttpMethod,
url: String,
_: Vec<Header>,
_: Option<Vec<u8>>,
) -> Result<HttpResponse, TransportError> {
panic!("{url} reached the client")
}
}
struct BufferedHost;
#[async_trait::async_trait]
impl HttpClient for BufferedHost {
async fn request(
&self,
_: HttpMethod,
_: String,
_: Vec<Header>,
_: Option<Vec<u8>>,
) -> Result<HttpResponse, TransportError> {
Ok(HttpResponse {
status: 403,
headers: vec![Header { name: "Retry-After".into(), value: "17".into() }],
body: vec![b'x'; MAX_RESPONSE_BYTES + 1],
})
}
}
#[tokio::test]
async fn already_buffered_host_overflow_is_retryable_and_preserves_headers() {
let host = Arc::new(BufferedHost);
let weak = Arc::downgrade(&host);
let transport = NetTransport::new(host);
let result = transport
.send(ExportRequest { url: "http://collector/v1/logs".into(), ..Default::default() })
.await;
drop(transport);
assert!(weak.upgrade().is_none());
assert!(matches!(result, Err(ExportError::Retryable { retry_after_ms: Some(17_000), .. })));
}
#[tokio::test]
async fn a_malformed_url_is_rejected_before_the_client_sees_it() {
let transport = NetTransport::new(Arc::new(Unreachable));
for url in ["not a url", "ftp://collector/v1/logs", "file:///tmp/collector", "mailto:x@y"] {
let request = ExportRequest { url: url.into(), ..Default::default() };
assert!(
matches!(
transport.send(request).await,
Err(ExportError::Rejected { ref reason }) if reason.contains(url)
),
"{url}"
);
}
}
}
#[cfg(test)]
mod tests {
use prost::Message;
use super::*;
#[test]
fn oversized_host_answers_retry_before_status_or_disabled_classification() {
for status in [200, 400, 401, 403, 413, 503] {
let mut body = vec![b'x'; MAX_RESPONSE_BYTES + 1];
body.extend_from_slice(DISABLED_ANSWER.as_bytes());
let response = ExportResponse {
status,
body,
headers: HashMap::from([("Retry-After".into(), "17".into())]),
};
assert!(matches!(
Verdict::of(&response),
Verdict::Retry { delay_ms: Some(17_000), .. }
));
}
for size in [MAX_RESPONSE_BYTES - 1, MAX_RESPONSE_BYTES] {
let response =
ExportResponse { status: 403, body: vec![b'x'; size], ..Default::default() };
assert!(matches!(Verdict::of(&response), Verdict::Unauthorized(_)));
}
let response = ExportResponse {
status: 403,
body: DISABLED_ANSWER.as_bytes().to_vec(),
..Default::default()
};
assert_eq!(Verdict::of(&response), Verdict::Disabled);
}
fn status_body(message: &str, retry: Option<(i64, i32)>) -> Vec<u8> {
let details = retry
.map(|(seconds, nanos)| prost_types::Any {
type_url: "type.googleapis.com/google.rpc.RetryInfo".into(),
value: RetryInfo { retry_delay: Some(prost_types::Duration { seconds, nanos }) }
.encode_to_vec(),
})
.into_iter()
.collect();
RpcStatus { code: 8, message: message.into(), details }.encode_to_vec()
}
fn response(status: u16, headers: &[(&str, &str)], body: Vec<u8>) -> ExportResponse {
ExportResponse {
status,
headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
body,
}
}
fn verdict(status: u16, headers: &[(&str, &str)], body: Vec<u8>) -> Verdict {
Verdict::of(&response(status, headers, body))
}
#[test]
fn success_and_partial_success() {
assert_eq!(
Verdict::of(&ExportResponse::accepted()),
Verdict::Accepted { rejected: 0, reason: String::new() }
);
assert!(matches!(verdict(204, &[], vec![]), Verdict::Accepted { rejected: 0, .. }));
let partial = PartialSuccessResponse {
partial_success: Some(PartialSuccess { rejected: 3, error_message: "too old".into() }),
}
.encode_to_vec();
assert_eq!(
verdict(200, &[], partial),
Verdict::Accepted { rejected: 3, reason: "too old".into() }
);
assert!(matches!(verdict(200, &[], b"not protobuf".to_vec()), Verdict::Accepted { .. }));
}
#[test]
fn client_errors() {
assert_eq!(
verdict(400, &[], status_body("bad batch", None)),
Verdict::Rejected("HTTP 400: bad batch".into())
);
assert_eq!(verdict(413, &[], vec![]), Verdict::TooLarge);
assert!(matches!(verdict(307, &[], vec![]), Verdict::Rejected(_)), "never follow");
assert!(matches!(verdict(422, &[], vec![]), Verdict::Rejected(_)));
assert_eq!(verdict(404, &[], vec![]), Verdict::NotFound);
for body in ["invalid token", "operation requires observability write grant"] {
assert_eq!(
verdict(401, &[], body.as_bytes().to_vec()),
Verdict::Unauthorized(format!("HTTP 401: {body}"))
);
}
assert!(matches!(verdict(403, &[], vec![]), Verdict::Unauthorized(_)));
}
#[test]
fn disabled_by_owner() {
let text = b"project data recording is disabled by owner".to_vec();
assert_eq!(verdict(401, &[], text), Verdict::Disabled);
let rpc = status_body("Project data recording is disabled by owner", None);
assert_eq!(verdict(403, &[], rpc), Verdict::Disabled);
let long = format!("{} data recording is disabled by owner", "x".repeat(250));
assert_eq!(verdict(401, &[], long.into_bytes()), Verdict::Disabled);
let Verdict::Unauthorized(reason) = verdict(401, &[], "y".repeat(500).into_bytes()) else {
panic!("a credential answer")
};
assert_eq!(reason.len(), "HTTP 401: ".len() + MAX_REASON_CHARS);
let mixed_case = b"Project Data Recording Is DISABLED By Owner".to_vec();
assert_eq!(verdict(403, &[], mixed_case), Verdict::Disabled);
assert!(contains_ignore_ascii_case(b"ab", ""));
assert!(!contains_ignore_ascii_case(b"a", "ab"));
let mut page = vec![0xFF; 5_000];
page.extend_from_slice(b"data recording is disabled by owner");
assert_eq!(verdict(401, &[], page), Verdict::Disabled);
let Verdict::Unauthorized(reason) = verdict(401, &[], vec![0xFF; 5_000]) else {
panic!("a credential answer")
};
assert_eq!(reason.chars().count(), "HTTP 401: ".len() + MAX_REASON_CHARS);
for body in ["account disabled", "token disabled for this room"] {
assert!(matches!(
verdict(401, &[], body.as_bytes().to_vec()),
Verdict::Unauthorized(_)
));
}
}
#[test]
fn delay_hints_never_make_a_final_status_retryable() {
type Hint<'a> = (&'a [(&'a str, &'a str)], Vec<u8>);
let hints: [Hint; 3] = [
(&[("Retry-After", "5")], vec![]),
(&[], status_body("later", Some((5, 0)))),
(&[("Retry-After", "5")], status_body("later", Some((5, 0)))),
];
for (headers, body) in &hints {
for status in [300, 302, 307, 400, 405, 409, 410, 422, 501, 505, 599] {
assert!(
matches!(verdict(status, headers, body.clone()), Verdict::Rejected(_)),
"{status} with {headers:?}"
);
}
assert_eq!(verdict(413, headers, body.clone()), Verdict::TooLarge);
assert_eq!(verdict(404, headers, body.clone()), Verdict::NotFound);
assert!(matches!(verdict(429, headers, body.clone()), Verdict::Throttled { .. }));
assert!(matches!(verdict(503, headers, body.clone()), Verdict::Throttled { .. }));
for status in [502, 504] {
assert!(matches!(
verdict(status, headers, body.clone()),
Verdict::Retry { delay_ms: Some(5_000), .. }
));
}
}
assert!(matches!(verdict(500, &[("Retry-After", "5")], vec![]), Verdict::Rejected(_)));
assert!(matches!(
verdict(500, &[], status_body("later", Some((5, 0)))),
Verdict::Retry { delay_ms: Some(5_000), .. }
));
}
#[test]
fn untrusted_time_values_are_bounded() {
assert_eq!(retry_after_ms("99999999999999999999999"), Some(u64::MAX));
assert_eq!(retry_after_ms("18446744073709551615"), Some(u64::MAX));
assert_eq!(retry_after_ms("-5"), None);
assert_eq!(retry_after_ms(""), None);
for date in [
"Sun, 06 Nov 99999999999999 08:49:37 GMT",
"Sun, 06 Nov -9223372036854775808 08:49:37 GMT",
"Sun, 06 Nov 1969 08:49:37 GMT",
"Sun, 06 Nov 10000 08:49:37 GMT",
"Sun, 32 Nov 2026 08:49:37 GMT",
"Sun, 06 Nov 2026 08:49:37:99 GMT",
"Sun, 06 Nov 2026 99:49:37 GMT",
] {
assert_eq!(imf_fixdate_secs(date), None, "{date}");
}
assert_eq!(imf_fixdate_secs("Fri, 31 Dec 9999 23:59:59 GMT"), Some(253_402_300_799));
let huge = verdict(429, &[("Retry-After", "99999999999999999999")], vec![]);
assert!(matches!(huge, Verdict::Throttled { delay_ms: u64::MAX, .. }), "clamped later");
let far = verdict(429, &[], status_body("q", Some((i64::MAX, 999_999_999))));
assert!(matches!(far, Verdict::Throttled { delay_ms: u64::MAX, .. }));
}
#[test]
fn throttling_takes_the_header_then_the_body_then_the_default() {
let both = verdict(429, &[("Retry-After", "7")], status_body("quota", Some((30, 0))));
assert!(matches!(both, Verdict::Throttled { delay_ms: 7_000, .. }));
let body = verdict(429, &[], status_body("quota", Some((30, 500_000_000))));
assert!(matches!(body, Verdict::Throttled { delay_ms: 30_500, .. }));
assert_eq!(
verdict(429, &[], status_body("QuotaStatusExceeded", None)),
Verdict::Throttled { delay_ms: 60_000, reason: "HTTP 429: QuotaStatusExceeded".into() }
);
let unavailable = verdict(503, &[("retry-after", " 12 ")], vec![]);
assert!(matches!(unavailable, Verdict::Throttled { delay_ms: 12_000, .. }));
let past = verdict(429, &[("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")], vec![]);
assert!(matches!(past, Verdict::Throttled { delay_ms: 0, .. }), "a past date: now");
let garbage = verdict(429, &[("Retry-After", "soon-ish")], vec![]);
assert!(matches!(garbage, Verdict::Throttled { delay_ms: 60_000, .. }), "ignored");
let negative = verdict(429, &[], status_body("q", Some((-5, -1))));
assert!(matches!(negative, Verdict::Throttled { delay_ms: 0, .. }), "clamped");
}
#[test]
fn impossible_calendar_dates_are_ignored() {
assert_eq!(imf_fixdate_secs("Tue, 30 Feb 2027 12:00:00 GMT"), None);
assert_eq!(imf_fixdate_secs("Mon, 29 Feb 2027 12:00:00 GMT"), None, "not a leap year");
assert_eq!(imf_fixdate_secs("Thu, 31 Apr 2027 12:00:00 GMT"), None);
assert_eq!(imf_fixdate_secs("Tue, 29 Feb 2100 12:00:00 GMT"), None, "a century");
assert!(imf_fixdate_secs("Tue, 29 Feb 2000 12:00:00 GMT").is_some(), "every 400 years");
let ignored = verdict(429, &[("Retry-After", "Tue, 30 Feb 2027 12:00:00 GMT")], vec![]);
assert!(matches!(ignored, Verdict::Throttled { delay_ms: 60_000, .. }), "the default");
}
#[test]
fn retry_after_http_dates() {
assert_eq!(imf_fixdate_secs("Sun, 06 Nov 1994 08:49:37 GMT"), Some(784_111_777));
assert_eq!(imf_fixdate_secs("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0));
assert_eq!(imf_fixdate_secs("Tue, 29 Feb 2028 12:00:00 GMT"), Some(1_835_438_400));
assert_eq!(imf_fixdate_secs("Sunday, 06-Nov-94 08:49:37 GMT"), None, "obsolete form");
assert_eq!(imf_fixdate_secs("Sun, 06 Nov 1994 08:49:37 PST"), None);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs();
let expected = (253_402_300_799 - now) * 1000;
let ms = retry_after_ms("Fri, 31 Dec 9999 23:59:59 GMT").expect("date");
assert!(expected.abs_diff(ms) <= 2_000, "{ms} vs {expected}");
}
#[test]
fn server_errors() {
for status in [502, 503, 504] {
assert!(matches!(verdict(status, &[], vec![]), Verdict::Retry { delay_ms: None, .. }));
}
assert!(matches!(
verdict(502, &[("Retry-After", "3")], vec![]),
Verdict::Retry { delay_ms: Some(3_000), .. }
));
let internal = verdict(500, &[], status_body("try later", Some((5, 0))));
assert!(matches!(internal, Verdict::Retry { delay_ms: Some(5_000), .. }));
assert!(matches!(verdict(500, &[], vec![]), Verdict::Rejected(_)));
assert!(matches!(verdict(501, &[], vec![]), Verdict::Rejected(_)));
}
#[cfg(feature = "uniffi")]
#[test]
fn an_undeclared_foreign_exception_is_a_retryable_failure() {
let lifted = <Result<ExportResponse, ExportError> as uniffi::LiftReturn<
crate::UniFfiTag,
>>::handle_callback_unexpected_error(
uniffi::UnexpectedUniFFICallbackError::new("java.lang.IllegalStateException: boom"),
);
assert!(matches!(
lifted,
Err(ExportError::Retryable { retry_after_ms: None, ref reason }) if reason.contains("boom")
));
}
}