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    /// `DELETE {path}` with a JSON body, returning a typed success body.
324    ///
325    /// A few Drive endpoints take their operand in the body of a `DELETE`
326    /// (C# `HttpApiCallBuilder.DeleteAsync(route, payload, ...)`), e.g. removing
327    /// photo tags.
328    pub async fn delete_with_body<B: Serialize, T: DeserializeOwned>(
329        &self,
330        path: &str,
331        body: &B,
332    ) -> Result<T> {
333        self.send::<B, T>(Method::DELETE, path, Some(body)).await
334    }
335
336    async fn send<B: Serialize, T: DeserializeOwned>(
337        &self,
338        method: Method,
339        path: &str,
340        body: Option<&B>,
341    ) -> Result<T> {
342        let mut timer = self.telemetry().start("http_request");
343        timer.attr("method", method.as_str());
344
345        let access_token = self.inner.tokens.lock().await.access_token.clone();
346
347        // An early `?` here records the op as a failure (OpTimer defaults to it).
348        let response = self
349            .send_with_token(method.clone(), path, body, &access_token)
350            .await?;
351
352        let response = if response.status() == StatusCode::UNAUTHORIZED {
353            self.handle_unauthorized(method, path, body, response, access_token)
354                .await?
355        } else {
356            response
357        };
358
359        timer.attr("status", response.status().as_u16());
360        let parsed = parse_response(response).await?;
361        timer.success();
362        Ok(parsed)
363    }
364
365    async fn handle_unauthorized<B: Serialize>(
366        &self,
367        method: Method,
368        path: &str,
369        body: Option<&B>,
370        response: reqwest::Response,
371        rejected_access_token: String,
372    ) -> Result<reqwest::Response> {
373        // Don't bother refreshing for terminal account states.
374        let bytes = response.bytes().await?;
375        if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes)
376            && matches!(
377                envelope.code,
378                ResponseCode::AccountDeleted | ResponseCode::AccountDisabled
379            )
380        {
381            return Err(api_error(StatusCode::UNAUTHORIZED, &bytes));
382        }
383
384        let access_token = self.refresh_access_token(&rejected_access_token).await?;
385        self.send_with_token(method, path, body, &access_token)
386            .await
387    }
388
389    async fn send_with_token<B: Serialize>(
390        &self,
391        method: Method,
392        path: &str,
393        body: Option<&B>,
394        access_token: &str,
395    ) -> Result<reqwest::Response> {
396        let url = format!(
397            "{}{}{}",
398            self.inner.base_url,
399            self.route_prefix,
400            path.trim_start_matches('/')
401        );
402        send_retrying(&self.inner.config.retry_policy, || {
403            let mut request = self
404                .inner
405                .http
406                .request(method.clone(), &url)
407                .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
408                .header(APP_VERSION_HEADER, &self.inner.config.app_version)
409                .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
410                .bearer_auth(access_token);
411
412            if !self.inner.config.user_agent.is_empty() {
413                request =
414                    request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
415            }
416
417            if let Some(body) = body {
418                request = request.json(body);
419            }
420
421            request
422        })
423        .await
424    }
425
426    async fn send_multipart_with_token(
427        &self,
428        path: &str,
429        metadata: &[u8],
430        binary_parts: &[(String, Vec<u8>)],
431        access_token: &str,
432    ) -> Result<reqwest::Response> {
433        let url = format!(
434            "{}{}{}",
435            self.inner.base_url,
436            self.route_prefix,
437            path.trim_start_matches('/')
438        );
439        send_retrying(&self.inner.config.retry_policy, || {
440            let metadata_part = reqwest::multipart::Part::bytes(metadata.to_vec())
441                .file_name("Metadata")
442                .mime_str("application/json")
443                .expect("application/json is a valid MIME type");
444            let mut form = reqwest::multipart::Form::new().part("Metadata", metadata_part);
445            for (name, bytes) in binary_parts {
446                let part = reqwest::multipart::Part::bytes(bytes.clone())
447                    .file_name(name.clone())
448                    .mime_str("application/octet-stream")
449                    .expect("octet-stream is a valid MIME type");
450                form = form.part(name.clone(), part);
451            }
452            let mut request = self
453                .inner
454                .http
455                .post(&url)
456                .timeout(self.inner.config.storage_timeout)
457                .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
458                .header(APP_VERSION_HEADER, &self.inner.config.app_version)
459                .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
460                .bearer_auth(access_token)
461                .multipart(form);
462            if !self.inner.config.user_agent.is_empty() {
463                request =
464                    request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
465            }
466            request
467        })
468        .await
469    }
470
471    /// Refresh the session tokens, deduplicating concurrent refreshes: if the
472    /// in-memory access token already differs from the rejected one, another
473    /// task refreshed first and we reuse its result.
474    async fn refresh_access_token(&self, rejected_access_token: &str) -> Result<String> {
475        let mut guard = self.inner.tokens.lock().await;
476
477        if guard.access_token != rejected_access_token {
478            return Ok(guard.access_token.clone());
479        }
480
481        let refreshed = self.request_refresh(&guard.refresh_token).await?;
482        *guard = refreshed.clone();
483
484        // Notify callback
485        if let Some(ref cb) = *self
486            .inner
487            .on_tokens_refreshed
488            .lock()
489            .expect("on_tokens_refreshed mutex poisoned")
490        {
491            cb(refreshed.clone());
492        }
493
494        Ok(refreshed.access_token)
495    }
496
497    async fn request_refresh(&self, refresh_token: &str) -> Result<Tokens> {
498        let url = format!("{}auth/v4/refresh", self.inner.base_url);
499        let body = SessionRefreshRequest {
500            response_type: "token",
501            grant_type: "refresh_token",
502            refresh_token,
503            redirect_uri: &self.inner.config.refresh_redirect_uri,
504        };
505
506        // The refresh call carries the session id but, deliberately, no bearer
507        // token (the access token is the thing being replaced).
508        let response = send_retrying(&self.inner.config.retry_policy, || {
509            self.inner
510                .http
511                .post(&url)
512                .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
513                .header(APP_VERSION_HEADER, &self.inner.config.app_version)
514                .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
515                .json(&body)
516        })
517        .await?;
518
519        let refreshed: SessionRefreshResponse = parse_response(response).await?;
520        Ok(Tokens {
521            access_token: refreshed.access_token,
522            refresh_token: refreshed.refresh_token,
523        })
524    }
525}
526
527#[derive(Serialize)]
528struct SessionRefreshRequest<'a> {
529    #[serde(rename = "ResponseType")]
530    response_type: &'a str,
531    #[serde(rename = "GrantType")]
532    grant_type: &'a str,
533    #[serde(rename = "RefreshToken")]
534    refresh_token: &'a str,
535    #[serde(rename = "RedirectURI")]
536    redirect_uri: &'a str,
537}
538
539#[derive(serde::Deserialize)]
540struct SessionRefreshResponse {
541    #[serde(rename = "AccessToken")]
542    access_token: String,
543    #[serde(rename = "RefreshToken")]
544    refresh_token: String,
545}
546
547/// `GET {path}` without a session: no `x-pm-uid` and no bearer token.
548///
549/// The public-link flow needs this: `drive/urls/{token}/info` opens the SRP
550/// handshake and is by definition callable by a visitor who has no Proton
551/// session at all.
552pub async fn get_unauthenticated<T: DeserializeOwned>(
553    config: &ProtonClientConfiguration,
554    path: &str,
555) -> Result<T> {
556    let http = reqwest::Client::builder()
557        .timeout(config.request_timeout)
558        .gzip(true)
559        .build()?;
560
561    let base_url = ensure_trailing_slash(&config.base_url);
562    let url = format!("{}{}", base_url, path.trim_start_matches('/'));
563
564    let response = send_retrying(&config.retry_policy, || {
565        let mut request = http
566            .get(&url)
567            .header(APP_VERSION_HEADER, &config.app_version)
568            .header(reqwest::header::ACCEPT, API_CONTENT_TYPE);
569        if !config.user_agent.is_empty() {
570            request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
571        }
572        request
573    })
574    .await?;
575
576    parse_response(response).await
577}
578
579/// `POST {path}` without a session: no `x-pm-uid` and no bearer token.
580///
581/// Used by the SRP login flow (`auth/v4/info`, `auth/v4`), which runs before a
582/// session exists. Mirrors the C# SDK's `BeginAsync`, which issues these calls
583/// on a session-less `HttpClient`.
584pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
585    config: &ProtonClientConfiguration,
586    path: &str,
587    body: &B,
588) -> Result<T> {
589    post_unauthenticated_verified(config, path, body, None).await
590}
591
592/// [`post_unauthenticated`], optionally replaying a solved human-verification
593/// challenge.
594///
595/// A login from an unfamiliar IP comes back `9001` with a challenge instead of a
596/// session. Once the user has solved it, the *same* request is sent again with
597/// the resulting token in the header pair below — the challenge is not a
598/// separate handshake, it is a precondition attached to the original call, which
599/// is why this takes the credential rather than exposing a "verify" endpoint.
600pub async fn post_unauthenticated_verified<B: Serialize, T: DeserializeOwned>(
601    config: &ProtonClientConfiguration,
602    path: &str,
603    body: &B,
604    verification: Option<&HumanVerificationCredential>,
605) -> Result<T> {
606    let http = reqwest::Client::builder()
607        .timeout(config.request_timeout)
608        .gzip(true)
609        .build()?;
610
611    let base_url = ensure_trailing_slash(&config.base_url);
612    let url = format!("{}{}", base_url, path.trim_start_matches('/'));
613
614    let response = send_retrying(&config.retry_policy, || {
615        let mut request = http
616            .post(&url)
617            .header(APP_VERSION_HEADER, &config.app_version)
618            .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
619            .json(body);
620        if !config.user_agent.is_empty() {
621            request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
622        }
623        if let Some(hv) = verification {
624            request = request
625                .header(HV_TOKEN_HEADER, &hv.token)
626                .header(HV_TOKEN_TYPE_HEADER, &hv.method);
627        }
628        request
629    })
630    .await?;
631
632    parse_response(response).await
633}
634
635/// Read a response body, enforce the Proton success envelope, and deserialize
636/// the typed success payload.
637async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
638    let status = response.status();
639    let bytes = response.bytes().await?;
640
641    // Every Proton response embeds the envelope; a missing/non-success code or a
642    // non-2xx HTTP status is an API error. `MultipleResponses` (1001) is a batch
643    // multi-status, not a failure: the real per-item codes live in the body, so
644    // the caller (e.g. trash/restore/delete) inspects them itself.
645    if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
646        if !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses {
647            return Err(api_error(status, &bytes));
648        }
649    } else if !status.is_success() {
650        return Err(api_error(status, &bytes));
651    }
652
653    Ok(serde_json::from_slice::<T>(&bytes)?)
654}
655
656fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError {
657    let envelope = serde_json::from_slice::<ApiResponse>(bytes).ok();
658    let code = envelope
659        .as_ref()
660        .map(|e| e.code)
661        .unwrap_or(ResponseCode::Unknown);
662    let details = envelope.as_ref().and_then(|e| e.details.clone());
663    let message = envelope.and_then(|e| e.error_message).unwrap_or_else(|| {
664        status
665            .canonical_reason()
666            .unwrap_or("unknown error")
667            .to_owned()
668    });
669
670    ProtonError::Api(ProtonApiError {
671        code,
672        http_status: status.as_u16(),
673        message,
674        details,
675    })
676}
677
678/// Send a request, transparently retrying retryable failures per `policy`.
679///
680/// `build` is called once per attempt to produce a fresh `RequestBuilder`
681/// (reqwest builders are consumed by `send`, and streaming bodies like
682/// `multipart` can't be cloned), so every retry resends the full request.
683///
684/// Retryable = HTTP 408/429/502/503/504 or a transient transport error
685/// (timeout / connect). A `Retry-After` header (delta-seconds) is honoured;
686/// otherwise the delay is exponential backoff with full jitter. Non-retryable
687/// responses and errors — including ordinary 4xx and the 401 that drives token
688/// refresh — pass straight through to the caller untouched.
689async fn send_retrying<F>(policy: &RetryPolicy, build: F) -> Result<reqwest::Response>
690where
691    F: Fn() -> reqwest::RequestBuilder,
692{
693    let mut attempt: u32 = 0;
694    loop {
695        match build().send().await {
696            Ok(response) => {
697                if attempt < policy.max_retries && is_retryable_status(response.status()) {
698                    let delay = retry_after(&response).unwrap_or_else(|| backoff(policy, attempt));
699                    tokio::time::sleep(delay).await;
700                    attempt += 1;
701                    continue;
702                }
703                return Ok(response);
704            }
705            Err(err) => {
706                if attempt < policy.max_retries && is_retryable_error(&err) {
707                    tokio::time::sleep(backoff(policy, attempt)).await;
708                    attempt += 1;
709                    continue;
710                }
711                return Err(err.into());
712            }
713        }
714    }
715}
716
717/// Status codes Proton (or an intermediary) returns for transient conditions:
718/// request timeout, rate limit, and the gateway/unavailable family.
719fn is_retryable_status(status: StatusCode) -> bool {
720    matches!(status.as_u16(), 408 | 429 | 502 | 503 | 504)
721}
722
723/// A transport error worth retrying: a timeout or a failure to connect. A
724/// mid-body error (`is_body`) is not retried — the request may have been
725/// applied server-side.
726fn is_retryable_error(err: &reqwest::Error) -> bool {
727    err.is_timeout() || err.is_connect()
728}
729
730/// Parse a `Retry-After` header expressed as delta-seconds. The HTTP-date form
731/// is not emitted by the Proton API, so it is ignored (falls back to backoff).
732fn retry_after(response: &reqwest::Response) -> Option<Duration> {
733    let value = response
734        .headers()
735        .get(reqwest::header::RETRY_AFTER)?
736        .to_str()
737        .ok()?;
738    parse_retry_after_secs(value)
739}
740
741/// Parse a `Retry-After` delta-seconds value into a delay. Non-numeric values
742/// (the HTTP-date form, which Proton does not emit) yield `None`.
743fn parse_retry_after_secs(value: &str) -> Option<Duration> {
744    value.trim().parse().ok().map(Duration::from_secs)
745}
746
747/// Exponential backoff with full jitter: a uniformly random delay in
748/// `[0, base_delay * 2^attempt]`, capped at `max_delay`.
749fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
750    let ceiling = policy
751        .base_delay
752        .saturating_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
753        .min(policy.max_delay);
754    let ceiling_ms = ceiling.as_millis() as u64;
755    if ceiling_ms == 0 {
756        return Duration::ZERO;
757    }
758    Duration::from_millis(rand::rng().random_range(0..=ceiling_ms))
759}
760
761fn ensure_trailing_slash(url: &str) -> String {
762    if url.ends_with('/') {
763        url.to_owned()
764    } else {
765        format!("{url}/")
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn retryable_statuses() {
775        for code in [408u16, 429, 502, 503, 504] {
776            assert!(is_retryable_status(StatusCode::from_u16(code).unwrap()));
777        }
778        for code in [200u16, 400, 401, 403, 404, 500] {
779            assert!(!is_retryable_status(StatusCode::from_u16(code).unwrap()));
780        }
781    }
782
783    #[test]
784    fn retry_after_parses_seconds_only() {
785        assert_eq!(parse_retry_after_secs("5"), Some(Duration::from_secs(5)));
786        assert_eq!(
787            parse_retry_after_secs("  12 "),
788            Some(Duration::from_secs(12))
789        );
790        assert_eq!(parse_retry_after_secs("0"), Some(Duration::ZERO));
791        // HTTP-date form is unsupported -> falls back to backoff.
792        assert_eq!(
793            parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
794            None
795        );
796        assert_eq!(parse_retry_after_secs(""), None);
797    }
798
799    #[test]
800    fn backoff_grows_then_caps_within_jitter_bounds() {
801        let policy = RetryPolicy {
802            max_retries: 5,
803            base_delay: Duration::from_millis(100),
804            max_delay: Duration::from_millis(1000),
805        };
806        // Full jitter: every sample stays within [0, ceiling] where the
807        // ceiling is base*2^attempt capped at max_delay.
808        for attempt in 0..8u32 {
809            let ceiling = Duration::from_millis(100u64.saturating_mul(1 << attempt.min(20)))
810                .min(policy.max_delay);
811            for _ in 0..64 {
812                assert!(backoff(&policy, attempt) <= ceiling);
813            }
814        }
815    }
816
817    #[test]
818    fn backoff_handles_large_attempt_without_overflow() {
819        let policy = RetryPolicy::default();
820        // attempt >= 32 would overflow a naive shift; must saturate to max_delay.
821        assert!(backoff(&policy, 64) <= policy.max_delay);
822    }
823
824    #[test]
825    fn disabled_policy_has_no_retries() {
826        assert_eq!(RetryPolicy::disabled().max_retries, 0);
827    }
828
829    /// A telemetry sink that records every event for assertions.
830    struct Capture(std::sync::Mutex<Vec<crate::telemetry::TelemetryEvent>>);
831
832    impl Telemetry for Capture {
833        fn record(&self, event: &crate::telemetry::TelemetryEvent) {
834            self.0.lock().unwrap().push(event.clone());
835        }
836    }
837
838    #[tokio::test]
839    async fn http_request_records_telemetry_event() {
840        use crate::telemetry::Outcome;
841        use tokio::io::{AsyncReadExt, AsyncWriteExt};
842
843        // One-shot loopback server: read the request, reply with the success
844        // envelope, then close.
845        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
846        let addr = listener.local_addr().unwrap();
847        let server = tokio::spawn(async move {
848            let (mut sock, _) = listener.accept().await.unwrap();
849            let mut buf = [0u8; 2048];
850            let _ = sock.read(&mut buf).await.unwrap();
851            let body = br#"{"Code":1000}"#;
852            let head = format!(
853                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
854                body.len()
855            );
856            sock.write_all(head.as_bytes()).await.unwrap();
857            sock.write_all(body).await.unwrap();
858            sock.flush().await.unwrap();
859        });
860
861        let config = ProtonClientConfiguration::new("test@1.0")
862            .with_base_url(format!("http://{addr}/"))
863            .with_retry_policy(RetryPolicy::disabled());
864        let client = ApiHttpClient::new(
865            config,
866            SessionId::from("test-session"),
867            Tokens {
868                access_token: "access".into(),
869                refresh_token: "refresh".into(),
870            },
871        )
872        .unwrap();
873
874        let capture = Arc::new(Capture(std::sync::Mutex::new(Vec::new())));
875        client.set_telemetry(capture.clone());
876
877        let _: ApiResponse = client.get("some/path").await.unwrap();
878        server.await.unwrap();
879
880        let events = capture.0.lock().unwrap();
881        assert_eq!(events.len(), 1, "exactly one http_request event");
882        let event = &events[0];
883        assert_eq!(event.operation, "http_request");
884        assert_eq!(event.outcome, Outcome::Success);
885        assert!(
886            event
887                .attributes
888                .iter()
889                .any(|(k, v)| *k == "method" && v == "GET")
890        );
891        assert!(
892            event
893                .attributes
894                .iter()
895                .any(|(k, v)| *k == "status" && v == "200")
896        );
897    }
898}