use std::time::Duration;
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::sessions::Sessions;
use crate::types::{
AgenticSearchResult, CrawlResult, Document, MapResult, SearchResult, WireResult,
};
pub const VERSION: &str = "0.1.0";
const DEFAULT_BASE_URL: &str = "https://api.anakin.io/v1";
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
api_key: String,
base_url: String,
max_retries: u32,
poll_interval: Duration,
poll_max_interval: Duration,
poll_timeout: Duration,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.base_url)
.field("max_retries", &self.max_retries)
.field("poll_interval", &self.poll_interval)
.field("poll_max_interval", &self.poll_max_interval)
.field("poll_timeout", &self.poll_timeout)
.finish_non_exhaustive()
}
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn sessions(&self) -> Sessions<'_> {
Sessions::new(self)
}
pub async fn scrape(&self, url: &str) -> Result<Document> {
self.scrape_with(url, None).await
}
pub async fn scrape_with(&self, url: &str, opts: Option<Value>) -> Result<Document> {
let body = build_body(json!({ "url": url }), opts);
let submit: Value = self.send_json(reqwest::Method::POST, "/url-scraper", Some(body)).await?;
let job_id = require_string(&submit, "job_id")?;
let poll = self.poll_job(&format!("/url-scraper/{job_id}")).await?;
decode_field(&poll, "result")
}
pub async fn map(&self, url: &str) -> Result<MapResult> {
self.map_with(url, None).await
}
pub async fn map_with(&self, url: &str, opts: Option<Value>) -> Result<MapResult> {
let body = build_body(json!({ "url": url }), opts);
let submit: Value = self.send_json(reqwest::Method::POST, "/map", Some(body)).await?;
let job_id = require_string(&submit, "job_id")?;
let poll = self.poll_job(&format!("/map/{job_id}")).await?;
decode_field(&poll, "result")
}
pub async fn crawl(&self, url: &str) -> Result<CrawlResult> {
self.crawl_with(url, None).await
}
pub async fn crawl_with(&self, url: &str, opts: Option<Value>) -> Result<CrawlResult> {
let body = build_body(json!({ "url": url }), opts);
let submit: Value = self.send_json(reqwest::Method::POST, "/crawl", Some(body)).await?;
let job_id = require_string(&submit, "job_id")?;
let poll = self.poll_job(&format!("/crawl/{job_id}")).await?;
decode_field(&poll, "result")
}
pub async fn search(&self, query: &str) -> Result<SearchResult> {
self.search_with(query, None).await
}
pub async fn search_with(&self, query: &str, opts: Option<Value>) -> Result<SearchResult> {
let body = build_body(json!({ "prompt": query }), opts);
let v: Value = self.send_json(reqwest::Method::POST, "/search", Some(body)).await?;
serde_json::from_value(v).map_err(|e| Error::Other(format!("decode response: {e}")))
}
pub async fn agentic_search(&self, prompt: &str) -> Result<AgenticSearchResult> {
self.agentic_search_with(prompt, None).await
}
pub async fn agentic_search_with(&self, prompt: &str, opts: Option<Value>) -> Result<AgenticSearchResult> {
let body = build_body(json!({ "prompt": prompt }), opts);
let submit: Value = self.send_json(reqwest::Method::POST, "/agentic-search", Some(body)).await?;
let job_id = require_string(&submit, "job_id")?;
let poll = self.poll_job(&format!("/agentic-search/{job_id}")).await?;
decode_field(&poll, "result")
}
pub async fn wire(&self, action_id: &str, params: Option<Value>) -> Result<WireResult> {
let mut body = json!({ "action_id": action_id });
if let Some(p) = params {
body["params"] = p;
}
let submit: Value = self.send_json(reqwest::Method::POST, "/holocron/task", Some(body)).await?;
let job_id = require_string(&submit, "job_id")?;
let poll = self.poll_job(&format!("/holocron/task/{job_id}")).await?;
decode_field(&poll, "result")
}
pub(crate) async fn send_json(
&self,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> Result<Value> {
let url = format!("{}{}", self.base_url, path);
let mut last_resp: Option<reqwest::Response> = None;
for attempt in 0..=self.max_retries {
if attempt > 0 {
let delay = backoff(attempt, last_resp.as_ref());
tokio::time::sleep(delay).await;
}
let mut req = self
.http
.request(method.clone(), &url)
.header("X-API-Key", &self.api_key)
.header("Accept", "application/json")
.header("User-Agent", format!("anakin-rust/{VERSION}"));
if let Some(ref b) = body {
req = req.json(b);
}
let resp = match req.send().await {
Ok(r) => r,
Err(e) => {
if attempt == self.max_retries {
return Err(Error::Network {
message: format!(
"http request after {} retries: {}",
self.max_retries, e
),
source: Some(Box::new(e)),
});
}
continue;
}
};
let status = resp.status().as_u16();
if should_retry(status) && attempt < self.max_retries {
last_resp = Some(resp);
continue;
}
return self.handle_response(resp).await;
}
Err(Error::Other("retry loop exited unexpectedly".into()))
}
async fn handle_response(&self, resp: reqwest::Response) -> Result<Value> {
let status = resp.status();
let retry_after_header = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if status.is_success() {
let bytes = resp.bytes().await.map_err(|e| Error::Network {
message: format!("read response: {e}"),
source: Some(Box::new(e)),
})?;
if bytes.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
return serde_json::from_slice(&bytes)
.map_err(|e| Error::Other(format!("decode response: {e}")));
}
let body_bytes = resp.bytes().await.unwrap_or_default();
Err(map_error(status.as_u16(), retry_after_header.as_deref(), &body_bytes))
}
async fn poll_job(&self, path: &str) -> Result<Value> {
let deadline = std::time::Instant::now() + self.poll_timeout;
let mut delay = self.poll_interval;
loop {
let v = self.send_json(reqwest::Method::GET, path, None).await?;
let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
let error = v.get("error").and_then(|s| s.as_str()).unwrap_or("");
let job_id = v.get("job_id").and_then(|s| s.as_str()).map(|s| s.to_string());
if status == "completed" || status == "succeeded" {
return Ok(v);
}
if status == "failed" {
return Err(Error::JobFailed {
job_id,
reason: error.to_string(),
});
}
if std::time::Instant::now() > deadline {
return Err(Error::JobTimeout {
job_id,
elapsed: self.poll_timeout,
});
}
tokio::time::sleep(delay).await;
let next_ms = (delay.as_millis() as f64 * 1.5) as u64;
let capped = std::cmp::min(next_ms, self.poll_max_interval.as_millis() as u64);
delay = Duration::from_millis(capped);
}
}
}
fn build_body(base: Value, extra: Option<Value>) -> Value {
let mut obj = base.as_object().cloned().unwrap_or_default();
if let Some(Value::Object(map)) = extra {
for (k, v) in map {
obj.insert(k, v);
}
}
Value::Object(obj)
}
fn should_retry(status: u16) -> bool {
status == 429 || (500..600).contains(&status)
}
fn backoff(attempt: u32, prev: Option<&reqwest::Response>) -> Duration {
if let Some(r) = prev {
if let Some(ra) = r
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
{
if ra > 0 {
return Duration::from_secs(ra);
}
}
}
let ms = (2_u64.saturating_pow(attempt.saturating_sub(1))) * 500;
Duration::from_millis(std::cmp::min(ms, 30_000))
}
fn parse_retry_after(header: Option<&str>) -> Duration {
match header {
Some(s) => match s.trim().parse::<u64>() {
Ok(n) => Duration::from_secs(n),
Err(_) => Duration::ZERO,
},
None => Duration::ZERO,
}
}
fn map_error(status: u16, retry_after_header: Option<&str>, body: &[u8]) -> Error {
let parsed: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let message = parsed
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let code = parsed
.get("code")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let balance = parsed.get("balance").and_then(|v| v.as_i64()).unwrap_or(0);
let required = parsed.get("required").and_then(|v| v.as_i64()).unwrap_or(0);
let message = if message.is_empty() {
format!("HTTP {status}")
} else {
message
};
match status {
400 => Error::InvalidRequest { message, status, code },
401 => Error::Authentication { message, status, code },
402 => Error::InsufficientCredits { message, status, code, balance, required },
429 => Error::RateLimit {
message,
status,
code,
retry_after: parse_retry_after(retry_after_header),
},
s if s >= 500 => Error::Server { message, status, code },
_ => Error::Other(format!("HTTP {status}: {message}")),
}
}
fn require_string(v: &Value, field: &str) -> Result<String> {
v.get(field)
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| Error::Other(format!("API response missing required field: {field}")))
}
fn decode_field<T: DeserializeOwned + Default>(parent: &Value, field: &str) -> Result<T> {
match parent.get(field) {
Some(v) if !v.is_null() => serde_json::from_value(v.clone())
.map_err(|e| Error::Other(format!("decode response: {e}"))),
_ => Ok(T::default()),
}
}
pub struct ClientBuilder {
api_key: Option<String>,
base_url: Option<String>,
timeout: Duration,
max_retries: u32,
poll_interval: Duration,
poll_max_interval: Duration,
poll_timeout: Duration,
http: Option<reqwest::Client>,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self {
api_key: None,
base_url: None,
timeout: Duration::from_secs(60),
max_retries: 4,
poll_interval: Duration::from_secs(1),
poll_max_interval: Duration::from_secs(10),
poll_timeout: Duration::from_secs(300),
http: None,
}
}
}
impl ClientBuilder {
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn timeout(mut self, t: Duration) -> Self {
self.timeout = t;
self
}
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = n;
self
}
pub fn poll_interval(mut self, d: Duration) -> Self {
self.poll_interval = d;
self
}
pub fn poll_max_interval(mut self, d: Duration) -> Self {
self.poll_max_interval = d;
self
}
pub fn poll_timeout(mut self, d: Duration) -> Self {
self.poll_timeout = d;
self
}
pub fn http_client(mut self, c: reqwest::Client) -> Self {
self.http = Some(c);
self
}
pub fn build(self) -> Result<Client> {
let api_key = self
.api_key
.or_else(|| std::env::var("ANAKIN_API_KEY").ok())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
Error::Other(
"no API key — call .api_key(...) on the builder or set ANAKIN_API_KEY".into(),
)
})?;
let http = match self.http {
Some(c) => c,
None => reqwest::Client::builder()
.timeout(self.timeout)
.user_agent(format!("anakin-rust/{VERSION}"))
.build()
.map_err(|e| Error::Other(format!("build http client: {e}")))?,
};
Ok(Client {
http,
api_key,
base_url: self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
.trim_end_matches('/')
.to_string(),
max_retries: self.max_retries,
poll_interval: self.poll_interval,
poll_max_interval: self.poll_max_interval,
poll_timeout: self.poll_timeout,
})
}
}