use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use reqwest::{Method, Url};
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::{from_response, ScrapflyError};
#[derive(Debug, Clone, Default, Serialize)]
pub struct BrowserConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_pool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
#[serde(skip_serializing_if = "is_false")]
pub block_images: bool,
#[serde(skip_serializing_if = "is_false")]
pub block_styles: bool,
#[serde(skip_serializing_if = "is_false")]
pub block_fonts: bool,
#[serde(skip_serializing_if = "is_false")]
pub block_media: bool,
#[serde(skip_serializing_if = "is_false")]
pub screenshot: bool,
#[serde(skip_serializing_if = "is_false")]
pub cache: bool,
#[serde(skip_serializing_if = "is_false")]
pub blacklist: bool,
#[serde(skip_serializing_if = "is_false")]
pub debug: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_brand: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byop_proxy: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub enable_mcp: bool,
#[serde(skip_serializing_if = "is_false")]
pub solve_captcha: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub vault: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vault_key: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub enable_vnc: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub vnc_password: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub enable_rtc: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rtc_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rtc_password: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hitl_allowed_networks: Vec<String>,
}
pub fn project_salt(api_key: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(api_key.as_bytes());
hex::encode(&digest[..4])
}
fn is_false(v: &bool) -> bool {
!*v
}
fn ws_base(host: &str) -> String {
if let Some(rest) = host.strip_prefix("wss://") {
format!("wss://{}", rest)
} else if let Some(rest) = host.strip_prefix("ws://") {
format!("ws://{}", rest)
} else if let Some(rest) = host.strip_prefix("https://") {
format!("wss://{}", rest)
} else if let Some(rest) = host.strip_prefix("http://") {
format!("ws://{}", rest)
} else {
format!("wss://{}", host)
}
}
fn rest_base(host: &str) -> String {
if let Some(rest) = host.strip_prefix("wss://") {
format!("https://{}", rest)
} else if let Some(rest) = host.strip_prefix("ws://") {
format!("http://{}", rest)
} else if host.starts_with("https://") || host.starts_with("http://") {
host.to_string()
} else {
format!("https://{}", host)
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct UnblockConfig {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_brand: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_timeout: Option<u32>,
#[serde(skip_serializing_if = "is_false")]
pub enable_mcp: bool,
#[serde(skip_serializing_if = "is_false")]
pub debug: bool,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct UnblockResult {
#[serde(default)]
pub ws_url: String,
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub run_id: String,
#[serde(default)]
pub mcp_endpoint: String,
}
impl Client {
pub fn cloud_browser_url(&self, config: &BrowserConfig) -> String {
let ws_host = ws_base(self.cloud_browser_host());
let mut pairs: Vec<(String, String)> = vec![("api_key".into(), self.api_key().into())];
if let Some(v) = &config.proxy_pool {
pairs.push(("proxy_pool".into(), v.clone()));
}
if let Some(v) = &config.os {
pairs.push(("os".into(), v.clone()));
}
if let Some(v) = &config.country {
pairs.push(("country".into(), v.clone()));
}
if let Some(v) = &config.session {
pairs.push(("session".into(), v.clone()));
}
if let Some(v) = config.timeout {
pairs.push(("timeout".into(), v.to_string()));
}
if config.block_images {
pairs.push(("block_images".into(), "true".into()));
}
if config.block_styles {
pairs.push(("block_styles".into(), "true".into()));
}
if config.block_fonts {
pairs.push(("block_fonts".into(), "true".into()));
}
if config.block_media {
pairs.push(("block_media".into(), "true".into()));
}
if config.screenshot {
pairs.push(("screenshot".into(), "true".into()));
}
if config.cache {
pairs.push(("cache".into(), "true".into()));
}
if config.blacklist {
pairs.push(("blacklist".into(), "true".into()));
}
if config.debug {
pairs.push(("debug".into(), "true".into()));
}
if let Some(v) = &config.resolution {
pairs.push(("resolution".into(), v.clone()));
}
if let Some(v) = &config.browser_brand {
pairs.push(("browser_brand".into(), v.clone()));
}
if let Some(v) = &config.byop_proxy {
pairs.push(("byop_proxy".into(), v.clone()));
}
if config.enable_mcp {
pairs.push(("enable_mcp".into(), "true".into()));
}
if config.solve_captcha {
pairs.push(("solve_captcha".into(), "true".into()));
}
if let Some(v) = &config.vault {
pairs.push(("vault".into(), v.clone()));
}
if let Some(v) = &config.vault_key {
pairs.push(("vault_key".into(), v.clone()));
}
if config.enable_vnc {
pairs.push(("enable_vnc".into(), "true".into()));
}
if let Some(v) = &config.vnc_password {
pairs.push(("vnc_password".into(), v.clone()));
}
if config.enable_rtc {
pairs.push(("enable_rtc".into(), "true".into()));
}
if let Some(v) = &config.rtc_username {
pairs.push(("rtc_username".into(), v.clone()));
}
if let Some(v) = &config.rtc_password {
pairs.push(("rtc_password".into(), v.clone()));
}
if !config.hitl_allowed_networks.is_empty() {
pairs.push((
"hitl_allowed_networks".into(),
config.hitl_allowed_networks.join(","),
));
}
let qs = serde_urlencoded::to_string(&pairs).unwrap_or_default();
format!("{}?{}", ws_host, qs)
}
pub fn cloud_browser_project_salt(&self) -> String {
project_salt(self.api_key())
}
pub async fn cloud_browser_unblock(
&self,
config: &UnblockConfig,
) -> Result<UnblockResult, ScrapflyError> {
let url = format!(
"{}/unblock?key={}",
rest_base(self.cloud_browser_host()),
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid unblock url: {}", e)))?;
let body = serde_json::to_vec(config)?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let resp = self
.send_with_retry(Method::POST, url, Some(headers), Some(body))
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_extension_list(&self) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/extension?key={}",
rest_base(self.cloud_browser_host()),
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_extension_get(
&self,
extension_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/extension/{}?key={}",
rest_base(self.cloud_browser_host()),
extension_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_extension_upload(
&self,
file_path: &std::path::Path,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/extension?key={}",
rest_base(self.cloud_browser_host()),
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
let file_bytes = std::fs::read(file_path)
.map_err(|e| ScrapflyError::Config(format!("failed to read extension file: {}", e)))?;
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("extension.zip")
.to_string();
let boundary = format!(
"----ScrapflyBoundary{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
let mut body = Vec::new();
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n\
Content-Type: application/octet-stream\r\n\r\n",
file_name
)
.as_bytes(),
);
body.extend_from_slice(&file_bytes);
body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(&format!("multipart/form-data; boundary={}", boundary))
.map_err(|e| ScrapflyError::Config(format!("invalid content-type: {}", e)))?,
);
let resp = self
.send_with_retry(Method::POST, url, Some(headers), Some(body))
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 && status != 201 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_extension_delete(
&self,
extension_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/extension/{}?key={}",
rest_base(self.cloud_browser_host()),
extension_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
let resp = self
.send_with_retry(Method::DELETE, url, None, None)
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_create(
&self,
name: &str,
description: Option<&str>,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault?key={}",
rest_base(self.cloud_browser_host()),
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let body = serde_json::json!({
"name": name,
"description": description.unwrap_or(""),
});
let body_bytes = serde_json::to_vec(&body)?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let resp = self
.send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 && status != 201 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_list(&self) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault?key={}",
rest_base(self.cloud_browser_host()),
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_get(
&self,
vault_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_update(
&self,
vault_id: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let mut body_map = serde_json::Map::new();
if let Some(v) = name {
body_map.insert("name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = description {
body_map.insert("description".into(), serde_json::Value::String(v.into()));
}
let body_bytes = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let resp = self
.send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_delete(
&self,
vault_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let resp = self
.send_with_retry(Method::DELETE, url, None, None)
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_rotate(
&self,
vault_id: &str,
current_vault_key: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}/rotate?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let mut headers = HeaderMap::new();
headers.insert(
"X-Vault-Key",
HeaderValue::from_str(current_vault_key)
.map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
);
let resp = self
.send_with_retry(Method::POST, url, Some(headers), None)
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_item_list(
&self,
vault_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}/item?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_item_create(
&self,
vault_id: &str,
vault_key: &str,
item: serde_json::Value,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}/item?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let body_bytes = serde_json::to_vec(&item)?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
"X-Vault-Key",
HeaderValue::from_str(vault_key)
.map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
);
let resp = self
.send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 && status != 201 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_item_update(
&self,
vault_id: &str,
item_id: &str,
vault_key: Option<&str>,
patch: serde_json::Value,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}/item/{}?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
item_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let body_bytes = serde_json::to_vec(&patch)?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if let Some(k) = vault_key {
headers.insert(
"X-Vault-Key",
HeaderValue::from_str(k).map_err(|_| {
ScrapflyError::Config("X-Vault-Key contained invalid bytes".into())
})?,
);
}
let resp = self
.send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_vault_item_delete(
&self,
vault_id: &str,
item_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/vault/{}/item/{}?key={}",
rest_base(self.cloud_browser_host()),
vault_id,
item_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
let resp = self
.send_with_retry(Method::DELETE, url, None, None)
.await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_playback(
&self,
run_id: &str,
) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/run/{}/playback?key={}",
rest_base(self.cloud_browser_host()),
run_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid playback url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_wait_for_playback(
&self,
run_id: &str,
timeout: std::time::Duration,
fallback_interval: std::time::Duration,
) -> Result<serde_json::Value, ScrapflyError> {
let deadline = std::time::Instant::now() + timeout;
loop {
let playback = self.cloud_browser_playback(run_id).await?;
let status = playback
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
if status != "uploading" {
return Ok(playback);
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Ok(playback);
}
let retry_after_ms = playback
.get("retry_after_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let next = if retry_after_ms > 0 {
std::time::Duration::from_millis(retry_after_ms)
} else {
fallback_interval
};
tokio::time::sleep(next.min(remaining)).await;
}
}
pub async fn cloud_browser_session_stop(&self, session_id: &str) -> Result<(), ScrapflyError> {
if session_id.is_empty() {
return Err(ScrapflyError::Config("session_id is required".into()));
}
let url = format!(
"{}/session/{}/stop?key={}",
rest_base(self.cloud_browser_host()),
session_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid session url: {}", e)))?;
let resp = self.send_with_retry(Method::POST, url, None, None).await?;
let status = resp.status().as_u16();
if status != 200 {
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
return Err(from_response(status, &body, 0, false));
}
Ok(())
}
pub async fn cloud_browser_sessions(&self) -> Result<serde_json::Value, ScrapflyError> {
let url = format!(
"{}/sessions?key={}",
rest_base(self.cloud_browser_host()),
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid sessions url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(serde_json::from_slice(&body)?)
}
pub async fn cloud_browser_video(&self, run_id: &str) -> Result<Vec<u8>, ScrapflyError> {
let url = format!(
"{}/run/{}/video?key={}",
rest_base(self.cloud_browser_host()),
run_id,
self.api_key()
);
let url = Url::parse(&url)
.map_err(|e| ScrapflyError::Config(format!("invalid video url: {}", e)))?;
let resp = self.send_with_retry(Method::GET, url, None, None).await?;
let status = resp.status().as_u16();
let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
if status != 200 {
return Err(from_response(status, &body, 0, false));
}
Ok(body.to_vec())
}
}