Skip to main content

omni_dev/drive/
api_client.rs

1//! Shared transport core for Google REST APIs.
2//!
3//! Extracted from `crate::drive::client` (issue #1589) so a second Google
4//! host — the Sheets API at `sheets.googleapis.com`, which Drive's
5//! `/drive/v3/...`-on-`www.googleapis.com` base URL cannot reach — can reuse
6//! the OAuth session, the 401 refresh-and-retry, the quota/429 backoff, the
7//! request-log hook and the error-envelope parsing without duplicating any
8//! of it.
9//!
10//! This module owns **transport only**. It has no notion of files, ranges or
11//! any API's resource model; `DriveClient` and `SheetsClient` are thin typed
12//! wrappers that add a default host, an env override and their own façades.
13//! Keeping them as *distinct types* is deliberate: `FilesApi::new` takes a
14//! `&DriveClient`, so a Sheets-hosted client cannot be handed to it and
15//! silently issue `/drive/v3/files` against `sheets.googleapis.com`.
16//!
17//! Gmail (`crate::gmail::client::GmailClient`) is a near-verbatim twin of the
18//! pre-extraction `DriveClient` and is deliberately **not** migrated here:
19//! it carries its own `GmailSession`, so sharing would need a `TokenSource`
20//! abstraction that nothing in issue #1589 requires.
21
22use std::sync::Arc;
23
24use anyhow::{Context, Result};
25use reqwest::{Client, Response};
26use url::Url;
27
28use crate::drive::auth::{DriveCredentials, DriveSession};
29use crate::drive::error::DriveError;
30use crate::request_log;
31use crate::utils::http::{connect_timeout, read_timeout, retry_if};
32
33/// Authenticated HTTP transport for one Google API host.
34pub(crate) struct GoogleApiClient {
35    client: Client,
36    base_url: String,
37    /// Shared so a `SheetsClient` derived from a `DriveClient` reuses the
38    /// same OAuth session: a `sheets write` needs Drive (`files.get` plus the
39    /// ancestor-chain walk) *and* Sheets calls, and an unshared session would
40    /// refresh the same refresh token twice per invocation.
41    session: Arc<DriveSession>,
42    /// The `service` tag every HTTP record carries
43    /// ([`request_log::record_http_result`]). Deliberately the same for
44    /// Drive and Sheets — see `crate::drive::client::SERVICE_TAG`.
45    service: &'static str,
46    /// Human-readable API name for error messages (`"Drive"`/`"Sheets"`).
47    /// Distinct from [`Self::service`] precisely because that one is shared.
48    api_name: &'static str,
49}
50
51impl std::fmt::Debug for GoogleApiClient {
52    // Hand-written, not derived: omits `session` entirely rather than relying
53    // on every nested `Secret` staying wrapped — the safest possible
54    // redaction is "not mentioned at all."
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("GoogleApiClient")
57            .field("base_url", &self.base_url)
58            .field("service", &self.service)
59            .field("api_name", &self.api_name)
60            .finish_non_exhaustive()
61    }
62}
63
64impl GoogleApiClient {
65    /// Builds a transport against `base_url` with already-loaded credentials.
66    pub(crate) fn new(
67        base_url: &str,
68        credentials: &DriveCredentials,
69        service: &'static str,
70        api_name: &'static str,
71    ) -> Result<Self> {
72        let client = Client::builder()
73            .connect_timeout(connect_timeout())
74            .read_timeout(read_timeout())
75            .build()
76            .context("Failed to build HTTP client")?;
77        let session = Arc::new(DriveSession::new(client.clone(), credentials));
78        Ok(Self::from_parts(
79            client, base_url, session, service, api_name,
80        ))
81    }
82
83    /// Builds a transport reusing an existing client and OAuth session,
84    /// against a different host.
85    ///
86    /// This is how a `SheetsClient` is derived from a `DriveClient`: the
87    /// `reqwest::Client` (and its connection pool) and the `Arc<DriveSession>`
88    /// are shared; only the base URL differs.
89    ///
90    /// `dead_code`-allowed because its only consumer, `SheetsClient`, lands in
91    /// the following commit; the extraction is what makes that possible, so
92    /// the seam ships with the extraction rather than trailing it.
93    #[allow(dead_code)]
94    pub(crate) fn derived(
95        &self,
96        base_url: &str,
97        service: &'static str,
98        api_name: &'static str,
99    ) -> Self {
100        Self::from_parts(
101            self.client.clone(),
102            base_url,
103            Arc::clone(&self.session),
104            service,
105            api_name,
106        )
107    }
108
109    fn from_parts(
110        client: Client,
111        base_url: &str,
112        session: Arc<DriveSession>,
113        service: &'static str,
114        api_name: &'static str,
115    ) -> Self {
116        Self {
117            client,
118            base_url: base_url.trim_end_matches('/').to_string(),
119            session,
120            service,
121            api_name,
122        }
123    }
124
125    /// Returns the API base URL (without trailing slash).
126    pub(crate) fn base_url(&self) -> &str {
127        &self.base_url
128    }
129
130    /// Builds an absolute API URL by joining `path` onto `base_url`.
131    ///
132    /// Takes `base_url` (rather than `&self`) so the free `build_*_url`
133    /// functions in the API façade modules — and their unit tests, which pass
134    /// literal base URLs — can call it without an instance.
135    ///
136    /// `path` is interpolated verbatim, so a caller embedding anything but a
137    /// Drive-style opaque id (`[A-Za-z0-9_-]`) **must** percent-encode it
138    /// first: a Sheets A1 range can contain `#`, `?`, `/` and spaces, any of
139    /// which would silently reshape the URL.
140    pub(crate) fn api_url(base_url: &str, path: &str) -> Result<Url> {
141        Url::parse(&format!("{base_url}{path}")).context("Invalid Drive base URL")
142    }
143
144    /// Appends `segments` to `url`'s path, percent-encoding each one.
145    ///
146    /// **This is the safe counterpart to [`Self::api_url`]**, and lives
147    /// beside it because that method's doc comment describes exactly the
148    /// hazard this one exists to remove. A Sheets A1 range goes in the URL
149    /// *path* and is caller-influenced text that routinely contains
150    /// characters with URL meaning: a sheet titled with a `#` truncates the
151    /// path into a fragment, a `?` starts a query string, a `/` invents a
152    /// path segment, and a space is simply invalid. Every one of those
153    /// silently reads or writes the wrong cells rather than erroring.
154    ///
155    /// `files_api.rs`'s `format!("/drive/v3/files/{{file_id}}")` sites are
156    /// safe only because Drive ids are `[A-Za-z0-9_-]`; do not generalise
157    /// from them. New façades should reach for this even when their own ids
158    /// happen to be opaque, so no second precedent for `format!` exists.
159    pub(crate) fn push_path_segments(url: &mut Url, segments: &[&str]) -> Result<()> {
160        let mut path = url
161            .path_segments_mut()
162            .map_err(|()| anyhow::anyhow!("Invalid API base URL: cannot be a base"))?;
163        for segment in segments {
164            path.push(segment);
165        }
166        Ok(())
167    }
168
169    /// Checks `response` for success and deserialises its JSON body into `T`.
170    pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
171        &self,
172        response: Response,
173        context: &'static str,
174    ) -> Result<T> {
175        let response = self.error_unless_success(response).await?;
176        response.json().await.context(context)
177    }
178
179    /// Passes a 2xx `response` through and turns any other status into the
180    /// error [`Self::response_to_error`] builds — the one place both
181    /// `parse_response` and `parse_success_response` decide "the server
182    /// refused".
183    async fn error_unless_success(&self, response: Response) -> Result<Response> {
184        if response.status().is_success() {
185            Ok(response)
186        } else {
187            Err(Self::response_to_error(self.api_name, response)
188                .await
189                .into())
190        }
191    }
192
193    /// Like [`Self::parse_response`], but tells "the server refused" apart from
194    /// "the server said 2xx, yet the body could not be read".
195    ///
196    /// The outer `Err` is a non-2xx (`response_to_error`'s message). On a 2xx the
197    /// inner `Result` is the body: `Err(detail)` means the request *succeeded*
198    /// but its reply failed to read or deserialise. For a mutating call that
199    /// distinction is load-bearing, because reporting an applied mutation as a
200    /// failure invites a retry that duplicates it (issue #2021).
201    pub(crate) async fn parse_success_response<T: serde::de::DeserializeOwned>(
202        &self,
203        response: Response,
204        context: &'static str,
205    ) -> Result<std::result::Result<T, String>> {
206        let response = self.error_unless_success(response).await?;
207        let bytes = match response.bytes().await {
208            Ok(bytes) => bytes,
209            Err(err) => return Ok(Err(format!("{context}: {err}"))),
210        };
211        Ok(serde_json::from_slice(&bytes).map_err(|err| format!("{context}: {err}")))
212    }
213
214    /// Sends an authenticated GET and deserialises the JSON body into `T`.
215    pub(crate) async fn get_parsed<T: serde::de::DeserializeOwned>(
216        &self,
217        url: &str,
218        context: &'static str,
219    ) -> Result<T> {
220        let response = self.get_json(url).await?;
221        self.parse_response(response, context).await
222    }
223
224    /// Sends an authenticated GET request and returns the raw response.
225    ///
226    /// Retries exactly once on HTTP 401 by forcing a session refresh — see
227    /// [`Self::send_authorized`] for why both a proactive and a reactive
228    /// refresh path exist.
229    pub(crate) async fn get_json(&self, url: &str) -> Result<Response> {
230        self.send_authorized(url, "GET", |client, token| {
231            client
232                .get(url)
233                .bearer_auth(token)
234                .header("Accept", "application/json")
235        })
236        .await
237    }
238
239    /// Sends an authenticated GET request without forcing an `Accept:
240    /// application/json` header, for endpoints that return raw bytes rather
241    /// than JSON (`files.export`, `files.get?alt=media`).
242    pub(crate) async fn get_bytes(&self, url: &str) -> Result<Response> {
243        self.send_authorized(url, "GET", |client, token| {
244            client.get(url).bearer_auth(token)
245        })
246        .await
247    }
248
249    /// Sends an authenticated POST request with a JSON body.
250    pub(crate) async fn post_json<T: serde::Serialize + Sync + ?Sized>(
251        &self,
252        url: &str,
253        body: &T,
254    ) -> Result<Response> {
255        self.send_authorized(url, "POST", |client, token| {
256            client
257                .post(url)
258                .bearer_auth(token)
259                .header("Content-Type", "application/json")
260                .json(body)
261        })
262        .await
263    }
264
265    /// Sends an authenticated PUT request with a JSON body.
266    ///
267    /// Added for Sheets' `spreadsheets.values.update` (issue #1589), the
268    /// first PUT endpoint this transport calls — Drive v3 uses PATCH for
269    /// `files.update`. `dead_code`-allowed until that caller lands; exercised
270    /// by `client.rs`'s `put_json_sends_body_and_bearer_auth` meanwhile.
271    #[allow(dead_code)]
272    pub(crate) async fn put_json<T: serde::Serialize + Sync + ?Sized>(
273        &self,
274        url: &str,
275        body: &T,
276    ) -> Result<Response> {
277        self.send_authorized(url, "PUT", |client, token| {
278            client
279                .put(url)
280                .bearer_auth(token)
281                .header("Content-Type", "application/json")
282                .json(body)
283        })
284        .await
285    }
286
287    /// Sends an authenticated PATCH request with a JSON body.
288    pub(crate) async fn patch_json<T: serde::Serialize + Sync + ?Sized>(
289        &self,
290        url: &str,
291        body: &T,
292    ) -> Result<Response> {
293        self.send_authorized(url, "PATCH", |client, token| {
294            client
295                .patch(url)
296                .bearer_auth(token)
297                .header("Content-Type", "application/json")
298                .json(body)
299        })
300        .await
301    }
302
303    /// Sends an authenticated POST request with a raw byte body — for
304    /// Drive's multipart-upload endpoint, whose `multipart/related` body
305    /// [`crate::drive::files_api::FilesApi::upload`] hand-assembles.
306    ///
307    /// `body` is cloned per send attempt ([`Self::send_authorized`]'s `build`
308    /// closure is `Fn`, not `FnOnce` — it may run twice, once on a 401 retry
309    /// — and [`reqwest::RequestBuilder::body`] takes ownership).
310    pub(crate) async fn post_bytes(
311        &self,
312        url: &str,
313        body: &[u8],
314        content_type: &str,
315    ) -> Result<Response> {
316        self.send_authorized(url, "POST", |client, token| {
317            client
318                .post(url)
319                .bearer_auth(token)
320                .header("Content-Type", content_type)
321                .body(body.to_vec())
322        })
323        .await
324    }
325
326    /// Sends an authenticated PATCH request with a raw byte body —
327    /// [`crate::drive::files_api::FilesApi::edit_content`]'s simple
328    /// media-only content replacement.
329    pub(crate) async fn patch_bytes(
330        &self,
331        url: &str,
332        body: &[u8],
333        content_type: &str,
334    ) -> Result<Response> {
335        self.send_authorized(url, "PATCH", |client, token| {
336            client
337                .patch(url)
338                .bearer_auth(token)
339                .header("Content-Type", content_type)
340                .body(body.to_vec())
341        })
342        .await
343    }
344
345    /// Sends a request built by `build`, retrying exactly once on HTTP 401.
346    ///
347    /// [`DriveSession::access_token`] already refreshes proactively when the
348    /// tracked expiry is near — this reactive path exists for what proactive
349    /// tracking can't see: clock skew against Google's clock, or the token
350    /// being invalidated server-side mid-run (revoked access). A second 401
351    /// after the retry is authoritative: either the refresh produced a token
352    /// that was also rejected, or another caller's already-current token was
353    /// reused and still rejected — either way the problem isn't staleness, so
354    /// it surfaces as an ordinary `ApiRequestFailed` rather than retrying
355    /// again.
356    async fn send_authorized<F>(
357        &self,
358        url: &str,
359        method: &'static str,
360        build: F,
361    ) -> Result<Response>
362    where
363        F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
364    {
365        let token = self
366            .session
367            .access_token()
368            .await
369            .context("Failed to obtain a Drive access token")?;
370        let response = self
371            .send_once(url, method, &build, token.expose_secret())
372            .await?;
373        if response.status().as_u16() != 401 {
374            return Ok(response);
375        }
376        let refreshed = self
377            .session
378            .force_refresh(&token)
379            .await
380            .context("Failed to refresh the Drive access token after a 401")?;
381        self.send_once(url, method, &build, refreshed.expose_secret())
382            .await
383    }
384
385    async fn send_once<F>(
386        &self,
387        url: &str,
388        method: &'static str,
389        build: &F,
390        token: &str,
391    ) -> Result<Response>
392    where
393        F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
394    {
395        retry_if(
396            || build(&self.client, token),
397            |started, result| {
398                request_log::record_http_result(self.service, method, url, started, result);
399            },
400            |status, body| status == 429 || is_quota_exceeded(status, body),
401            None,
402        )
403        .await
404        .with_context(|| format!("Failed to send {method} request to Drive API"))
405    }
406
407    /// Consumes a non-success response into a [`DriveError`].
408    ///
409    /// Parses Google's error envelope into a human message when present
410    /// (falls back to the raw body otherwise). See [`error_reason`] for why
411    /// two envelope shapes have to be understood.
412    pub(crate) async fn response_to_error(api: &'static str, response: Response) -> DriveError {
413        let status = response.status().as_u16();
414        let raw = response.text().await.unwrap_or_default();
415        let value = serde_json::from_str::<serde_json::Value>(&raw).ok();
416        let reason = value.as_ref().and_then(error_reason);
417        let body = value
418            .as_ref()
419            .and_then(error_message)
420            .map(|message| match &reason {
421                Some(r) => format!("{message} (reason: {r})"),
422                None => message,
423            })
424            .unwrap_or(raw);
425        DriveError::ApiRequestFailed {
426            api,
427            status,
428            body,
429            reason,
430        }
431    }
432}
433
434/// Extracts a machine-readable reason code from an already-parsed Google
435/// JSON error envelope, understanding **both** shapes Google ships.
436///
437/// Drive v3 and the other older APIs return the legacy envelope, whose code
438/// lives at `error.errors[0].reason` (`"insufficientPermissions"`,
439/// `"userRateLimitExceeded"`, ...). Newer services — Sheets v4 among them —
440/// return the `google.rpc` envelope instead:
441///
442/// ```json
443/// {"error":{"code":403,"message":"...","status":"PERMISSION_DENIED"}}
444/// ```
445///
446/// which has **no `errors[]` array at all**. Reading only the legacy shape
447/// yields `reason: None` for every Sheets failure, which silently disables
448/// both [`crate::drive::files_api::append_write_scope_hint`] (it matches on
449/// the reason string) and [`is_quota_exceeded`] — neither fails loudly, they
450/// just never fire. Hence: legacy first, then `error.status`.
451fn error_reason(value: &serde_json::Value) -> Option<String> {
452    let error = value.get("error")?;
453    let legacy = error
454        .get("errors")
455        .and_then(|e| e.as_array())
456        .and_then(|a| a.first())
457        .and_then(|e| e.get("reason"))
458        .and_then(|r| r.as_str())
459        .map(str::to_string);
460    legacy.or_else(|| error.get("status")?.as_str().map(str::to_string))
461}
462
463/// Extracts the `error.message` field from an already-parsed Google JSON
464/// error envelope, if present. Both envelope shapes carry it identically.
465fn error_message(value: &serde_json::Value) -> Option<String> {
466    value
467        .get("error")?
468        .get("message")?
469        .as_str()
470        .map(str::to_string)
471}
472
473/// Whether a response is Google's quota-exhaustion signal — a **403**
474/// carrying the legacy `userRateLimitExceeded` reason or the `google.rpc`
475/// `RESOURCE_EXHAUSTED` status, not any 403 with a reason field:
476/// `insufficientPermissions` / `PERMISSION_DENIED` are also 403s and must
477/// never be retried (retrying a scope error just wastes the backoff window
478/// before failing anyway).
479///
480/// Unlike Gmail's `is_gmail_quota_exceeded`, this does **not** also match the
481/// bare `rateLimitExceeded` reason — that string is confirmed for Gmail but
482/// not for Drive against
483/// [Drive's error-handling guide](https://developers.google.com/workspace/drive/api/guides/handle-errors);
484/// widen this match only once testing surfaces it. Plain `429` responses are
485/// already covered by the literal `status == 429` branch in
486/// [`GoogleApiClient::send_once`], independent of this function.
487fn is_quota_exceeded(status: u16, body: &[u8]) -> bool {
488    if status != 403 {
489        return false;
490    }
491    let Ok(text) = std::str::from_utf8(body) else {
492        return false;
493    };
494    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
495        return false;
496    };
497    matches!(
498        error_reason(&value).as_deref(),
499        Some("userRateLimitExceeded" | "RESOURCE_EXHAUSTED")
500    )
501}
502
503/// Test-only seam letting sibling modules bootstrap a deterministic access
504/// token against a wiremock `/token` endpoint.
505///
506/// A **constructor**, not a post-hoc field assignment: with the session held
507/// behind an `Arc` and shared into derived clients, replacing it after a
508/// derivation would leave the derived client pointed at the real
509/// `oauth2.googleapis.com` — a live network call from a unit test.
510#[cfg(test)]
511impl GoogleApiClient {
512    pub(crate) fn new_with_token_endpoint(
513        base_url: &str,
514        credentials: &DriveCredentials,
515        service: &'static str,
516        api_name: &'static str,
517        token_endpoint: &str,
518    ) -> Result<Self> {
519        let client = Client::builder()
520            .connect_timeout(connect_timeout())
521            .read_timeout(read_timeout())
522            .build()
523            .context("Failed to build HTTP client")?;
524        let session = Arc::new(DriveSession::new_with_token_endpoint(
525            client.clone(),
526            credentials,
527            token_endpoint,
528        ));
529        Ok(Self::from_parts(
530            client, base_url, session, service, api_name,
531        ))
532    }
533}
534
535#[cfg(test)]
536#[allow(clippy::unwrap_used, clippy::expect_used)]
537mod tests {
538    use super::*;
539    use crate::drive::auth::DriveGrantedScopes;
540    use crate::utils::secret::Secret;
541
542    fn test_credentials() -> DriveCredentials {
543        DriveCredentials {
544            client_id: "client-1".to_string(),
545            client_secret: Secret::new("secret-1"),
546            refresh_token: Secret::new("refresh-1"),
547            scope: DriveGrantedScopes::READONLY,
548        }
549    }
550
551    // ── error_reason: both Google envelope shapes ──────────────────────
552
553    #[test]
554    fn error_reason_reads_the_legacy_drive_envelope() {
555        let value = serde_json::json!({
556            "error": {"errors": [{"reason": "insufficientPermissions"}], "message": "nope"},
557        });
558        assert_eq!(
559            error_reason(&value).as_deref(),
560            Some("insufficientPermissions")
561        );
562    }
563
564    #[test]
565    fn error_reason_reads_the_google_rpc_status_when_there_is_no_errors_array() {
566        // The shape Sheets v4 actually returns. Before issue #1589 this
567        // yielded None, silently disabling the write-scope hint and the
568        // quota retry for every Sheets call.
569        let value = serde_json::json!({
570            "error": {"code": 403, "message": "The caller does not have permission",
571                      "status": "PERMISSION_DENIED"},
572        });
573        assert_eq!(error_reason(&value).as_deref(), Some("PERMISSION_DENIED"));
574    }
575
576    #[test]
577    fn error_reason_prefers_the_legacy_reason_when_both_are_present() {
578        let value = serde_json::json!({
579            "error": {"errors": [{"reason": "insufficientPermissions"}],
580                      "status": "PERMISSION_DENIED"},
581        });
582        assert_eq!(
583            error_reason(&value).as_deref(),
584            Some("insufficientPermissions")
585        );
586    }
587
588    #[test]
589    fn error_reason_none_when_neither_shape_is_present() {
590        assert_eq!(error_reason(&serde_json::json!({"error": {}})), None);
591        assert_eq!(error_reason(&serde_json::json!({"nope": 1})), None);
592    }
593
594    // ── is_quota_exceeded ──────────────────────────────────────────────
595
596    #[test]
597    fn is_quota_exceeded_false_on_non_utf8_body() {
598        assert!(!is_quota_exceeded(403, &[0xff, 0xfe]));
599    }
600
601    #[test]
602    fn is_quota_exceeded_false_on_non_403_status() {
603        assert!(!is_quota_exceeded(
604            429,
605            br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
606        ));
607    }
608
609    #[test]
610    fn is_quota_exceeded_true_on_matching_403_reason() {
611        assert!(is_quota_exceeded(
612            403,
613            br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
614        ));
615    }
616
617    #[test]
618    fn is_quota_exceeded_true_on_google_rpc_resource_exhausted() {
619        assert!(is_quota_exceeded(
620            403,
621            br#"{"error":{"code":403,"status":"RESOURCE_EXHAUSTED"}}"#
622        ));
623    }
624
625    #[test]
626    fn is_quota_exceeded_false_on_permission_denied() {
627        // The whole point of not matching every 403 with a reason: a scope
628        // error must fail immediately, not burn the backoff window first.
629        assert!(!is_quota_exceeded(
630            403,
631            br#"{"error":{"code":403,"status":"PERMISSION_DENIED"}}"#
632        ));
633        assert!(!is_quota_exceeded(
634            403,
635            br#"{"error":{"errors":[{"reason":"insufficientPermissions"}]}}"#
636        ));
637    }
638
639    // ── construction ───────────────────────────────────────────────────
640
641    #[test]
642    fn new_strips_trailing_slash() {
643        let client = GoogleApiClient::new(
644            "https://example.test/",
645            &test_credentials(),
646            "drive",
647            "Drive",
648        )
649        .unwrap();
650        assert_eq!(client.base_url(), "https://example.test");
651    }
652
653    #[test]
654    fn derived_shares_the_session_and_swaps_the_host() {
655        let drive = GoogleApiClient::new(
656            "https://www.googleapis.com",
657            &test_credentials(),
658            "drive",
659            "Drive",
660        )
661        .unwrap();
662        let sheets = drive.derived("https://sheets.googleapis.com", "drive", "Sheets");
663        assert_eq!(sheets.base_url(), "https://sheets.googleapis.com");
664        assert_eq!(drive.base_url(), "https://www.googleapis.com");
665        assert!(
666            Arc::ptr_eq(&drive.session, &sheets.session),
667            "a derived client must reuse the OAuth session, not open a second one"
668        );
669    }
670
671    #[test]
672    fn debug_never_mentions_the_session_field() {
673        let client = GoogleApiClient::new(
674            "https://example.test",
675            &test_credentials(),
676            "drive",
677            "Drive",
678        )
679        .unwrap();
680        let debug = format!("{client:?}");
681        assert!(!debug.contains("secret-1"));
682        assert!(!debug.contains("refresh-1"));
683        assert!(!debug.contains("session"));
684    }
685
686    /// A 2xx whose body dies mid-stream is still a *successful* request: the
687    /// mutation may have been applied, so it is an inner `Err`, never the
688    /// outer one that a caller would treat as a failure to retry.
689    #[tokio::test]
690    async fn parse_success_response_reports_a_truncated_2xx_body_as_an_inner_error() {
691        use tokio::io::AsyncWriteExt;
692        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
693        let addr = listener.local_addr().unwrap();
694        tokio::spawn(async move {
695            let (mut socket, _) = listener.accept().await.unwrap();
696            // Promises 100 bytes, delivers 3, then hangs up.
697            socket
698                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nabc")
699                .await
700                .unwrap();
701            socket.shutdown().await.unwrap();
702        });
703        let client = GoogleApiClient::new(
704            &format!("http://{addr}"),
705            &test_credentials(),
706            "drive",
707            "Drive",
708        )
709        .unwrap();
710        let response = reqwest::Client::builder()
711            .no_proxy()
712            .build()
713            .unwrap()
714            .get(format!("http://{addr}/x"))
715            .send()
716            .await
717            .unwrap();
718        let parsed = client
719            .parse_success_response::<serde_json::Value>(response, "reading the reply")
720            .await
721            .unwrap();
722        let detail = parsed.unwrap_err();
723        assert!(detail.starts_with("reading the reply: "), "{detail}");
724    }
725}