Skip to main content

livekit_telemetry/
transport.rs

1// Copyright 2026 LiveKit, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16
17use prost::Message;
18
19/// One fully composed OTLP/HTTP export request.
20///
21/// The core fills in the URL, the headers (content type, auth, …) and the protobuf body;
22/// a transport only moves the bytes. The body is an `ExportLogsServiceRequest` or an
23/// `ExportTraceServiceRequest`, as `url` says.
24#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
25#[derive(Debug, Clone, PartialEq, Default)]
26pub struct ExportRequest {
27    /// Where to POST: the OTLP logs or traces endpoint of the batch's project.
28    pub url: String,
29    /// Headers to send exactly as given: content type, content encoding, `Authorization`.
30    pub headers: HashMap<String, String>,
31    /// The encoded request body, sent as is.
32    pub body: Vec<u8>,
33}
34
35/// What the collector answered. A transport moves bytes both ways and never interprets them:
36/// status classification, `Retry-After`, the `google.rpc.Status` body and the Cloud "disabled"
37/// contract are read once, here, for every platform (`Verdict::of`).
38#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
39#[derive(Debug, Clone, PartialEq, Default)]
40pub struct ExportResponse {
41    /// The HTTP status code.
42    pub status: u16,
43    /// The response headers; the core reads `Retry-After` from them.
44    pub headers: HashMap<String, String>,
45    /// The response body: an OTLP partial success, a `google.rpc.Status` or plain text.
46    pub body: Vec<u8>,
47}
48
49impl ExportResponse {
50    /// `2xx`, nothing else: what an accepting collector answers.
51    pub fn accepted() -> Self {
52        Self { status: 200, ..Self::default() }
53    }
54}
55
56/// LiveKit Cloud's answer when the project owner switched data recording off (401 or 403, plain
57/// text, no machine-readable code yet). Matched as a phrase, not a word.
58const DISABLED_ANSWER: &str = "data recording is disabled by owner";
59
60/// The most characters of an error body that a verdict's reason keeps: enough to read the cause in
61/// a log line, short enough that a large HTML error page does not fill it.
62const MAX_REASON_CHARS: usize = 200;
63
64/// Inclusive decoded collector-response limit; never a request or shared-client limit.
65const 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
77/// How long a 429 that names no delay holds uploads: LiveKit Cloud's quota answer names none.
78const THROTTLE_DEFAULT_MS: u64 = 60_000;
79
80/// Why a transport could not get an answer. A 4xx/5xx is an answer, not an error: return it as an
81/// [`ExportResponse`] and the core classifies it.
82#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
83#[derive(Debug, Clone, PartialEq, thiserror::Error)]
84pub enum ExportError {
85    /// No response: connection refused or reset, DNS or TLS failure, offline, timed out. The
86    /// batch stays cached and is retried with backoff.
87    #[error("retryable export error: {reason}")]
88    Retryable {
89        /// What went wrong, for the log.
90        reason: String,
91        /// How long to wait before the next try, when known. `None` uses the backoff.
92        retry_after_ms: Option<u64>,
93    },
94    /// The request can never succeed (an invalid URL): the batch is dropped.
95    #[error("export rejected: {reason}")]
96    Rejected {
97        /// What went wrong, for the log.
98        reason: String,
99    },
100}
101
102/// A foreign transport threw something its binding did not declare (a Swift `Error`, a Kotlin
103/// `RuntimeException`): no answer, retried with backoff — never a panic in the exporter.
104#[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/// What a collector's answer means for the batch: OTLP/HTTP failure semantics, plus the answers
115/// LiveKit Cloud gives on top of them.
116#[derive(Debug, Clone, PartialEq)]
117pub(crate) enum Verdict {
118    /// 2xx. `rejected` counts the records an OTLP partial success refused (never retried).
119    Accepted { rejected: u64, reason: String },
120    /// 400, 3xx (a redirect the transport did not follow), and every other 4xx/5xx the OTLP spec
121    /// does not call retryable: drop the batch.
122    Rejected(String),
123    /// 413: split the batch and retry the halves, down to single records.
124    TooLarge,
125    /// 401/403 that is not the "disabled" answer: the credential is the problem, not the data —
126    /// hold the project's batches until the platform hands over a new token.
127    Unauthorized(String),
128    /// 404: the host has no client ingest; the project goes silent.
129    NotFound,
130    /// 401/403 "data recording is disabled by owner": the project goes silent and its cache is
131    /// purged.
132    Disabled,
133    /// 429, or 503 naming a delay: pause every upload for `delay_ms`, keep collecting.
134    Throttled { delay_ms: u64, reason: String },
135    /// 502/503/504, or 500 carrying `RetryInfo`: the server failed transiently. Retry the batch
136    /// after `delay_ms` or a backoff; a batch that keeps failing is dropped eventually.
137    Retry { delay_ms: Option<u64>, reason: String },
138}
139
140impl Verdict {
141    /// Classify a collector response.
142    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        // OTLP/HTTP errors are a protobuf `google.rpc.Status`; Cloud's auth answers are text.
163        // The whole answer is searched for the phrase, in place; only the logged reason is
164        // decoded, from a head long enough to hold it (a large error page is never copied).
165        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        // `reason`, not `message`: a UniFFI field named `message` collides with
175        // `Throwable.message` in Kotlin.
176        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        // The status decides whether a batch is retried; a delay hint only says when. A hint on a
192        // final status (400, 422, 501, a redirect) changes nothing.
193        match status {
194            404 => Self::NotFound,
195            413 => Self::TooLarge,
196            // A rate limit is an instruction to stop, so 429 always yields a wait: LiveKit Cloud
197            // answers a quota check with a bare `ResourceExhausted` — no `Retry-After`, no
198            // `RetryInfo` — and without one the exporter would spend its retries hammering the
199            // endpoint that just asked for quiet.
200            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            // LiveKit Cloud's one retryable 500 carries `RetryInfo` (custom: OTLP retries only
206            // 429/502/503/504).
207            500 if retry_info.is_some() => Self::Retry { delay_ms: retry_info, reason },
208            _ => Self::Rejected(reason),
209        }
210    }
211}
212
213/// Whether `haystack` contains `needle` ignoring ASCII case, without copying either.
214fn 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/// `ExportLogsServiceResponse` and `ExportTraceServiceResponse` share one shape: field 1 is the
220/// partial success, whose field 1 counts the rejected records and field 2 says why.
221#[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/// `google.rpc.Status`, the OTLP/HTTP error body. Two messages, three fields: not worth a
236/// generated crate.
237#[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/// `google.rpc.RetryInfo`.
248#[derive(Clone, PartialEq, prost::Message)]
249struct RetryInfo {
250    #[prost(message, optional, tag = "1")]
251    retry_delay: Option<prost_types::Duration>,
252}
253
254/// RFC 9110 §10.2.3 `Retry-After`: delay-seconds or an IMF-fixdate. Untrusted input: garbage is
255/// ignored, a date in the past means now; the caller clamps the far end.
256fn retry_after_ms(value: &str) -> Option<u64> {
257    let value = value.trim();
258    // delay-seconds: digits only; more than a u64 holds saturates (the exporter clamps it).
259    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
267/// `Sun, 06 Nov 1994 08:49:37 GMT` → unix seconds. The obsolete RFC 850 / asctime forms are not
268/// accepted (RFC 9110 lets a recipient ignore them).
269fn 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    // IMF-fixdate has a four-digit year; bounding it keeps the arithmetic below in range.
290    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    // Days from the civil date (proleptic Gregorian), after Howard Hinnant's algorithm.
300    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/// Moves an encoded batch off the device and hands back whatever came back.
323///
324/// Implemented in Rust (`NetTransport`, feature `net`) or by the host platform through UniFFI.
325/// The contract:
326///
327/// - POST `url` with `headers` and `body` as given, and return the response whatever its status;
328///   fail only when there was no response (connection, DNS, TLS, timeout) —
329///   [`ExportError::Retryable`] — or the request cannot be made at all
330///   ([`ExportError::Rejected`], e.g. an invalid URL).
331/// - Bound the decoded response body to 65,536 bytes while reading. On overflow return
332///   [`ExportError::Retryable`], preserving a valid `Retry-After` as `retry_after_ms`.
333///   Never return a truncated body for classification. The core defensively rejects oversized
334///   already-buffered host/pulled answers; that check cannot bound host read-time allocation.
335/// - Never retry: the core owns the retry policy.
336/// - Never forward the `Authorization` header across origins or out of TLS: do not follow a
337///   redirect to another host or port with it, nor one from `https` to a plaintext scheme
338///   (returning the 3xx is fine — the core drops the batch). A same-origin upgrade from `http`
339///   to `https` may carry it: it lands on TLS. `NetTransport` over the native `livekit-net`
340///   client follows at most 10 redirects, never one from `https` to a plaintext scheme, and
341///   drops `Authorization` when a redirect changes the host or port.
342/// - Keep the request off the call's critical path: lowest priority where the platform allows it.
343#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
344#[async_trait::async_trait]
345pub trait TelemetryTransport: Send + Sync {
346    /// Send `request` once and return the collector's answer, whatever its status.
347    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    /// Default transport: HTTP POST through a `livekit-net` client — the native backend, or
362    /// whatever the host registered with `livekit_net::set_http_client`.
363    pub struct NetTransport(Arc<dyn HttpClient>);
364
365    impl NetTransport {
366        /// Post through `client`.
367        pub(crate) fn new(client: Arc<dyn HttpClient>) -> Self {
368            Self(client)
369        }
370
371        /// Resolve the process-wide `livekit-net` client; `None` when none is available.
372        ///
373        /// A registered client gets a decoded-size check after its callback buffers the body.
374        /// That check does not bound host read-time allocation. Without a registered client, on
375        /// a `livekit-net/native` build, the first lookup builds the native client — a `reqwest`
376        /// client, whose TLS backend may load the system root store synchronously — and
377        /// concurrent first lookups wait for it; later lookups return the memoized client.
378        /// Call it where blocking is acceptable (the exporter's own task), not on a Room's
379        /// connect path. That build panics if the TLS backend fails to initialize, as
380        /// `reqwest::Client::new` does.
381        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            // A request that cannot be made is final: the client would report a malformed or
400            // non-HTTP URL as a connection error, and the batch would retry until its cache
401            // expired.
402            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                // Against the `HttpClient` contract, a host client reported a status as an
425                // error and discarded the headers and body with it; the native client never
426                // does. The status still classifies, but a refusal's text and `Retry-After`
427                // are gone: a "disabled by owner" 401 reads as a bad credential.
428                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    /// A client that must never be asked anything.
451    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    /// A URL the client cannot even build a request for is a final failure, not a retry: the
499    /// native client would report it as a connection error. So is a URL it parses but cannot
500    /// make an HTTP request to.
501    #[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        // The phrase counts wherever it sits; only the logged reason is cut short.
619        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        // Not UTF-8, and the phrase past the logged head: still found, the reason still cut.
630        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        // Only the documented phrase: another answer that merely mentions "disabled" is a
638        // credential problem, not a project switch.
639        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    /// The status decides; a delay hint on a final status is ignored. Only 429, 502–504 and Cloud's
648    /// 500-with-RetryInfo are retried.
649    #[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        // 500: only with RetryInfo in the body, never on a Retry-After header alone.
676        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    /// Untrusted time values never panic or wrap; oversized ones saturate.
684    #[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        // What LiveKit Cloud answers over quota: ResourceExhausted, no header, no RetryInfo.
715        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        // A far-future date: the delay is the distance from now, whatever now is.
748        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        // Cloud's retryable 500 carries RetryInfo; a bare 500 is final, per OTLP/HTTP.
767        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    /// An exception the foreign transport did not declare becomes a retryable transport error.
774    /// Without the conversion UniFFI panics on the exporter's thread.
775    #[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}