diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ffb2bc2f..1d207dd282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Telemetry is now a process-wide singleton internally; the binding API is unchanged, but the `MozAdsTelemetry` callback now receives metrics from every client in the process. - `request_spoc_or_image_ads` is added to return ads that may ambiguously be spocs or images. - Added support for relevance scores ([AC-205](https://mozilla-hub.atlassian.net/browse/AC-205)) ([AC-206](https://mozilla-hub.atlassian.net/browse/AC-206)) Accepts a HashMap. +- The `DELETE /v1/delete_user` request for a retired context id is now sent by the ads client right after an ad request, over that request's OHTTP channel, instead of as a direct request from the `context_id` component. When the ad request did not use OHTTP the deletion is skipped and logged, so consumers that do not configure the `ads-client` OHTTP channel no longer send it. No binding API change. ([AC-179](https://mozilla-hub.atlassian.net/browse/AC-179)) ### Autofill diff --git a/components/ads-client/docs/usage-javascript.md b/components/ads-client/docs/usage-javascript.md index d3298ddc1f..4eaef593e0 100644 --- a/components/ads-client/docs/usage-javascript.md +++ b/components/ads-client/docs/usage-javascript.md @@ -247,7 +247,7 @@ Options passed when making a single ad request. * @typedef {Object} MozAdsRequestOptions * @property {MozAdsRequestCachePolicy|null} cachePolicy - Per-request caching policy. * @property {Object.} flags - Request-level flags forwarded as the `flags` object on the wire. An empty object omits it. - * @property {boolean} ohttp - Whether to route this request through OHTTP (default: false). + * @property {boolean} ohttp - Whether to route this request through OHTTP; the context id deletion request that may follow it uses the same setting and is skipped without OHTTP (default: false). * @property {Object.} relevance - Per client calculated relevance scores. An empty object omits it. Defaults to `{}`. */ ``` @@ -256,7 +256,7 @@ Options passed when making a single ad request. | ------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cachePolicy` | `MozAdsRequestCachePolicy \| null` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | | `flags` | `Object.` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty object omits it. e.g. `{ contextual_placement: true }`. Defaults to `{}`. | -| `ohttp` | `boolean` | Whether to route this request through OHTTP. Defaults to `false`. | +| `ohttp` | `boolean` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. | | `relevance` | `Object.` | Per client calculated relevance scores. An empty object omits it. Defaults to `{}`. --- diff --git a/components/ads-client/docs/usage-kotlin.md b/components/ads-client/docs/usage-kotlin.md index e6e9afcce8..e9d67e542d 100644 --- a/components/ads-client/docs/usage-kotlin.md +++ b/components/ads-client/docs/usage-kotlin.md @@ -230,7 +230,7 @@ data class MozAdsRequestOptions( | ------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | | `flags` | `Map` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty map omits it. e.g. `mapOf("contextual_placement" to true)`. Defaults to `emptyMap()`. | -| `ohttp` | `Boolean` | Whether to route this request through OHTTP. Defaults to `false`. | +| `ohttp` | `Boolean` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. | | `relevance` | `Map` | Per client calculated relevance scores. An empty object omits it. Defaults to `emptyMap()``. | --- diff --git a/components/ads-client/docs/usage-swift.md b/components/ads-client/docs/usage-swift.md index b2cdf78d94..ad22921f2c 100644 --- a/components/ads-client/docs/usage-swift.md +++ b/components/ads-client/docs/usage-swift.md @@ -230,7 +230,7 @@ struct MozAdsRequestOptions { | ------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `nil`, uses the client's default TTL with a `cacheFirst` mode. | | `flags` | `[String: Bool]` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty dictionary omits it. e.g. `["contextual_placement": true]`. Defaults to `[:]`. | -| `ohttp` | `Bool` | Whether to route this request through OHTTP. Defaults to `false`. | +| `ohttp` | `Bool` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. | | `relevance` | `[String: Double]` | Per client calculated relevance scores. An empty object omits it.Defaults to `[:]`. | --- diff --git a/components/ads-client/integration-tests/tests/mars.rs b/components/ads-client/integration-tests/tests/mars.rs index 77a01a09ba..513d1f0084 100644 --- a/components/ads-client/integration-tests/tests/mars.rs +++ b/components/ads-client/integration-tests/tests/mars.rs @@ -292,3 +292,54 @@ fn test_contract_tile_ohttp_prod() { "OHTTP response should contain mock_tile_1" ); } + +/// Sends the ads client's rotation-time `DELETE /v1/delete_user` through the +/// production OHTTP relay and gateway and expects a 200. Built with viaduct +/// directly because the MARS module is private. CI runs `--ignored`, so it only +/// writes to production when `ADS_CLIENT_PROD_DELETE_USER_CONTRACT` is set. +#[test] +#[ignore = "writes to production MARS: run manually with ADS_CLIENT_PROD_DELETE_USER_CONTRACT=1 -- --ignored --nocapture"] +fn test_contract_delete_user_ohttp_prod() { + if std::env::var_os("ADS_CLIENT_PROD_DELETE_USER_CONTRACT").is_none() { + eprintln!( + "skipping: set ADS_CLIENT_PROD_DELETE_USER_CONTRACT=1 to send a DELETE to production" + ); + return; + } + init_backend(); + viaduct::ohttp::configure_ohttp_channel( + "ads-client".to_string(), + viaduct::ohttp::OhttpConfig { + gateway_host: "prod.ohttp-gateway.prod.webservices.mozgcp.net".to_string(), + relay_url: "https://mozilla-ohttp.fastly-edge.com/".to_string(), + }, + ) + .expect("OHTTP channel configuration should succeed"); + + let settings = viaduct::ClientSettings { + timeout: 5_000, + ..viaduct::ClientSettings::default() + }; + let client = viaduct::Client::with_ohttp_channel("ads-client", settings) + .expect("ads-client OHTTP channel should be configured"); + let request = viaduct::Request::delete( + url::Url::parse("https://ads.mozilla.org/v1/delete_user").unwrap(), + ) + .json(&serde_json::json!({ + // Synthetic id never issued to a client, so this write touches no real data. + "context_id": "00000000-0000-4000-8000-0000000000ac" + })); + + let response = client + .send_sync(request) + .expect("delete_user over OHTTP should get an HTTP response"); + eprintln!( + "DELETE /v1/delete_user over OHTTP -> {} {}", + response.status, + response.text() + ); + assert_eq!( + response.status, 200, + "delete_user over OHTTP should reach MARS and succeed" + ); +} diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 8b645c5c97..62bdff9f6d 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -14,7 +14,8 @@ use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError} use crate::mars::{MARSClient, ReportReason}; use crate::telemetry::{self, ClientOperation}; use config::AdsClientConfig; -use context_id::{ContextIDComponent, DefaultContextIdCallback}; +use context_id::ContextIDComponent; +use context_id_deletion::ContextIdDeletionQueue; use error::RequestAdsError; use sql_support::open_database; use std::collections::HashMap; @@ -25,26 +26,34 @@ use url::Url; use uuid::Uuid; pub mod config; +mod context_id_deletion; pub mod error; const DEFAULT_TTL_SECONDS: u64 = 300; const DEFAULT_MAX_CACHE_SIZE_MIB: u64 = 10; const DEFAULT_ROTATION_DAYS: u8 = 3; +/// Passed as `running_in_test_automation` so `ContextIDComponent` never sends +/// its own `DELETE /delete_user`; `AdsClient` sends it instead. Firefox's +/// `ContextId.sys.mjs` does the same. +const DISABLE_CONTEXT_ID_COMPONENT_DELETION: bool = true; + pub struct AdsClient { #[cfg(feature = "stateful")] ads_store: Arc>, client: MARSClient, context_id_component: ContextIDComponent, + context_id_deletion_queue: ContextIdDeletionQueue, } impl AdsClient { pub fn new(client_config: AdsClientConfig) -> Self { + let context_id_deletion_queue = ContextIdDeletionQueue::default(); let context_id_component = ContextIDComponent::new( &Uuid::new_v4().to_string(), 0, - cfg!(test), - Box::new(DefaultContextIdCallback), + DISABLE_CONTEXT_ID_COMPONENT_DELETION, + Box::new(context_id_deletion_queue.clone()), ); let environment = client_config.environment; @@ -92,6 +101,7 @@ impl AdsClient { ads_store: Arc::new(ads_store), client, context_id_component, + context_id_deletion_queue, } } @@ -300,6 +310,22 @@ impl AdsClient { self.client.shutdown_db(); } + /// Best-effort: sends each retired id's deletion over OHTTP, or skips it when + /// the ad request did not use OHTTP. Failures are logged and not retried. + fn flush_context_id_deletions(&self, retired_context_ids: Vec, ohttp: bool) { + for old_context_id in retired_context_ids { + if !ohttp { + error_support::info!( + "Skipping context id deletion request: the ad request did not use OHTTP" + ); + continue; + } + if let Err(e) = self.client.delete_user(&old_context_id, true) { + error_support::warn!("Context id deletion request failed: {e}"); + } + } + } + fn request_ads( &self, placements: Vec, @@ -317,9 +343,12 @@ impl AdsClient { } else { None }; + // Take the retired ids now so they are flushed with this call's OHTTP flag. + // A concurrent call may take them first; nothing is ever sent outside OHTTP. + let retired_context_ids = self.context_id_deletion_queue.take_all(); let cache_policy = options.unwrap_or_default(); - let (mut response, request_hash) = self.client.fetch_ads::( + let result = self.client.fetch_ads::( context_id, flags, placements, @@ -327,7 +356,10 @@ impl AdsClient { ohttp, blocks, relevance, - )?; + ); + // Flush even if the fetch failed: the ids have already left the queue. + self.flush_context_id_deletions(retired_context_ids, ohttp); + let (mut response, request_hash) = result?; response.enrich_callbacks(&request_hash); Ok(response) } @@ -339,7 +371,7 @@ mod tests { #[cfg(feature = "stateful")] use crate::ads_store::builder::AdsStoreBuilder; use crate::{ - mars::Environment, + mars::{error::FetchAdsError, Environment}, test_utils::{ get_example_happy_image_response, get_example_happy_spoc_response, get_example_happy_uatile_response, make_happy_placement_requests, @@ -349,7 +381,17 @@ mod tests { use super::*; fn new_with_mars_client(client: MARSClient) -> AdsClient { - AdsClient { + new_with_mars_client_at(client, 0).0 + } + + /// Builds a client whose context id was created at `creation_timestamp_s` + /// (0 = now) and hands back the deletion queue shared with it. + fn new_with_mars_client_at( + client: MARSClient, + creation_timestamp_s: i64, + ) -> (AdsClient, ContextIdDeletionQueue) { + let context_id_deletion_queue = ContextIdDeletionQueue::default(); + let ads_client = AdsClient { #[cfg(feature = "stateful")] ads_store: Arc::new(Some( AdsStoreBuilder::new("test_store.db") @@ -359,11 +401,90 @@ mod tests { client, context_id_component: ContextIDComponent::new( &Uuid::new_v4().to_string(), - 0, - false, - Box::new(DefaultContextIdCallback), + creation_timestamp_s, + DISABLE_CONTEXT_ID_COMPONENT_DELETION, + Box::new(context_id_deletion_queue.clone()), ), - } + context_id_deletion_queue: context_id_deletion_queue.clone(), + }; + (ads_client, context_id_deletion_queue) + } + + fn thirty_days_ago_s() -> i64 { + (chrono::Utc::now() - chrono::Duration::days(30)).timestamp() + } + + #[test] + fn test_rotation_without_ohttp_skips_delete() { + viaduct_dev::init_backend_dev(); + + let expected_response = get_example_happy_uatile_response(); + let ads_mock = mockito::mock("POST", "/ads") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&expected_response.data).unwrap()) + .expect(1) + .create(); + let delete_mock = mockito::mock("DELETE", "/delete_user").expect(0).create(); + + let mars_client = MARSClient::new(Environment::Test, None); + let (ads_client, queue) = new_with_mars_client_at(mars_client, thirty_days_ago_s()); + + // A 30-day-old id rotates on first use and queues the old id. + ads_client.get_context_id().unwrap(); + assert!(!queue.is_empty()); + + // Without OHTTP the queue is drained and no deletion is sent. + let result = ads_client.request_tile_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + Default::default(), + AdRequestRelevance::default(), + ); + assert!(result.is_ok()); + ads_mock.assert(); + delete_mock.assert(); + assert!(queue.is_empty()); + } + + #[test] + fn test_rotation_flush_runs_even_when_fetch_fails() { + viaduct_dev::init_backend_dev(); + + // OHTTP without a configured channel: preflight succeeds, the ad fetch fails, + // and the queue is still drained without sending a deletion. + let preflight_mock = mockito::mock("GET", "/ads-preflight") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"geo_location":"US-CA","normalized_ua":"Firefox/140.0"}"#) + .expect(1) + .create(); + let delete_mock = mockito::mock("DELETE", "/delete_user").expect(0).create(); + + let mars_client = MARSClient::new(Environment::Test, None); + let (ads_client, queue) = new_with_mars_client_at(mars_client, thirty_days_ago_s()); + ads_client.get_context_id().unwrap(); + assert!(!queue.is_empty()); + + let result = ads_client.request_tile_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + true, + Default::default(), + AdRequestRelevance::default(), + ); + assert!(matches!( + result, + Err(RequestAdsError::FetchAds(FetchAdsError::Request( + viaduct::ViaductError::OhttpChannelNotConfigured(_) + ))) + )); + preflight_mock.assert(); + delete_mock.assert(); + assert!(queue.is_empty()); } #[test] diff --git a/components/ads-client/src/client/context_id_deletion.rs b/components/ads-client/src/client/context_id_deletion.rs new file mode 100644 index 0000000000..b5fd0ac668 --- /dev/null +++ b/components/ads-client/src/client/context_id_deletion.rs @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use std::sync::Arc; + +use context_id::ContextIdCallback; +use parking_lot::Mutex; + +/// `ContextIdCallback` that queues retired context ids. The component calls +/// `rotated` under its own lock and without the request's OHTTP flag, so +/// `AdsClient` drains the queue in `request_ads` and sends the deletions itself. +/// +/// Clones share one queue. `persist` is a no-op because the ads client does +/// not persist its context id. +#[derive(Clone, Debug, Default)] +pub struct ContextIdDeletionQueue(Arc>>); + +impl ContextIdDeletionQueue { + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.0.lock().is_empty() + } + + pub fn take_all(&self) -> Vec { + std::mem::take(&mut *self.0.lock()) + } +} + +impl ContextIdCallback for ContextIdDeletionQueue { + fn persist(&self, _context_id: String, _creation_date: i64) {} + + fn rotated(&self, old_context_id: String) { + self.0.lock().push(old_context_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use context_id::ContextIdCallback; + + #[test] + fn test_rotated_ids_are_queued_until_taken() { + let queue = ContextIdDeletionQueue::default(); + assert!(queue.is_empty()); + + queue.rotated("old-1".to_string()); + queue.rotated("old-2".to_string()); + assert!(!queue.is_empty()); + + assert_eq!( + queue.take_all(), + vec!["old-1".to_string(), "old-2".to_string()] + ); + assert!(queue.is_empty()); + assert!(queue.take_all().is_empty()); + } + + #[test] + fn test_persist_is_a_no_op() { + let queue = ContextIdDeletionQueue::default(); + queue.persist("id".to_string(), 0); + assert!(queue.is_empty()); + } + + #[test] + fn test_clones_share_the_queue() { + let queue = ContextIdDeletionQueue::default(); + let callback = queue.clone(); + callback.rotated("old".to_string()); + assert_eq!(queue.take_all(), vec!["old".to_string()]); + } +} diff --git a/components/ads-client/src/mars.rs b/components/ads-client/src/mars.rs index 03dc3872f2..42a07d1c5c 100644 --- a/components/ads-client/src/mars.rs +++ b/components/ads-client/src/mars.rs @@ -5,6 +5,7 @@ pub mod ad_request; pub mod ad_response; +mod delete_user; pub mod environment; pub mod error; mod preflight; @@ -18,8 +19,10 @@ use sql_support::open_database; use self::{ ad_request::{AdPlacementRequest, AdRequest, AdRequestFlags, AdRequestRelevance}, ad_response::{AdResponse, AdResponseValue}, + delete_user::DeleteUserRequest, error::{ - CallbackRequestError, FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError, + CallbackRequestError, DeleteUserError, FetchAdsError, RecordClickError, + RecordImpressionError, ReportAdError, }, preflight::PreflightRequest, transport::MARSTransport, @@ -37,6 +40,10 @@ use std::collections::HashMap; use url::Url; use viaduct::{Headers, Request}; +/// Timeout for the best-effort deletion request, which runs inside an ad request. +/// Over OHTTP it bounds the relay request; the gateway key fetch keeps its own. +const DELETE_USER_TIMEOUT_MS: u32 = 5_000; + pub struct MARSClient { environment: Environment, transport: MARSTransport, @@ -54,6 +61,15 @@ impl MARSClient { self.transport.clear_cache() } + /// Sends `DELETE /delete_user` for `context_id`, over OHTTP when `ohttp` is set. + pub fn delete_user(&self, context_id: &str, ohttp: bool) -> Result<(), DeleteUserError> { + let request = Request::delete(self.environment.clone().into_url("delete_user")) + .json(&DeleteUserRequest { context_id }); + self.transport + .fire_with_timeout(request, ohttp, DELETE_USER_TIMEOUT_MS) + .map_err(Into::into) + } + #[allow(clippy::too_many_arguments)] pub fn fetch_ads( &self, @@ -416,4 +432,41 @@ mod tests { assert!(result.is_ok()); m.assert(); } + + #[test] + fn test_delete_user_sends_json_delete() { + viaduct_dev::init_backend_dev(); + let m = mock("DELETE", "/delete_user") + .match_header("content-type", "application/json") + .match_body(mockito::Matcher::Json( + serde_json::json!({ "context_id": TEST_CONTEXT_ID }), + )) + .with_status(200) + .expect(1) + .create(); + + let client = make_test_client(None); + let result = client.delete_user(TEST_CONTEXT_ID, false); + + assert!(result.is_ok(), "unexpected error: {result:?}"); + m.assert(); + } + + #[test] + fn test_delete_user_without_ohttp_channel_fails_closed() { + viaduct_dev::init_backend_dev(); + // No channel configured: fail closed, never fall back to a direct DELETE. + let m = mock("DELETE", "/delete_user").expect(0).create(); + + let client = make_test_client(None); + let result = client.delete_user(TEST_CONTEXT_ID, true); + + assert!(matches!( + result, + Err(DeleteUserError::Request( + viaduct::ViaductError::OhttpChannelNotConfigured(ref channel) + )) if channel == "ads-client" + )); + m.assert(); + } } diff --git a/components/ads-client/src/mars/delete_user.rs b/components/ads-client/src/mars/delete_user.rs new file mode 100644 index 0000000000..ef80136a86 --- /dev/null +++ b/components/ads-client/src/mars/delete_user.rs @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use serde::Serialize; + +/// Request body for the MARS `/v1/delete_user` endpoint. +#[derive(Debug, Serialize)] +pub struct DeleteUserRequest<'a> { + pub context_id: &'a str, +} diff --git a/components/ads-client/src/mars/error.rs b/components/ads-client/src/mars/error.rs index 5cd9cf13df..be223f3f72 100644 --- a/components/ads-client/src/mars/error.rs +++ b/components/ads-client/src/mars/error.rs @@ -30,6 +30,18 @@ pub enum CallbackRequestError { Request(#[from] viaduct::ViaductError), } +#[derive(Debug, thiserror::Error)] +pub enum DeleteUserError { + #[error("Context id deletion request failed, MARS responded with: {0}")] + HTTPError(#[from] HTTPError), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Error sending request: {0}")] + Request(#[from] viaduct::ViaductError), +} + #[derive(Debug, thiserror::Error)] pub enum FetchAdsError { #[error("Error building ad request: {0}")] @@ -132,6 +144,16 @@ impl From for FetchAdsError { } } +impl From for DeleteUserError { + fn from(err: TransportError) -> Self { + match err { + TransportError::Http(e) => Self::HTTPError(e), + TransportError::Json(e) => Self::Json(e), + TransportError::Request(e) => Self::Request(e), + } + } +} + impl From for CallbackRequestError { fn from(err: TransportError) -> Self { match err { diff --git a/components/ads-client/src/mars/transport.rs b/components/ads-client/src/mars/transport.rs index 153beb1476..b68fc824b3 100644 --- a/components/ads-client/src/mars/transport.rs +++ b/components/ads-client/src/mars/transport.rs @@ -33,10 +33,20 @@ impl MARSTransport { } pub fn fire(&self, request: Request, ohttp: bool) -> Result<(), TransportError> { - let client = Self::client_for(ohttp)?; - let response = client.send_sync(request)?; - HTTPError::check(&response)?; - Ok(()) + self.fire_with_settings(request, ohttp, ClientSettings::default()) + } + + pub fn fire_with_timeout( + &self, + request: Request, + ohttp: bool, + timeout_ms: u32, + ) -> Result<(), TransportError> { + let settings = ClientSettings { + timeout: timeout_ms, + ..ClientSettings::default() + }; + self.fire_with_settings(request, ohttp, settings) } #[allow(dead_code)] @@ -56,7 +66,7 @@ impl MARSTransport { policy: &CachePolicy, ohttp: bool, ) -> Result { - let client = Self::client_for(ohttp)?; + let client = Self::client_for(ohttp, ClientSettings::default())?; if let Some(cache) = self.http_cache.as_ref() { let (response, outcomes) = cache.send_with_policy(&client, request, policy)?; for outcome in &outcomes { @@ -77,11 +87,23 @@ impl MARSTransport { } } - fn client_for(ohttp: bool) -> Result { + fn client_for(ohttp: bool, settings: ClientSettings) -> Result { if ohttp { - Client::with_ohttp_channel(OHTTP_CHANNEL_ID, ClientSettings::default()) + Client::with_ohttp_channel(OHTTP_CHANNEL_ID, settings) } else { - Ok(Client::new(ClientSettings::default())) + Ok(Client::new(settings)) } } + + fn fire_with_settings( + &self, + request: Request, + ohttp: bool, + settings: ClientSettings, + ) -> Result<(), TransportError> { + let client = Self::client_for(ohttp, settings)?; + let response = client.send_sync(request)?; + HTTPError::check(&response)?; + Ok(()) + } }