use crate::context::JobContext;
use crate::executor::{JobError, JobResult, Worker};
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum HttpWorkerMode {
Async,
Sync {
response_timeout: Duration,
},
}
#[derive(Debug, Clone)]
pub struct HttpWorkerConfig {
pub url: String,
pub mode: HttpWorkerMode,
pub callback_timeout: Duration,
pub headers: HashMap<String, String>,
pub hmac_secret: Option<[u8; 32]>,
pub callback_base_url: Option<String>,
pub request_timeout: Duration,
}
impl Default for HttpWorkerConfig {
fn default() -> Self {
Self {
url: String::new(),
mode: HttpWorkerMode::Async,
callback_timeout: Duration::from_secs(3600),
headers: HashMap::new(),
hmac_secret: None,
callback_base_url: None,
request_timeout: Duration::from_secs(30),
}
}
}
pub struct HttpWorker {
kind: &'static str,
client: reqwest::Client,
config: HttpWorkerConfig,
}
impl HttpWorker {
pub fn new(kind: String, config: HttpWorkerConfig) -> Self {
let mut builder = reqwest::Client::builder()
.timeout(config.request_timeout)
.user_agent("awa-http-worker/0.5");
if let HttpWorkerMode::Sync { response_timeout } = &config.mode {
builder = builder.timeout(*response_timeout);
}
let client = builder.build().expect("failed to build HTTP client");
Self {
kind: Box::leak(kind.into_boxed_str()),
client,
config,
}
}
fn sign_callback_id(&self, callback_id: &str) -> Option<String> {
self.config.hmac_secret.map(|key| {
let hash = blake3::keyed_hash(&key, callback_id.as_bytes());
hash.to_hex().to_string()
})
}
}
#[derive(serde::Serialize)]
struct HttpWorkerRequest<'a> {
job_id: i64,
kind: &'a str,
args: &'a serde_json::Value,
attempt: i16,
max_attempts: i16,
#[serde(skip_serializing_if = "Option::is_none")]
callback_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
callback_url: Option<String>,
}
#[async_trait::async_trait]
impl Worker for HttpWorker {
fn kind(&self) -> &'static str {
self.kind
}
async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
match &self.config.mode {
HttpWorkerMode::Async => self.perform_async(ctx).await,
HttpWorkerMode::Sync { .. } => self.perform_sync(ctx).await,
}
}
}
impl HttpWorker {
async fn perform_async(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
let guard = ctx
.register_callback(self.config.callback_timeout)
.await
.map_err(JobError::retryable)?;
let callback_id_str = guard.id().to_string();
let callback_url = self.config.callback_base_url.as_ref().map(|base| {
format!(
"{}/api/callbacks/{}/complete",
base.trim_end_matches('/'),
callback_id_str
)
});
let body = HttpWorkerRequest {
job_id: ctx.job.id,
kind: self.kind,
args: &ctx.job.args,
attempt: ctx.job.attempt,
max_attempts: ctx.job.max_attempts,
callback_id: Some(callback_id_str.clone()),
callback_url,
};
let mut request = self.client.post(&self.config.url).json(&body);
for (key, value) in &self.config.headers {
request = request.header(key.as_str(), value.as_str());
}
if let Some(signature) = self.sign_callback_id(&callback_id_str) {
request = request.header("X-Awa-Signature", &signature);
}
let response = request.send().await.map_err(JobError::retryable)?;
let status = response.status();
if status.is_success() {
Ok(JobResult::WaitForCallback(guard))
} else if status.is_server_error() {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::retryable_msg(format!(
"function returned {status}: {body_text}"
)))
} else {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::Terminal(format!(
"function returned {status}: {body_text}"
)))
}
}
async fn perform_sync(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
let body = HttpWorkerRequest {
job_id: ctx.job.id,
kind: self.kind,
args: &ctx.job.args,
attempt: ctx.job.attempt,
max_attempts: ctx.job.max_attempts,
callback_id: None,
callback_url: None,
};
let mut request = self.client.post(&self.config.url).json(&body);
for (key, value) in &self.config.headers {
request = request.header(key.as_str(), value.as_str());
}
let response = request.send().await.map_err(JobError::retryable)?;
let status = response.status();
if status.is_success() {
Ok(JobResult::Completed)
} else if status.is_server_error() {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::retryable_msg(format!(
"function returned {status}: {body_text}"
)))
} else {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::Terminal(format!(
"function returned {status}: {body_text}"
)))
}
}
}
pub fn verify_callback_signature(
hmac_secret: &[u8; 32],
callback_id: &str,
provided_signature: &str,
) -> bool {
let expected = blake3::keyed_hash(hmac_secret, callback_id.as_bytes());
blake3::Hash::from_hex(provided_signature).is_ok_and(|h| expected == h)
}