Skip to main content

omni_dev/gmail/
client.rs

1//! Gmail REST API client.
2//!
3//! Thin `reqwest` wrapper that attaches a Bearer access token (refreshed by
4//! an owned [`GmailSession`]) to every request, retries HTTP 429 via the
5//! shared [`retry_429`](crate::utils::http::retry_429) driver, and retries
6//! exactly once on HTTP 401 by forcing a session refresh. Modelled on
7//! [`crate::datadog::client::DatadogClient`]; the difference is Bearer-token
8//! auth with in-process refresh instead of two static API keys.
9
10use anyhow::{Context, Result};
11use reqwest::{Client, Response};
12use url::Url;
13
14use crate::gmail::auth::{GmailCredentials, GmailSession};
15use crate::gmail::error::GmailError;
16use crate::request_log;
17use crate::utils::env::{EnvSource, SystemEnv};
18use crate::utils::http::{connect_timeout, read_timeout, retry_if};
19
20/// HTTP client for the Gmail v1 REST API.
21pub struct GmailClient {
22    client: Client,
23    base_url: String,
24    session: GmailSession,
25}
26
27impl std::fmt::Debug for GmailClient {
28    // Hand-written, not derived: omits `session` entirely rather than
29    // relying on every nested `Secret` staying wrapped — the safest
30    // possible redaction is "not mentioned at all."
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("GmailClient")
33            .field("base_url", &self.base_url)
34            .finish_non_exhaustive()
35    }
36}
37
38impl GmailClient {
39    /// The real Gmail API host. Unlike Datadog, there is no per-tenant
40    /// site/region this is *derived* from — [`Self::DEFAULT_BASE_URL`] is
41    /// the one real host, overridable wholesale via `GMAIL_API_URL`
42    /// (`crate::gmail::auth::GMAIL_API_URL`; see
43    /// [`Self::from_credentials_with`]) rather than site-substituted like
44    /// Datadog's `DATADOG_API_URL`. [`Self::new`]'s `base_url` parameter is
45    /// the lower-level seam both the override and tests go through.
46    const DEFAULT_BASE_URL: &'static str = "https://gmail.googleapis.com";
47
48    /// Builds a client against `base_url` with already-loaded credentials.
49    ///
50    /// For production use, construct via [`Self::from_credentials`]; tests
51    /// pass a wiremock URL directly.
52    pub fn new(base_url: &str, credentials: &GmailCredentials) -> Result<Self> {
53        let client = Client::builder()
54            .connect_timeout(connect_timeout())
55            .read_timeout(read_timeout())
56            .build()
57            .context("Failed to build HTTP client")?;
58        let session = GmailSession::new(client.clone(), credentials);
59        Ok(Self {
60            client,
61            base_url: base_url.trim_end_matches('/').to_string(),
62            session,
63        })
64    }
65
66    /// Creates a client from stored credentials against the real Gmail API
67    /// host.
68    ///
69    /// Respects `GMAIL_API_URL` as an optional override: when set (and
70    /// non-empty) in the process environment it replaces
71    /// [`Self::DEFAULT_BASE_URL`] wholesale. Added per PR #1466 review —
72    /// without it, exercising the CLI's output shapes required a real
73    /// Google Cloud project, and there was no way to route through a forced
74    /// egress proxy.
75    pub fn from_credentials(credentials: &GmailCredentials) -> Result<Self> {
76        Self::from_credentials_with(&SystemEnv, credentials)
77    }
78
79    /// [`from_credentials`](Self::from_credentials) over an injected
80    /// [`EnvSource`], so tests can exercise the `GMAIL_API_URL` override via
81    /// `MapEnv` without mutating the process environment.
82    pub(crate) fn from_credentials_with(
83        env: &impl EnvSource,
84        credentials: &GmailCredentials,
85    ) -> Result<Self> {
86        let base_url = env
87            .var(crate::gmail::auth::GMAIL_API_URL)
88            .filter(|s| !s.is_empty())
89            .unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string());
90        Self::new(&base_url, credentials)
91    }
92
93    /// Returns the API base URL (without trailing slash).
94    #[must_use]
95    pub fn base_url(&self) -> &str {
96        &self.base_url
97    }
98
99    /// Builds an absolute API URL by joining `path` onto `base_url`.
100    ///
101    /// Takes `base_url` (rather than `&self`) so the free `build_*_url`
102    /// functions in the API façade modules — and their unit tests, which
103    /// pass literal base URLs — can call it without an instance.
104    pub(crate) fn api_url(base_url: &str, path: &str) -> Result<Url> {
105        Url::parse(&format!("{base_url}{path}")).context("Invalid Gmail base URL")
106    }
107
108    /// Checks `response` for success and deserialises its JSON body into `T`.
109    pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
110        &self,
111        response: Response,
112        context: &'static str,
113    ) -> Result<T> {
114        if !response.status().is_success() {
115            return Err(Self::response_to_error(response).await.into());
116        }
117        response.json().await.context(context)
118    }
119
120    /// Sends an authenticated GET and deserialises the JSON body into `T`.
121    pub(crate) async fn get_parsed<T: serde::de::DeserializeOwned>(
122        &self,
123        url: &str,
124        context: &'static str,
125    ) -> Result<T> {
126        let response = self.get_json(url).await?;
127        self.parse_response(response, context).await
128    }
129
130    /// Sends an authenticated GET request and returns the raw response.
131    ///
132    /// Retries exactly once on HTTP 401 by forcing a session refresh — see
133    /// [`Self::send_authorized`] for why both a proactive and a reactive
134    /// refresh path exist.
135    pub async fn get_json(&self, url: &str) -> Result<Response> {
136        self.send_authorized(url, "GET", |client, token| {
137            client
138                .get(url)
139                .bearer_auth(token)
140                .header("Accept", "application/json")
141        })
142        .await
143    }
144
145    /// Sends an authenticated POST request with a JSON body and returns the
146    /// raw response.
147    pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
148        &self,
149        url: &str,
150        body: &T,
151    ) -> Result<Response> {
152        self.send_authorized(url, "POST", |client, token| {
153            client
154                .post(url)
155                .bearer_auth(token)
156                .header("Content-Type", "application/json")
157                .json(body)
158        })
159        .await
160    }
161
162    /// Sends a request built by `build`, retrying exactly once on HTTP 401.
163    ///
164    /// [`GmailSession::access_token`] already refreshes proactively when the
165    /// tracked expiry is near — this reactive path exists for what
166    /// proactive tracking can't see: clock skew against Google's clock, or
167    /// the token being invalidated server-side mid-run (revoked access). A
168    /// second 401 after the retry is authoritative: either the refresh
169    /// produced a token that was also rejected, or another caller's
170    /// already-current token was reused and still rejected — either way the
171    /// problem isn't staleness, so it surfaces as an ordinary
172    /// `ApiRequestFailed` rather than retrying again.
173    async fn send_authorized<F>(
174        &self,
175        url: &str,
176        method: &'static str,
177        build: F,
178    ) -> Result<Response>
179    where
180        F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
181    {
182        let token = self
183            .session
184            .access_token()
185            .await
186            .context("Failed to obtain a Gmail access token")?;
187        let response = self
188            .send_once(url, method, &build, token.expose_secret())
189            .await?;
190        if response.status().as_u16() != 401 {
191            return Ok(response);
192        }
193        let refreshed = self
194            .session
195            .force_refresh(&token)
196            .await
197            .context("Failed to refresh the Gmail access token after a 401")?;
198        self.send_once(url, method, &build, refreshed.expose_secret())
199            .await
200    }
201
202    async fn send_once<F>(
203        &self,
204        url: &str,
205        method: &'static str,
206        build: &F,
207        token: &str,
208    ) -> Result<Response>
209    where
210        F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
211    {
212        retry_if(
213            || build(&self.client, token),
214            |started, result| {
215                request_log::record_http_result("gmail", method, url, started, result);
216            },
217            |status, body| status == 429 || is_gmail_quota_exceeded(status, body),
218        )
219        .await
220        .with_context(|| format!("Failed to send {method} request to Gmail API"))
221    }
222
223    /// Consumes a non-success response into a [`GmailError`].
224    ///
225    /// Parses Gmail's `{"error":{"message":...,"errors":[{"reason":...}]}}`
226    /// envelope into a human message when present (falls back to the raw
227    /// body otherwise). Gmail signals quota exhaustion as **403**
228    /// `rateLimitExceeded`/`userRateLimitExceeded`, not `429` — unlike plain
229    /// 429s, that shape now also drives a retry (see [`is_gmail_quota_exceeded`]
230    /// via [`retry_if`](crate::utils::http::retry_if)), so this only sees the
231    /// error once retries are exhausted (or the reason didn't match).
232    pub async fn response_to_error(response: Response) -> GmailError {
233        let status = response.status().as_u16();
234        let raw = response.text().await.unwrap_or_default();
235        let value = serde_json::from_str::<serde_json::Value>(&raw).ok();
236        let reason = value.as_ref().and_then(gmail_error_reason);
237        let body = value
238            .as_ref()
239            .and_then(gmail_error_message)
240            .map(|message| match &reason {
241                Some(r) => format!("{message} (reason: {r})"),
242                None => message,
243            })
244            .unwrap_or(raw);
245        GmailError::ApiRequestFailed {
246            status,
247            body,
248            reason,
249        }
250    }
251}
252
253/// Extracts the `error.errors[0].reason` field from Gmail's already-parsed
254/// JSON error envelope, if present.
255fn gmail_error_reason(value: &serde_json::Value) -> Option<String> {
256    value
257        .get("error")
258        .and_then(|e| e.get("errors"))
259        .and_then(|e| e.as_array())
260        .and_then(|a| a.first())
261        .and_then(|e| e.get("reason"))
262        .and_then(|r| r.as_str())
263        .map(str::to_string)
264}
265
266/// Extracts the `error.message` field from Gmail's already-parsed JSON
267/// error envelope, if present.
268fn gmail_error_message(value: &serde_json::Value) -> Option<String> {
269    value
270        .get("error")?
271        .get("message")?
272        .as_str()
273        .map(str::to_string)
274}
275
276/// Whether a response is Gmail's quota-exhaustion signal — **403** with
277/// `reason` of `rateLimitExceeded` or `userRateLimitExceeded` specifically,
278/// not any 403 with a `reason` field: e.g. `insufficientPermissions` is also
279/// a 403 and must never be retried (retrying a scope/permission error just
280/// wastes the backoff window before failing anyway).
281fn is_gmail_quota_exceeded(status: u16, body: &[u8]) -> bool {
282    if status != 403 {
283        return false;
284    }
285    let Ok(text) = std::str::from_utf8(body) else {
286        return false;
287    };
288    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
289        return false;
290    };
291    matches!(
292        gmail_error_reason(&value).as_deref(),
293        Some("rateLimitExceeded" | "userRateLimitExceeded")
294    )
295}
296
297/// Test-only seam letting sibling API-façade test modules (which can't
298/// reach `GmailClient`'s private fields directly, unlike this module's own
299/// `tests` submodule) bootstrap a deterministic access token via wiremock.
300#[cfg(test)]
301pub(crate) mod test_support {
302    use super::GmailClient;
303    use crate::gmail::auth::{GmailCredentials, GmailSession};
304
305    /// Replaces `client`'s session with one pointed at an explicit token
306    /// endpoint.
307    pub(crate) fn replace_session(
308        client: &mut GmailClient,
309        credentials: &GmailCredentials,
310        token_endpoint: &str,
311    ) {
312        client.session = GmailSession::new_with_token_endpoint(
313            client.client.clone(),
314            credentials,
315            token_endpoint,
316        );
317    }
318}
319
320#[cfg(test)]
321#[allow(clippy::unwrap_used, clippy::expect_used)]
322mod tests {
323    use super::*;
324    use crate::gmail::auth::GmailScope;
325    use crate::utils::secret::Secret;
326
327    fn test_credentials() -> GmailCredentials {
328        GmailCredentials {
329            client_id: "client-1".to_string(),
330            client_secret: Secret::new("secret-1"),
331            refresh_token: Secret::new("refresh-1"),
332            scope: GmailScope::ReadOnly,
333        }
334    }
335
336    #[test]
337    fn new_client_strips_trailing_slash() {
338        let client =
339            GmailClient::new("https://gmail.googleapis.com/", &test_credentials()).unwrap();
340        assert_eq!(client.base_url(), "https://gmail.googleapis.com");
341    }
342
343    #[test]
344    fn new_client_preserves_clean_url() {
345        let client = GmailClient::new("https://gmail.googleapis.com", &test_credentials()).unwrap();
346        assert_eq!(client.base_url(), "https://gmail.googleapis.com");
347    }
348
349    #[test]
350    fn from_credentials_uses_gmail_api_host() {
351        // Via a fresh MapEnv, not from_credentials()'s real SystemEnv — a
352        // stray GMAIL_API_URL in the actual process environment must not
353        // make this test flaky (mirrors the Datadog precedent,
354        // from_credentials_builds_base_url_from_site).
355        let env = crate::test_support::env::MapEnv::new();
356        let client = GmailClient::from_credentials_with(&env, &test_credentials()).unwrap();
357        assert_eq!(client.base_url(), "https://gmail.googleapis.com");
358    }
359
360    #[test]
361    fn from_credentials_honours_api_url_override() {
362        let env = crate::test_support::env::MapEnv::new().with(
363            crate::gmail::auth::GMAIL_API_URL,
364            "http://proxy.example:8080",
365        );
366        let client = GmailClient::from_credentials_with(&env, &test_credentials()).unwrap();
367        assert_eq!(client.base_url(), "http://proxy.example:8080");
368    }
369
370    #[test]
371    fn from_credentials_ignores_empty_api_url_override() {
372        let env =
373            crate::test_support::env::MapEnv::new().with(crate::gmail::auth::GMAIL_API_URL, "");
374        let client = GmailClient::from_credentials_with(&env, &test_credentials()).unwrap();
375        assert_eq!(client.base_url(), "https://gmail.googleapis.com");
376    }
377
378    #[test]
379    fn client_debug_never_mentions_session_field() {
380        let client = GmailClient::new("https://gmail.googleapis.com", &test_credentials()).unwrap();
381        let debug = format!("{client:?}");
382        assert!(!debug.contains("secret-1"));
383        assert!(!debug.contains("refresh-1"));
384        assert!(!debug.contains("session"));
385        assert!(debug.contains("GmailClient"));
386    }
387
388    /// Mounts a bootstrap token-endpoint mock at the same base URL as the
389    /// Gmail API mock — `GmailSession` doesn't distinguish the two hosts in
390    /// these tests, so pointing the token endpoint at the wiremock server
391    /// too keeps the setup to one server per test.
392    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
393        // `up_to_n_times(1)` + `with_priority(1)` so a test's own follow-up
394        // POST /token mock (registered at `with_priority(2)`, matched only
395        // once this one is exhausted) can simulate a second, distinct
396        // refresh without either mock racing the other for every request.
397        wiremock::Mock::given(wiremock::matchers::method("POST"))
398            .and(wiremock::matchers::path("/token"))
399            .respond_with(
400                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
401                    "access_token": "bootstrap-token",
402                    "expires_in": 3600,
403                })),
404            )
405            .up_to_n_times(1)
406            .with_priority(1)
407            .mount(server)
408            .await;
409
410        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
411        client.session = GmailSession::new_with_token_endpoint(
412            client.client.clone(),
413            &test_credentials(),
414            &format!("{}/token", server.uri()),
415        );
416        client
417    }
418
419    #[tokio::test]
420    async fn get_json_sends_bearer_auth_header() {
421        let server = wiremock::MockServer::start().await;
422        let client = client_with_bootstrapped_token(&server).await;
423        wiremock::Mock::given(wiremock::matchers::method("GET"))
424            .and(wiremock::matchers::path("/test"))
425            .and(wiremock::matchers::header(
426                "Authorization",
427                "Bearer bootstrap-token",
428            ))
429            .respond_with(
430                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
431            )
432            .expect(1)
433            .mount(&server)
434            .await;
435
436        let resp = client
437            .get_json(&format!("{}/test", server.uri()))
438            .await
439            .unwrap();
440        assert!(resp.status().is_success());
441    }
442
443    #[tokio::test]
444    async fn post_json_sends_body_and_bearer_auth() {
445        let server = wiremock::MockServer::start().await;
446        let client = client_with_bootstrapped_token(&server).await;
447        wiremock::Mock::given(wiremock::matchers::method("POST"))
448            .and(wiremock::matchers::path("/test"))
449            .and(wiremock::matchers::header(
450                "Authorization",
451                "Bearer bootstrap-token",
452            ))
453            .and(wiremock::matchers::body_json(serde_json::json!({"k": "v"})))
454            .respond_with(wiremock::ResponseTemplate::new(200))
455            .expect(1)
456            .mount(&server)
457            .await;
458
459        let resp = client
460            .post_json(
461                &format!("{}/test", server.uri()),
462                &serde_json::json!({"k": "v"}),
463            )
464            .await
465            .unwrap();
466        assert!(resp.status().is_success());
467    }
468
469    #[tokio::test]
470    async fn get_json_retries_on_429() {
471        let server = wiremock::MockServer::start().await;
472        let client = client_with_bootstrapped_token(&server).await;
473        wiremock::Mock::given(wiremock::matchers::method("GET"))
474            .and(wiremock::matchers::path("/test"))
475            .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
476            .up_to_n_times(1)
477            .with_priority(1)
478            .mount(&server)
479            .await;
480        wiremock::Mock::given(wiremock::matchers::method("GET"))
481            .and(wiremock::matchers::path("/test"))
482            .respond_with(wiremock::ResponseTemplate::new(200))
483            .with_priority(2)
484            .mount(&server)
485            .await;
486
487        let resp = client
488            .get_json(&format!("{}/test", server.uri()))
489            .await
490            .unwrap();
491        assert_eq!(resp.status().as_u16(), 200);
492    }
493
494    #[tokio::test]
495    async fn get_json_retries_403_rate_limit_exceeded_then_succeeds() {
496        let server = wiremock::MockServer::start().await;
497        let client = client_with_bootstrapped_token(&server).await;
498        wiremock::Mock::given(wiremock::matchers::method("GET"))
499            .and(wiremock::matchers::path("/test"))
500            .respond_with(
501                wiremock::ResponseTemplate::new(403)
502                    .append_header("Retry-After", "0")
503                    .set_body_json(serde_json::json!({
504                        "error": {"message": "Rate Limit Exceeded", "errors": [{"reason": "rateLimitExceeded"}]}
505                    })),
506            )
507            .up_to_n_times(1)
508            .with_priority(1)
509            .mount(&server)
510            .await;
511        wiremock::Mock::given(wiremock::matchers::method("GET"))
512            .and(wiremock::matchers::path("/test"))
513            .respond_with(wiremock::ResponseTemplate::new(200))
514            .with_priority(2)
515            .mount(&server)
516            .await;
517
518        let resp = client
519            .get_json(&format!("{}/test", server.uri()))
520            .await
521            .unwrap();
522        assert_eq!(resp.status().as_u16(), 200);
523    }
524
525    #[tokio::test]
526    async fn get_json_does_not_retry_insufficient_permissions_403() {
527        let server = wiremock::MockServer::start().await;
528        let client = client_with_bootstrapped_token(&server).await;
529        wiremock::Mock::given(wiremock::matchers::method("GET"))
530            .and(wiremock::matchers::path("/test"))
531            .respond_with(
532                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
533                    "error": {"message": "Insufficient Permission", "errors": [{"reason": "insufficientPermissions"}]}
534                })),
535            )
536            .expect(1)
537            .mount(&server)
538            .await;
539
540        let resp = client
541            .get_json(&format!("{}/test", server.uri()))
542            .await
543            .unwrap();
544        assert_eq!(resp.status().as_u16(), 403);
545    }
546
547    #[tokio::test]
548    async fn get_json_refreshes_and_retries_once_on_401() {
549        let server = wiremock::MockServer::start().await;
550        let client = client_with_bootstrapped_token(&server).await;
551        // The refresh endpoint issues a second, distinct token.
552        wiremock::Mock::given(wiremock::matchers::method("POST"))
553            .and(wiremock::matchers::path("/token"))
554            .respond_with(
555                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
556                    "access_token": "refreshed-token",
557                    "expires_in": 3600,
558                })),
559            )
560            .up_to_n_times(1)
561            .with_priority(2)
562            .mount(&server)
563            .await;
564        wiremock::Mock::given(wiremock::matchers::method("GET"))
565            .and(wiremock::matchers::path("/test"))
566            .and(wiremock::matchers::header(
567                "Authorization",
568                "Bearer bootstrap-token",
569            ))
570            .respond_with(wiremock::ResponseTemplate::new(401))
571            .expect(1)
572            .mount(&server)
573            .await;
574        wiremock::Mock::given(wiremock::matchers::method("GET"))
575            .and(wiremock::matchers::path("/test"))
576            .and(wiremock::matchers::header(
577                "Authorization",
578                "Bearer refreshed-token",
579            ))
580            .respond_with(wiremock::ResponseTemplate::new(200))
581            .expect(1)
582            .mount(&server)
583            .await;
584
585        let resp = client
586            .get_json(&format!("{}/test", server.uri()))
587            .await
588            .unwrap();
589        assert_eq!(resp.status().as_u16(), 200);
590    }
591
592    #[tokio::test]
593    async fn get_json_does_not_retry_a_second_time_on_persistent_401() {
594        let server = wiremock::MockServer::start().await;
595        let client = client_with_bootstrapped_token(&server).await;
596        wiremock::Mock::given(wiremock::matchers::method("POST"))
597            .and(wiremock::matchers::path("/token"))
598            .respond_with(
599                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
600                    "access_token": "still-rejected-token",
601                    "expires_in": 3600,
602                })),
603            )
604            .up_to_n_times(1)
605            .with_priority(2)
606            .mount(&server)
607            .await;
608        wiremock::Mock::given(wiremock::matchers::method("GET"))
609            .and(wiremock::matchers::path("/test"))
610            .respond_with(
611                wiremock::ResponseTemplate::new(401).set_body_string("still unauthorized"),
612            )
613            .expect(2)
614            .mount(&server)
615            .await;
616
617        let resp = client
618            .get_json(&format!("{}/test", server.uri()))
619            .await
620            .unwrap();
621        assert_eq!(resp.status().as_u16(), 401);
622    }
623
624    #[tokio::test]
625    async fn response_to_error_extracts_gmail_message_and_reason() {
626        let server = wiremock::MockServer::start().await;
627        let client = client_with_bootstrapped_token(&server).await;
628        // `userRateLimitExceeded` is now retryable (`is_gmail_quota_exceeded`),
629        // so without a zero-delay `Retry-After` this test would wait through
630        // the real exponential backoff before giving up.
631        wiremock::Mock::given(wiremock::matchers::method("GET"))
632            .and(wiremock::matchers::path("/test"))
633            .respond_with(
634                wiremock::ResponseTemplate::new(403)
635                    .append_header("Retry-After", "0")
636                    .set_body_json(serde_json::json!({
637                        "error": {
638                            "message": "User Rate Limit Exceeded",
639                            "errors": [{"reason": "userRateLimitExceeded"}],
640                        }
641                    })),
642            )
643            .mount(&server)
644            .await;
645
646        let resp = client
647            .get_json(&format!("{}/test", server.uri()))
648            .await
649            .unwrap();
650        let err = GmailClient::response_to_error(resp).await;
651        let msg = err.to_string();
652        assert!(msg.contains("User Rate Limit Exceeded"));
653        assert!(msg.contains("userRateLimitExceeded"));
654        assert_eq!(err.reason(), Some("userRateLimitExceeded"));
655    }
656
657    #[tokio::test]
658    async fn response_to_error_omits_reason_suffix_when_absent() {
659        let server = wiremock::MockServer::start().await;
660        let client = client_with_bootstrapped_token(&server).await;
661        wiremock::Mock::given(wiremock::matchers::method("GET"))
662            .and(wiremock::matchers::path("/test"))
663            .respond_with(
664                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
665                    "error": {
666                        "message": "Invalid request",
667                    }
668                })),
669            )
670            .mount(&server)
671            .await;
672
673        let resp = client
674            .get_json(&format!("{}/test", server.uri()))
675            .await
676            .unwrap();
677        let err = GmailClient::response_to_error(resp).await;
678        let msg = err.to_string();
679        assert!(msg.contains("Invalid request"));
680        assert!(!msg.contains("reason:"));
681        assert_eq!(err.reason(), None);
682    }
683
684    #[tokio::test]
685    async fn response_to_error_falls_back_to_raw_body_when_not_gmail_shaped() {
686        let server = wiremock::MockServer::start().await;
687        let client = client_with_bootstrapped_token(&server).await;
688        wiremock::Mock::given(wiremock::matchers::method("GET"))
689            .and(wiremock::matchers::path("/test"))
690            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("internal error"))
691            .mount(&server)
692            .await;
693
694        let resp = client
695            .get_json(&format!("{}/test", server.uri()))
696            .await
697            .unwrap();
698        let err = GmailClient::response_to_error(resp).await;
699        assert!(err.to_string().contains("internal error"));
700    }
701
702    #[tokio::test]
703    async fn get_json_propagates_network_errors() {
704        let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
705        let result = client.get_json("http://127.0.0.1:1/test").await;
706        assert!(result.is_err());
707    }
708
709    #[tokio::test]
710    async fn get_parsed_errors_on_malformed_json_response() {
711        let server = wiremock::MockServer::start().await;
712        let client = client_with_bootstrapped_token(&server).await;
713        wiremock::Mock::given(wiremock::matchers::method("GET"))
714            .and(wiremock::matchers::path("/test"))
715            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
716            .mount(&server)
717            .await;
718
719        let result: Result<serde_json::Value> = client
720            .get_parsed(&format!("{}/test", server.uri()), "test context")
721            .await;
722        assert!(result.is_err());
723    }
724}