Skip to main content

proton_sdk/
http.rs

1//! HTTP plumbing: header injection, the Proton response envelope, bearer-token
2//! authentication and transparent 401 refresh.
3//!
4//! Mirrors `HttpApiCallBuilder`, `AuthorizationHandler` and `TokenCredential`
5//! from the C# SDK, collapsed into a single reqwest-based client since Rust has
6//! no `DelegatingHandler` pipeline.
7
8use std::sync::Arc;
9use std::time::Duration;
10
11use bytes::Bytes;
12use rand::RngExt;
13use reqwest::{Method, StatusCode};
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16use tokio::sync::Mutex;
17
18use crate::api::{ApiResponse, HumanVerificationCredential, ResponseCode};
19use crate::config::{API_CONTENT_TYPE, ProtonClientConfiguration, RetryPolicy};
20use crate::error::{ProtonApiError, ProtonError, Result};
21use crate::ids::SessionId;
22use crate::telemetry::{NoopTelemetry, Telemetry, TelemetryExt};
23
24const SESSION_ID_HEADER: &str = "x-pm-uid";
25const APP_VERSION_HEADER: &str = "x-pm-appversion";
26const STORAGE_TOKEN_HEADER: &str = "pm-storage-token";
27const HV_TOKEN_HEADER: &str = "x-pm-human-verification-token";
28const HV_TOKEN_TYPE_HEADER: &str = "x-pm-human-verification-token-type";
29
30/// The mutable authentication tokens for a session, shared between every
31/// request and the refresh path.
32#[derive(Debug, Clone)]
33pub struct Tokens {
34    pub access_token: String,
35    pub refresh_token: String,
36}
37
38/// A reqwest-backed client bound to a single authenticated session.
39///
40/// Cloning is cheap (everything is reference-counted) and shares the same token
41/// state, so a refresh triggered by one request is visible to all others.
42#[derive(Clone)]
43pub struct ApiHttpClient {
44    inner: Arc<Inner>,
45    /// Extra path segment prepended to every request path (after `base_url`,
46    /// before the per-call `path`). Mirrors C# `session.GetHttpClient(baseRoute)`
47    /// — the Drive client targets `…/drive/` while account/auth calls stay at the
48    /// root. Empty by default. Lives on the outer struct (not `Inner`) so clones
49    /// can carry different prefixes while sharing one token/telemetry store.
50    route_prefix: Arc<str>,
51}
52
53/// Callback invoked after a successful token refresh.
54type TokensRefreshedCallback = Arc<dyn Fn(Tokens) + Send + Sync>;
55
56struct Inner {
57    http: reqwest::Client,
58    base_url: String,
59    config: ProtonClientConfiguration,
60    session_id: SessionId,
61    tokens: Mutex<Tokens>,
62    /// Telemetry sink for per-request events. Interior-mutable because the
63    /// client is already shared (cloned into the Drive client) by the time a
64    /// caller attaches a sink via [`ApiHttpClient::set_telemetry`]. Defaults to
65    /// a no-op. `std::sync::Mutex` (not tokio's) — held only for the cheap
66    /// clone/replace, never across an await.
67    telemetry: std::sync::Mutex<Arc<dyn Telemetry>>,
68    on_tokens_refreshed: std::sync::Mutex<Option<TokensRefreshedCallback>>,
69}
70
71impl ApiHttpClient {
72    /// Build a client for an authenticated session.
73    pub fn new(
74        config: ProtonClientConfiguration,
75        session_id: SessionId,
76        tokens: Tokens,
77    ) -> Result<Self> {
78        // `gzip` makes reqwest advertise Accept-Encoding and transparently decode
79        // the response. The Proton API honours it for the JSON envelope, which is
80        // the bulk of a metadata-heavy walk (link details, listings). Block bodies
81        // are already ciphertext and will not compress — the header costs nothing
82        // there, and storage responses are unaffected either way.
83        let http = reqwest::Client::builder()
84            .timeout(config.request_timeout)
85            .gzip(true)
86            .build()?;
87
88        let base_url = ensure_trailing_slash(&config.base_url);
89
90        Ok(Self {
91            inner: Arc::new(Inner {
92                http,
93                base_url,
94                config,
95                session_id,
96                tokens: Mutex::new(tokens),
97                telemetry: std::sync::Mutex::new(NoopTelemetry::shared()),
98                on_tokens_refreshed: std::sync::Mutex::new(None),
99            }),
100            route_prefix: Arc::from(""),
101        })
102    }
103
104    /// Derive a clone that prepends `route` to every request path, sharing this
105    /// client's token store, telemetry sink and connection pool. Mirrors C#
106    /// `session.GetHttpClient(baseRoute)`: the Drive client passes `"drive/"` so
107    /// its routes resolve under `…/drive/` while auth/account calls (and token
108    /// refresh) stay at the root. `route` should end in `/`.
109    pub fn with_base_route(&self, route: impl Into<Arc<str>>) -> Self {
110        Self {
111            inner: Arc::clone(&self.inner),
112            route_prefix: route.into(),
113        }
114    }
115
116    /// Snapshot the current tokens (e.g. to persist for a later `resume`).
117    pub async fn current_tokens(&self) -> Tokens {
118        self.inner.tokens.lock().await.clone()
119    }
120
121    /// Attach a telemetry sink to receive a per-request
122    /// [`TelemetryEvent`](crate::telemetry::TelemetryEvent) (operation
123    /// `http_request` for API calls, `storage_download` / `storage_upload` for
124    /// block storage; attributes carry the HTTP method and status). Replaces any
125    /// previous sink. Takes effect for every clone of this client, since they
126    /// share state.
127    pub fn set_telemetry(&self, telemetry: Arc<dyn Telemetry>) {
128        *self
129            .inner
130            .telemetry
131            .lock()
132            .expect("telemetry mutex poisoned") = telemetry;
133    }
134
135    /// Set a callback to be invoked whenever the session's tokens are refreshed.
136    /// Replaces any previous callback. Takes effect for every clone of this client.
137    pub fn set_on_tokens_refreshed(&self, callback: impl Fn(Tokens) + Send + Sync + 'static) {
138        *self
139            .inner
140            .on_tokens_refreshed
141            .lock()
142            .expect("on_tokens_refreshed mutex poisoned") = Some(Arc::new(callback));
143    }
144
145    /// Snapshot the current telemetry sink.
146    fn telemetry(&self) -> Arc<dyn Telemetry> {
147        self.inner
148            .telemetry
149            .lock()
150            .expect("telemetry mutex poisoned")
151            .clone()
152    }
153
154    /// `GET {url}` against block storage, returning the raw (still-encrypted)
155    /// blob bytes.
156    ///
157    /// Block storage lives on a different host from the API: the URL is
158    /// absolute and authorization is a per-block `pm-storage-token` header
159    /// rather than the session bearer. Mirrors C# `StorageApiClient
160    /// .GetBlobStreamAsync`. A successful response is raw binary; an error
161    /// response is the usual JSON envelope.
162    ///
163    /// Returns [`Bytes`] rather than `Vec<u8>` so the 4 MiB body is not copied
164    /// on its way to the decryptor — the reference-counted buffer reqwest
165    /// already assembled is handed straight through, including into the
166    /// blocking decrypt task.
167    pub async fn get_storage_blob(&self, url: &str, token: &str) -> Result<Bytes> {
168        let mut timer = self.telemetry().start("storage_download");
169        let response = send_retrying(&self.inner.config.retry_policy, || {
170            // Override the client-level (API) timeout: a 4 MiB block on a slow
171            // uplink legitimately outruns the 30s JSON budget.
172            let mut request = self
173                .inner
174                .http
175                .get(url)
176                .timeout(self.inner.config.storage_timeout)
177                .header(STORAGE_TOKEN_HEADER, token);
178            if !self.inner.config.user_agent.is_empty() {
179                request =
180                    request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
181            }
182            request
183        })
184        .await?;
185        let status = response.status();
186        timer.attr("status", status.as_u16());
187        let bytes = response.bytes().await?;
188
189        // Success bodies are raw block bytes (not JSON); only error responses
190        // carry the envelope.
191        if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
192            if !envelope.is_success() {
193                return Err(api_error(status, &bytes));
194            }
195        } else if !status.is_success() {
196            return Err(api_error(status, &bytes));
197        }
198
199        timer.success();
200        Ok(bytes)
201    }
202
203    /// `POST {url}` a block blob to storage as `multipart/form-data`.
204    ///
205    /// Mirrors C# `StorageApiClient.UploadBlobAsync`: a single `Block` part
206    /// (filename `blob`, `application/octet-stream`) on the storage host,
207    /// authorized by the per-block `pm-storage-token` header rather than the
208    /// session bearer. The response is the usual JSON envelope.
209    ///
210    /// Takes [`Bytes`] rather than `Vec<u8>` because the multipart body has to be
211    /// rebuilt per attempt (a stream body can't be cloned) and a block is up to
212    /// 4 MiB: cloning `Bytes` bumps a refcount where cloning the `Vec` copied the
213    /// whole block on *every* attempt, first one included. The part is built with
214    /// an explicit length so the request still carries a `Content-Length`.
215    pub async fn post_storage_blob(&self, url: &str, token: &str, blob: Bytes) -> Result<()> {
216        // Validate the part once up front; the multipart body itself is rebuilt
217        // per attempt inside the retry closure.
218        reqwest::multipart::Part::bytes(Vec::new())
219            .mime_str("application/octet-stream")
220            .map_err(ProtonError::from)?;
221
222        let blob_len = blob.len() as u64;
223        let mut timer = self.telemetry().start("storage_upload");
224        let response = send_retrying(&self.inner.config.retry_policy, || {
225            let body = reqwest::Body::from(blob.clone());
226            let part = reqwest::multipart::Part::stream_with_length(body, blob_len)
227                .file_name("blob")
228                .mime_str("application/octet-stream")
229                .expect("octet-stream is a valid MIME type");
230            let form = reqwest::multipart::Form::new().part("Block", part);
231
232            let mut request = self
233                .inner
234                .http
235                .post(url)
236                .timeout(self.inner.config.storage_timeout)
237                .header(STORAGE_TOKEN_HEADER, token)
238                .multipart(form);
239
240            if !self.inner.config.user_agent.is_empty() {
241                request =
242                    request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
243            }
244            request
245        })
246        .await?;
247        let status = response.status();
248        timer.attr("status", status.as_u16());
249        let bytes = response.bytes().await?;
250
251        if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
252            if !envelope.is_success() {
253                return Err(api_error(status, &bytes));
254            }
255        } else if !status.is_success() {
256            return Err(api_error(status, &bytes));
257        }
258        timer.success();
259        Ok(())
260    }
261
262    pub fn session_id(&self) -> &SessionId {
263        &self.inner.session_id
264    }
265
266    /// `GET {path}` returning a typed success body.
267    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
268        self.send::<(), T>(Method::GET, path, None).await
269    }
270
271    /// `POST {path}` with a JSON body, returning a typed success body.
272    pub async fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
273        self.send::<B, T>(Method::POST, path, Some(body)).await
274    }
275
276    /// `POST {path}` as multipart form data with a JSON `Metadata` part and
277    /// zero or more binary parts. Used by Drive's atomic small-upload endpoints.
278    pub async fn post_multipart<B: Serialize, T: DeserializeOwned>(
279        &self,
280        path: &str,
281        metadata: &B,
282        binary_parts: &[(String, Vec<u8>)],
283    ) -> Result<T> {
284        let metadata = serde_json::to_vec(metadata)?;
285        let mut timer = self.telemetry().start("http_request");
286        timer.attr("method", "POST");
287        let rejected_token = self.inner.tokens.lock().await.access_token.clone();
288        let response = self
289            .send_multipart_with_token(path, &metadata, binary_parts, &rejected_token)
290            .await?;
291        let response = if response.status() == StatusCode::UNAUTHORIZED {
292            let bytes = response.bytes().await?;
293            if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes)
294                && matches!(
295                    envelope.code,
296                    ResponseCode::AccountDeleted | ResponseCode::AccountDisabled
297                )
298            {
299                return Err(api_error(StatusCode::UNAUTHORIZED, &bytes));
300            }
301            let token = self.refresh_access_token(&rejected_token).await?;
302            self.send_multipart_with_token(path, &metadata, binary_parts, &token)
303                .await?
304        } else {
305            response
306        };
307        timer.attr("status", response.status().as_u16());
308        let parsed = parse_response(response).await?;
309        timer.success();
310        Ok(parsed)
311    }
312
313    /// `PUT {path}` with a JSON body, returning a typed success body.
314    pub async fn put<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
315        self.send::<B, T>(Method::PUT, path, Some(body)).await
316    }
317
318    /// `DELETE {path}` returning a typed success body.
319    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
320        self.send::<(), T>(Method::DELETE, path, None).await
321    }
322
323    async fn send<B: Serialize, T: DeserializeOwned>(
324        &self,
325        method: Method,
326        path: &str,
327        body: Option<&B>,
328    ) -> Result<T> {
329        let mut timer = self.telemetry().start("http_request");
330        timer.attr("method", method.as_str());
331
332        let access_token = self.inner.tokens.lock().await.access_token.clone();
333
334        // An early `?` here records the op as a failure (OpTimer defaults to it).
335        let response = self
336            .send_with_token(method.clone(), path, body, &access_token)
337            .await?;
338
339        let response = if response.status() == StatusCode::UNAUTHORIZED {
340            self.handle_unauthorized(method, path, body, response, access_token)
341                .await?
342        } else {
343            response
344        };
345
346        timer.attr("status", response.status().as_u16());
347        let parsed = parse_response(response).await?;
348        timer.success();
349        Ok(parsed)
350    }
351
352    async fn handle_unauthorized<B: Serialize>(
353        &self,
354        method: Method,
355        path: &str,
356        body: Option<&B>,
357        response: reqwest::Response,
358        rejected_access_token: String,
359    ) -> Result<reqwest::Response> {
360        // Don't bother refreshing for terminal account states.
361        let bytes = response.bytes().await?;
362        if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes)
363            && matches!(
364                envelope.code,
365                ResponseCode::AccountDeleted | ResponseCode::AccountDisabled
366            )
367        {
368            return Err(api_error(StatusCode::UNAUTHORIZED, &bytes));
369        }
370
371        let access_token = self.refresh_access_token(&rejected_access_token).await?;
372        self.send_with_token(method, path, body, &access_token)
373            .await
374    }
375
376    async fn send_with_token<B: Serialize>(
377        &self,
378        method: Method,
379        path: &str,
380        body: Option<&B>,
381        access_token: &str,
382    ) -> Result<reqwest::Response> {
383        let url = format!(
384            "{}{}{}",
385            self.inner.base_url,
386            self.route_prefix,
387            path.trim_start_matches('/')
388        );
389        send_retrying(&self.inner.config.retry_policy, || {
390            let mut request = self
391                .inner
392                .http
393                .request(method.clone(), &url)
394                .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
395                .header(APP_VERSION_HEADER, &self.inner.config.app_version)
396                .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
397                .bearer_auth(access_token);
398
399            if !self.inner.config.user_agent.is_empty() {
400                request =
401                    request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
402            }
403
404            if let Some(body) = body {
405                request = request.json(body);
406            }
407
408            request
409        })
410        .await
411    }
412
413    async fn send_multipart_with_token(
414        &self,
415        path: &str,
416        metadata: &[u8],
417        binary_parts: &[(String, Vec<u8>)],
418        access_token: &str,
419    ) -> Result<reqwest::Response> {
420        let url = format!(
421            "{}{}{}",
422            self.inner.base_url,
423            self.route_prefix,
424            path.trim_start_matches('/')
425        );
426        send_retrying(&self.inner.config.retry_policy, || {
427            let metadata_part = reqwest::multipart::Part::bytes(metadata.to_vec())
428                .file_name("Metadata")
429                .mime_str("application/json")
430                .expect("application/json is a valid MIME type");
431            let mut form = reqwest::multipart::Form::new().part("Metadata", metadata_part);
432            for (name, bytes) in binary_parts {
433                let part = reqwest::multipart::Part::bytes(bytes.clone())
434                    .file_name(name.clone())
435                    .mime_str("application/octet-stream")
436                    .expect("octet-stream is a valid MIME type");
437                form = form.part(name.clone(), part);
438            }
439            let mut request = self
440                .inner
441                .http
442                .post(&url)
443                .timeout(self.inner.config.storage_timeout)
444                .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
445                .header(APP_VERSION_HEADER, &self.inner.config.app_version)
446                .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
447                .bearer_auth(access_token)
448                .multipart(form);
449            if !self.inner.config.user_agent.is_empty() {
450                request =
451                    request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
452            }
453            request
454        })
455        .await
456    }
457
458    /// Refresh the session tokens, deduplicating concurrent refreshes: if the
459    /// in-memory access token already differs from the rejected one, another
460    /// task refreshed first and we reuse its result.
461    async fn refresh_access_token(&self, rejected_access_token: &str) -> Result<String> {
462        let mut guard = self.inner.tokens.lock().await;
463
464        if guard.access_token != rejected_access_token {
465            return Ok(guard.access_token.clone());
466        }
467
468        let refreshed = self.request_refresh(&guard.refresh_token).await?;
469        *guard = refreshed.clone();
470
471        // Notify callback
472        if let Some(ref cb) = *self
473            .inner
474            .on_tokens_refreshed
475            .lock()
476            .expect("on_tokens_refreshed mutex poisoned")
477        {
478            cb(refreshed.clone());
479        }
480
481        Ok(refreshed.access_token)
482    }
483
484    async fn request_refresh(&self, refresh_token: &str) -> Result<Tokens> {
485        let url = format!("{}auth/v4/refresh", self.inner.base_url);
486        let body = SessionRefreshRequest {
487            response_type: "token",
488            grant_type: "refresh_token",
489            refresh_token,
490            redirect_uri: &self.inner.config.refresh_redirect_uri,
491        };
492
493        // The refresh call carries the session id but, deliberately, no bearer
494        // token (the access token is the thing being replaced).
495        let response = send_retrying(&self.inner.config.retry_policy, || {
496            self.inner
497                .http
498                .post(&url)
499                .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
500                .header(APP_VERSION_HEADER, &self.inner.config.app_version)
501                .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
502                .json(&body)
503        })
504        .await?;
505
506        let refreshed: SessionRefreshResponse = parse_response(response).await?;
507        Ok(Tokens {
508            access_token: refreshed.access_token,
509            refresh_token: refreshed.refresh_token,
510        })
511    }
512}
513
514#[derive(Serialize)]
515struct SessionRefreshRequest<'a> {
516    #[serde(rename = "ResponseType")]
517    response_type: &'a str,
518    #[serde(rename = "GrantType")]
519    grant_type: &'a str,
520    #[serde(rename = "RefreshToken")]
521    refresh_token: &'a str,
522    #[serde(rename = "RedirectURI")]
523    redirect_uri: &'a str,
524}
525
526#[derive(serde::Deserialize)]
527struct SessionRefreshResponse {
528    #[serde(rename = "AccessToken")]
529    access_token: String,
530    #[serde(rename = "RefreshToken")]
531    refresh_token: String,
532}
533
534/// `GET {path}` without a session: no `x-pm-uid` and no bearer token.
535///
536/// The public-link flow needs this: `drive/urls/{token}/info` opens the SRP
537/// handshake and is by definition callable by a visitor who has no Proton
538/// session at all.
539pub async fn get_unauthenticated<T: DeserializeOwned>(
540    config: &ProtonClientConfiguration,
541    path: &str,
542) -> Result<T> {
543    let http = reqwest::Client::builder()
544        .timeout(config.request_timeout)
545        .gzip(true)
546        .build()?;
547
548    let base_url = ensure_trailing_slash(&config.base_url);
549    let url = format!("{}{}", base_url, path.trim_start_matches('/'));
550
551    let response = send_retrying(&config.retry_policy, || {
552        let mut request = http
553            .get(&url)
554            .header(APP_VERSION_HEADER, &config.app_version)
555            .header(reqwest::header::ACCEPT, API_CONTENT_TYPE);
556        if !config.user_agent.is_empty() {
557            request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
558        }
559        request
560    })
561    .await?;
562
563    parse_response(response).await
564}
565
566/// `POST {path}` without a session: no `x-pm-uid` and no bearer token.
567///
568/// Used by the SRP login flow (`auth/v4/info`, `auth/v4`), which runs before a
569/// session exists. Mirrors the C# SDK's `BeginAsync`, which issues these calls
570/// on a session-less `HttpClient`.
571pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
572    config: &ProtonClientConfiguration,
573    path: &str,
574    body: &B,
575) -> Result<T> {
576    post_unauthenticated_verified(config, path, body, None).await
577}
578
579/// [`post_unauthenticated`], optionally replaying a solved human-verification
580/// challenge.
581///
582/// A login from an unfamiliar IP comes back `9001` with a challenge instead of a
583/// session. Once the user has solved it, the *same* request is sent again with
584/// the resulting token in the header pair below — the challenge is not a
585/// separate handshake, it is a precondition attached to the original call, which
586/// is why this takes the credential rather than exposing a "verify" endpoint.
587pub async fn post_unauthenticated_verified<B: Serialize, T: DeserializeOwned>(
588    config: &ProtonClientConfiguration,
589    path: &str,
590    body: &B,
591    verification: Option<&HumanVerificationCredential>,
592) -> Result<T> {
593    let http = reqwest::Client::builder()
594        .timeout(config.request_timeout)
595        .gzip(true)
596        .build()?;
597
598    let base_url = ensure_trailing_slash(&config.base_url);
599    let url = format!("{}{}", base_url, path.trim_start_matches('/'));
600
601    let response = send_retrying(&config.retry_policy, || {
602        let mut request = http
603            .post(&url)
604            .header(APP_VERSION_HEADER, &config.app_version)
605            .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
606            .json(body);
607        if !config.user_agent.is_empty() {
608            request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
609        }
610        if let Some(hv) = verification {
611            request = request
612                .header(HV_TOKEN_HEADER, &hv.token)
613                .header(HV_TOKEN_TYPE_HEADER, &hv.method);
614        }
615        request
616    })
617    .await?;
618
619    parse_response(response).await
620}
621
622/// Read a response body, enforce the Proton success envelope, and deserialize
623/// the typed success payload.
624async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
625    let status = response.status();
626    let bytes = response.bytes().await?;
627
628    // Every Proton response embeds the envelope; a missing/non-success code or a
629    // non-2xx HTTP status is an API error. `MultipleResponses` (1001) is a batch
630    // multi-status, not a failure: the real per-item codes live in the body, so
631    // the caller (e.g. trash/restore/delete) inspects them itself.
632    if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
633        if !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses {
634            return Err(api_error(status, &bytes));
635        }
636    } else if !status.is_success() {
637        return Err(api_error(status, &bytes));
638    }
639
640    Ok(serde_json::from_slice::<T>(&bytes)?)
641}
642
643fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError {
644    let envelope = serde_json::from_slice::<ApiResponse>(bytes).ok();
645    let code = envelope
646        .as_ref()
647        .map(|e| e.code)
648        .unwrap_or(ResponseCode::Unknown);
649    let details = envelope.as_ref().and_then(|e| e.details.clone());
650    let message = envelope.and_then(|e| e.error_message).unwrap_or_else(|| {
651        status
652            .canonical_reason()
653            .unwrap_or("unknown error")
654            .to_owned()
655    });
656
657    ProtonError::Api(ProtonApiError {
658        code,
659        http_status: status.as_u16(),
660        message,
661        details,
662    })
663}
664
665/// Send a request, transparently retrying retryable failures per `policy`.
666///
667/// `build` is called once per attempt to produce a fresh `RequestBuilder`
668/// (reqwest builders are consumed by `send`, and streaming bodies like
669/// `multipart` can't be cloned), so every retry resends the full request.
670///
671/// Retryable = HTTP 408/429/502/503/504 or a transient transport error
672/// (timeout / connect). A `Retry-After` header (delta-seconds) is honoured;
673/// otherwise the delay is exponential backoff with full jitter. Non-retryable
674/// responses and errors — including ordinary 4xx and the 401 that drives token
675/// refresh — pass straight through to the caller untouched.
676async fn send_retrying<F>(policy: &RetryPolicy, build: F) -> Result<reqwest::Response>
677where
678    F: Fn() -> reqwest::RequestBuilder,
679{
680    let mut attempt: u32 = 0;
681    loop {
682        match build().send().await {
683            Ok(response) => {
684                if attempt < policy.max_retries && is_retryable_status(response.status()) {
685                    let delay = retry_after(&response).unwrap_or_else(|| backoff(policy, attempt));
686                    tokio::time::sleep(delay).await;
687                    attempt += 1;
688                    continue;
689                }
690                return Ok(response);
691            }
692            Err(err) => {
693                if attempt < policy.max_retries && is_retryable_error(&err) {
694                    tokio::time::sleep(backoff(policy, attempt)).await;
695                    attempt += 1;
696                    continue;
697                }
698                return Err(err.into());
699            }
700        }
701    }
702}
703
704/// Status codes Proton (or an intermediary) returns for transient conditions:
705/// request timeout, rate limit, and the gateway/unavailable family.
706fn is_retryable_status(status: StatusCode) -> bool {
707    matches!(status.as_u16(), 408 | 429 | 502 | 503 | 504)
708}
709
710/// A transport error worth retrying: a timeout or a failure to connect. A
711/// mid-body error (`is_body`) is not retried — the request may have been
712/// applied server-side.
713fn is_retryable_error(err: &reqwest::Error) -> bool {
714    err.is_timeout() || err.is_connect()
715}
716
717/// Parse a `Retry-After` header expressed as delta-seconds. The HTTP-date form
718/// is not emitted by the Proton API, so it is ignored (falls back to backoff).
719fn retry_after(response: &reqwest::Response) -> Option<Duration> {
720    let value = response
721        .headers()
722        .get(reqwest::header::RETRY_AFTER)?
723        .to_str()
724        .ok()?;
725    parse_retry_after_secs(value)
726}
727
728/// Parse a `Retry-After` delta-seconds value into a delay. Non-numeric values
729/// (the HTTP-date form, which Proton does not emit) yield `None`.
730fn parse_retry_after_secs(value: &str) -> Option<Duration> {
731    value.trim().parse().ok().map(Duration::from_secs)
732}
733
734/// Exponential backoff with full jitter: a uniformly random delay in
735/// `[0, base_delay * 2^attempt]`, capped at `max_delay`.
736fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
737    let ceiling = policy
738        .base_delay
739        .saturating_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
740        .min(policy.max_delay);
741    let ceiling_ms = ceiling.as_millis() as u64;
742    if ceiling_ms == 0 {
743        return Duration::ZERO;
744    }
745    Duration::from_millis(rand::rng().random_range(0..=ceiling_ms))
746}
747
748fn ensure_trailing_slash(url: &str) -> String {
749    if url.ends_with('/') {
750        url.to_owned()
751    } else {
752        format!("{url}/")
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn retryable_statuses() {
762        for code in [408u16, 429, 502, 503, 504] {
763            assert!(is_retryable_status(StatusCode::from_u16(code).unwrap()));
764        }
765        for code in [200u16, 400, 401, 403, 404, 500] {
766            assert!(!is_retryable_status(StatusCode::from_u16(code).unwrap()));
767        }
768    }
769
770    #[test]
771    fn retry_after_parses_seconds_only() {
772        assert_eq!(parse_retry_after_secs("5"), Some(Duration::from_secs(5)));
773        assert_eq!(
774            parse_retry_after_secs("  12 "),
775            Some(Duration::from_secs(12))
776        );
777        assert_eq!(parse_retry_after_secs("0"), Some(Duration::ZERO));
778        // HTTP-date form is unsupported -> falls back to backoff.
779        assert_eq!(
780            parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
781            None
782        );
783        assert_eq!(parse_retry_after_secs(""), None);
784    }
785
786    #[test]
787    fn backoff_grows_then_caps_within_jitter_bounds() {
788        let policy = RetryPolicy {
789            max_retries: 5,
790            base_delay: Duration::from_millis(100),
791            max_delay: Duration::from_millis(1000),
792        };
793        // Full jitter: every sample stays within [0, ceiling] where the
794        // ceiling is base*2^attempt capped at max_delay.
795        for attempt in 0..8u32 {
796            let ceiling = Duration::from_millis(100u64.saturating_mul(1 << attempt.min(20)))
797                .min(policy.max_delay);
798            for _ in 0..64 {
799                assert!(backoff(&policy, attempt) <= ceiling);
800            }
801        }
802    }
803
804    #[test]
805    fn backoff_handles_large_attempt_without_overflow() {
806        let policy = RetryPolicy::default();
807        // attempt >= 32 would overflow a naive shift; must saturate to max_delay.
808        assert!(backoff(&policy, 64) <= policy.max_delay);
809    }
810
811    #[test]
812    fn disabled_policy_has_no_retries() {
813        assert_eq!(RetryPolicy::disabled().max_retries, 0);
814    }
815
816    /// A telemetry sink that records every event for assertions.
817    struct Capture(std::sync::Mutex<Vec<crate::telemetry::TelemetryEvent>>);
818
819    impl Telemetry for Capture {
820        fn record(&self, event: &crate::telemetry::TelemetryEvent) {
821            self.0.lock().unwrap().push(event.clone());
822        }
823    }
824
825    #[tokio::test]
826    async fn http_request_records_telemetry_event() {
827        use crate::telemetry::Outcome;
828        use tokio::io::{AsyncReadExt, AsyncWriteExt};
829
830        // One-shot loopback server: read the request, reply with the success
831        // envelope, then close.
832        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
833        let addr = listener.local_addr().unwrap();
834        let server = tokio::spawn(async move {
835            let (mut sock, _) = listener.accept().await.unwrap();
836            let mut buf = [0u8; 2048];
837            let _ = sock.read(&mut buf).await.unwrap();
838            let body = br#"{"Code":1000}"#;
839            let head = format!(
840                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
841                body.len()
842            );
843            sock.write_all(head.as_bytes()).await.unwrap();
844            sock.write_all(body).await.unwrap();
845            sock.flush().await.unwrap();
846        });
847
848        let config = ProtonClientConfiguration::new("test@1.0")
849            .with_base_url(format!("http://{addr}/"))
850            .with_retry_policy(RetryPolicy::disabled());
851        let client = ApiHttpClient::new(
852            config,
853            SessionId::from("test-session"),
854            Tokens {
855                access_token: "access".into(),
856                refresh_token: "refresh".into(),
857            },
858        )
859        .unwrap();
860
861        let capture = Arc::new(Capture(std::sync::Mutex::new(Vec::new())));
862        client.set_telemetry(capture.clone());
863
864        let _: ApiResponse = client.get("some/path").await.unwrap();
865        server.await.unwrap();
866
867        let events = capture.0.lock().unwrap();
868        assert_eq!(events.len(), 1, "exactly one http_request event");
869        let event = &events[0];
870        assert_eq!(event.operation, "http_request");
871        assert_eq!(event.outcome, Outcome::Success);
872        assert!(
873            event
874                .attributes
875                .iter()
876                .any(|(k, v)| *k == "method" && v == "GET")
877        );
878        assert!(
879            event
880                .attributes
881                .iter()
882                .any(|(k, v)| *k == "status" && v == "200")
883        );
884    }
885}