use std::time::{Duration, Instant};
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::Value;
use crate::http::HttpError;
use crate::polling::{calculate_backoff, BackoffConfig};
use crate::CoreError;
use super::client::SynthClient;
use super::types::{CancelRequest, EvalJobRequest, EvalJobStatus, EvalResult, JobSubmitResponse};
const JOBS_ROOT: &str = "/api/jobs";
const EVAL_CREATE_ENDPOINT: &str = "/api/jobs/eval";
const EVAL_ENDPOINT: &str = "/api/eval/jobs";
pub struct EvalClient<'a> {
client: &'a SynthClient,
}
impl<'a> EvalClient<'a> {
pub(crate) fn new(client: &'a SynthClient) -> Self {
Self { client }
}
pub async fn submit(&self, request: EvalJobRequest) -> Result<String, CoreError> {
let worker_token = request.container_worker_token.clone();
let body = serde_json::to_value(&request)
.map_err(|e| CoreError::Validation(format!("failed to serialize request: {}", e)))?;
let response: JobSubmitResponse = if let Some(token) = worker_token {
let mut headers = HeaderMap::new();
headers.insert(
"X-SynthTunnel-Worker-Token",
HeaderValue::from_str(&token).map_err(|_| {
CoreError::Validation("invalid SynthTunnel worker token".to_string())
})?,
);
self.client
.http
.post_json_with_headers(EVAL_CREATE_ENDPOINT, &body, Some(headers))
.await
.map_err(map_http_error)?
} else {
self.client
.http
.post_json(EVAL_CREATE_ENDPOINT, &body)
.await
.map_err(map_http_error)?
};
Ok(response.job_id)
}
pub async fn submit_raw(&self, request: Value) -> Result<String, CoreError> {
let response: JobSubmitResponse = self
.client
.http
.post_json(EVAL_CREATE_ENDPOINT, &request)
.await
.map_err(map_http_error)?;
Ok(response.job_id)
}
pub async fn get_status(&self, job_id: &str) -> Result<EvalResult, CoreError> {
let path = format!("{}/{}", EVAL_ENDPOINT, job_id);
self.client
.http
.get(&path, None)
.await
.map_err(map_http_error)
}
pub async fn get_results(&self, job_id: &str) -> Result<Value, CoreError> {
let path = format!("{}/{}/results", EVAL_ENDPOINT, job_id);
self.client
.http
.get(&path, None)
.await
.map_err(map_http_error)
}
pub async fn download_traces(&self, job_id: &str) -> Result<Vec<u8>, CoreError> {
let path = format!("{}/{}/traces", EVAL_ENDPOINT, job_id);
self.client
.http
.get_bytes(&path, None)
.await
.map_err(map_http_error)
}
pub async fn poll_until_complete(
&self,
job_id: &str,
timeout_secs: f64,
interval_secs: f64,
) -> Result<EvalResult, CoreError> {
let start = Instant::now();
let timeout = Duration::from_secs_f64(timeout_secs);
let base_interval_ms = (interval_secs * 1000.0) as u64;
let config = BackoffConfig::new(base_interval_ms, 60000, 4);
let mut consecutive_errors = 0u32;
let max_errors = 10u32;
loop {
if start.elapsed() > timeout {
return Err(CoreError::Timeout(format!(
"eval job {} did not complete within {:.0} seconds",
job_id, timeout_secs
)));
}
match self.get_status(job_id).await {
Ok(result) => {
consecutive_errors = 0;
if result.status.is_terminal() {
return Ok(result);
}
let delay = calculate_backoff(&config, 0);
tokio::time::sleep(delay).await;
}
Err(e) => {
consecutive_errors += 1;
if consecutive_errors >= max_errors {
return Err(CoreError::Internal(format!(
"too many consecutive errors polling eval job {}: {}",
job_id, e
)));
}
let delay = calculate_backoff(&config, consecutive_errors);
tokio::time::sleep(delay).await;
}
}
}
}
pub async fn cancel(&self, job_id: &str, reason: Option<String>) -> Result<Value, CoreError> {
let path = format!("{}/{}/cancel", JOBS_ROOT, job_id);
let body = serde_json::to_value(&CancelRequest { reason })
.unwrap_or(Value::Object(serde_json::Map::new()));
self.client
.http
.post_json(&path, &body)
.await
.map_err(map_http_error)
}
pub async fn query_workflow_state(&self, job_id: &str) -> Result<Value, CoreError> {
let path = format!("/api/jobs/{}/workflow-state", job_id);
self.client
.http
.get_json(&path, None)
.await
.map_err(map_http_error)
}
pub async fn list(
&self,
limit: Option<i32>,
status: Option<EvalJobStatus>,
) -> Result<Vec<EvalResult>, CoreError> {
let mut params = vec![];
let limit_str;
if let Some(l) = limit {
limit_str = l.to_string();
params.push(("limit", limit_str.as_str()));
}
let status_str;
if let Some(s) = status {
status_str = s.as_str().to_string();
params.push(("status", status_str.as_str()));
}
let params_ref: Option<&[(&str, &str)]> = if params.is_empty() {
None
} else {
Some(¶ms)
};
self.client
.http
.get(EVAL_ENDPOINT, params_ref)
.await
.map_err(map_http_error)
}
}
fn map_http_error(e: HttpError) -> CoreError {
match e {
HttpError::Response(detail) => {
if detail.status == 401 || detail.status == 403 {
CoreError::Authentication(format!("authentication failed: {}", detail))
} else if detail.status == 429 {
CoreError::UsageLimit(crate::UsageLimitInfo::from_http_429("eval", &detail))
} else {
CoreError::HttpResponse(crate::HttpErrorInfo {
status: detail.status,
url: detail.url,
message: detail.message,
body_snippet: detail.body_snippet,
})
}
}
HttpError::Request(e) => CoreError::Http(e),
_ => CoreError::Internal(format!("{}", e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_eval_endpoint() {
assert_eq!(EVAL_CREATE_ENDPOINT, "/api/jobs/eval");
assert_eq!(EVAL_ENDPOINT, "/api/eval/jobs");
assert_eq!(JOBS_ROOT, "/api/jobs");
}
}