use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
use progenitor_client::ClientInfo;
use reqwest::header::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use crate::api;
use crate::errors::{Error, Result};
use crate::retry::{with_backoff, RetryPolicy};
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
pub struct TaskFastClient {
inner: api::Client,
policy: RetryPolicy,
network_cfg: OnceCell<NetworkConfigResponse>,
}
impl TaskFastClient {
pub fn from_api_key(base_url: &str, api_key: &str) -> Result<Self> {
let mut value = HeaderValue::from_str(api_key)
.map_err(|_| Error::Auth("api key contains invalid header bytes".into()))?;
value.set_sensitive(true);
let mut headers = HeaderMap::new();
headers.insert("X-API-Key", value);
let http = reqwest::ClientBuilder::new()
.default_headers(headers)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.timeout(DEFAULT_REQUEST_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resolved = base_url.trim_end_matches('/').to_owned();
Ok(Self {
inner: api::Client::new_with_client(&resolved, http),
policy: RetryPolicy::default(),
network_cfg: OnceCell::new(),
})
}
pub fn http_client(&self) -> reqwest::Client {
self.inner.client().clone()
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.policy = policy;
self
}
pub fn inner(&self) -> &api::Client {
&self.inner
}
pub fn retry_policy(&self) -> RetryPolicy {
self.policy
}
pub async fn call_with_retry<T, F, Fut>(&self, op: F) -> Result<T>
where
F: FnMut(u32) -> Fut,
Fut: Future<Output = Result<T>>,
{
with_backoff(self.policy, op).await
}
pub async fn get_user_profile(&self) -> Result<Option<UserProfile>> {
let url = format!("{}/users/me", self.inner.baseurl());
let resp = self.inner.client().get(url).send().await?;
let status = resp.status();
if status.is_success() {
let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
return Ok(Some(serde_json::from_slice::<UserProfile>(&bytes)?));
}
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
Err(classify_response(resp).await)
}
pub async fn upload_artifact(
&self,
task_id: &uuid::Uuid,
filename: String,
content_type: String,
bytes: Vec<u8>,
) -> Result<api::types::Artifact> {
let url = format!("{}/tasks/{}/artifacts", self.inner.baseurl(), task_id);
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(filename)
.mime_str(&content_type)
.map_err(|e| Error::Server(format!("invalid content-type: {e}")))?;
let form = reqwest::multipart::Form::new().part("file", part);
let resp = self.inner.client().post(url).multipart(form).send().await?;
if resp.status().is_success() {
let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
Ok(serde_json::from_slice::<api::types::Artifact>(&bytes)?)
} else {
Err(classify_response(resp).await)
}
}
pub async fn fetch_network_config(&self) -> Result<&NetworkConfigResponse> {
self.network_cfg
.get_or_try_init(|| async {
let url = format!("{}/config/network", self.inner.baseurl());
let resp = self.inner.client().get(url).send().await?;
if !resp.status().is_success() {
return Err(classify_response(resp).await);
}
let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
Ok(serde_json::from_slice::<NetworkConfigResponse>(&bytes)?)
})
.await
}
pub async fn post_json_rpc(
&self,
network: &str,
request: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = format!("{}/rpc/{}", self.inner.baseurl(), network);
let resp = self.inner.client().post(url).json(request).send().await?;
if !resp.status().is_success() {
return Err(classify_response(resp).await);
}
let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
}
pub async fn list_agent_events_raw(
&self,
cursor: Option<&str>,
limit: Option<i64>,
) -> Result<serde_json::Value> {
let url = format!("{}/agents/me/events", self.inner.baseurl());
let mut req = self.inner.client().get(url);
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(c) = cursor {
query.push(("cursor", c.to_string()));
}
if let Some(l) = limit {
query.push(("limit", l.to_string()));
}
if !query.is_empty() {
req = req.query(&query);
}
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(classify_response(resp).await);
}
let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
}
}
async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> Result<Vec<u8>> {
crate::corr::record_corr_response(&resp);
let mut buf = Vec::new();
while let Some(chunk) = resp.chunk().await? {
if buf.len().saturating_add(chunk.len()) > cap {
return Err(Error::Server(format!(
"response body exceeds {cap}-byte safety cap"
)));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
pub async fn map_api_error(err: api::Error<()>) -> Error {
use api::Error as AE;
match err {
AE::InvalidRequest(m) => Error::Server(format!("invalid request: {m}")),
AE::CommunicationError(e) => Error::Network(e),
AE::InvalidUpgrade(e) => Error::Network(e),
AE::ResponseBodyError(e) => Error::Network(e),
AE::InvalidResponsePayload(_, e) => Error::Decode(e),
AE::UnexpectedResponse(resp) => classify_response(resp).await,
AE::ErrorResponse(_) => {
Error::Server(
"progenitor emitted typed ErrorResponse for E=() — spec normalization regression"
.into(),
)
}
AE::Custom(m) => Error::Server(format!("hook: {m}")),
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NetworkConfigResponse {
pub networks: HashMap<String, NetworkConfigEntry>,
}
impl NetworkConfigResponse {
pub fn entry(&self, network: &str) -> Result<&NetworkConfigEntry> {
self.networks.get(network).ok_or_else(|| Error::Validation {
code: "unknown_network".to_string(),
message: format!("deployment does not advertise network `{network}`"),
})
}
pub fn entry_by_chain_id(&self, chain_id: i64) -> Result<(&str, &NetworkConfigEntry)> {
self.networks
.iter()
.find(|(_, e)| e.chain_id == chain_id)
.map(|(name, entry)| (name.as_str(), entry))
.ok_or_else(|| Error::Validation {
code: "unknown_chain_id".to_string(),
message: format!("no network in /config/network advertises chain_id={chain_id}"),
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NetworkConfigEntry {
pub chain_id: i64,
pub rpc_url: String,
pub wss_url: String,
pub explorer_url: String,
pub default_stablecoin: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserProfile {
pub name: String,
pub email: String,
}
#[derive(Debug, Deserialize)]
struct ErrorBody {
#[serde(default)]
error: String,
#[serde(default)]
message: String,
}
async fn classify_response(resp: reqwest::Response) -> Error {
let status = resp.status();
let code = status.as_u16();
let retry_after = parse_retry_after(&resp);
let body = match read_body_capped(resp, 64 * 1024).await {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
tracing::warn!(error = %e.kind(), %code, "classify_response: failed to read body");
String::new()
}
};
let parsed: ErrorBody = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
if !body.is_empty() {
tracing::warn!(error = %e, %code, "classify_response: body is not JSON; using raw text");
}
ErrorBody {
error: String::new(),
message: body,
}
}
};
match code {
401 | 403 => Error::Auth(format_status(
code,
display_or_status(&parsed, status.as_str()),
)),
422 => Error::Validation {
code: or_default(&parsed.error, "validation_error"),
message: or_default(&parsed.message, "request body failed validation"),
},
429 => Error::RateLimited {
retry_after: retry_after.unwrap_or(Duration::from_secs(1)),
},
500..=599 => Error::Server(format_status(
code,
display_or_status(&parsed, status.as_str()),
)),
400..=499 => Error::Validation {
code: or_default(&parsed.error, status.as_str()),
message: or_default(
&parsed.message,
status.canonical_reason().unwrap_or("client error"),
),
},
_ => Error::Server(format_status(
code,
display_or_status(&parsed, status.as_str()),
)),
}
}
fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> {
let v = resp.headers().get(reqwest::header::RETRY_AFTER)?;
let s = v.to_str().ok()?.trim();
s.parse::<u64>().ok().map(Duration::from_secs)
}
fn or_default(s: &str, fallback: &str) -> String {
if s.is_empty() {
fallback.to_string()
} else {
s.to_string()
}
}
fn display_or_status<'a>(body: &'a ErrorBody, status: &'a str) -> &'a str {
if !body.message.is_empty() {
&body.message
} else if !body.error.is_empty() {
&body.error
} else {
status
}
}
fn format_status(code: u16, detail: &str) -> String {
format!("HTTP {code}: {detail}")
}