use std::collections::HashSet;
use std::time::Duration;
use futures_util::stream::{self, StreamExt};
use serde::Serialize;
use crate::clients::base::{
delete_resource, delete_with_body, get_resource, get_resource_required, post_action,
post_with_body, update_resource, ResourceContext,
};
use crate::common::{encode_path_segment, QueryParams};
use crate::error::{ApifyClientError, ApifyClientResult};
use crate::http_client::{sleep_public, HttpClient, HttpMethod, HttpRequest};
use crate::models::{
BatchRequestsOperationResult, LockedRequestQueueHead, RequestLockInfo, RequestQueue,
RequestQueueHead, RequestQueueOperationInfo, RequestQueueRequest, RequestQueueRequestsPage,
UnlockRequestsResult, UnprocessedRequest,
};
const MAX_REQUESTS_PER_BATCH_OPERATION: usize = 25;
const MAX_PAYLOAD_SIZE_BYTES: usize = 9_437_184;
const SAFETY_BUFFER_PERCENT: f64 = 0.0001;
const DEFAULT_MAX_PARALLEL_BATCH_ADD_REQUESTS: usize = 5;
const DEFAULT_MAX_UNPROCESSED_REQUESTS_RETRIES: u32 = 3;
const DEFAULT_MIN_DELAY_BETWEEN_UNPROCESSED_REQUESTS_RETRIES: Duration = Duration::from_millis(500);
#[derive(Debug, Default, Clone)]
pub struct BatchAddRequestsOptions {
pub forefront: bool,
pub max_unprocessed_requests_retries: Option<u32>,
pub max_parallel: Option<usize>,
pub min_delay_between_unprocessed_requests_retries: Option<Duration>,
}
fn dedup_key(request: &RequestQueueRequest) -> &str {
request.unique_key.as_deref().unwrap_or(&request.url)
}
fn json_byte_len<T: Serialize + ?Sized>(value: &T) -> ApifyClientResult<usize> {
Ok(serde_json::to_vec(value)?.len())
}
fn slice_requests_by_byte_length(
requests: &[RequestQueueRequest],
max_bytes: usize,
start_index: usize,
) -> ApifyClientResult<Vec<RequestQueueRequest>> {
if json_byte_len(requests)? < max_bytes {
return Ok(requests.to_vec());
}
let mut out = Vec::new();
let mut byte_length = 2usize; for (offset, request) in requests.iter().enumerate() {
let item_bytes = json_byte_len(request)?;
if item_bytes > max_bytes {
return Err(ApifyClientError::InvalidArgument(format!(
"RequestQueueClient::batch_add_requests: the request at index {} exceeds the \
maximum allowed size ({max_bytes} bytes)",
start_index + offset
)));
}
if !out.is_empty() && byte_length + item_bytes >= max_bytes {
break;
}
byte_length += item_bytes;
out.push(request.clone());
}
Ok(out)
}
fn chunk_requests_for_batch_add(
requests: &[RequestQueueRequest],
max_bytes: usize,
) -> ApifyClientResult<Vec<Vec<RequestQueueRequest>>> {
let mut chunks = Vec::new();
let mut i = 0;
while i < requests.len() {
let group_end = (i + MAX_REQUESTS_PER_BATCH_OPERATION).min(requests.len());
let chunk = slice_requests_by_byte_length(&requests[i..group_end], max_bytes, i)?;
i += chunk.len();
chunks.push(chunk);
}
Ok(chunks)
}
#[derive(Debug, Default, Clone)]
pub struct ListRequestsOptions {
pub limit: Option<i64>,
pub exclusive_start_id: Option<String>,
pub cursor: Option<String>,
pub filter: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct RequestQueueClient {
ctx: ResourceContext,
client_key: Option<String>,
}
impl RequestQueueClient {
pub(crate) fn new(http: HttpClient, base_url: &str, resource_path: &str, id: &str) -> Self {
Self {
ctx: ResourceContext::single(http, base_url, resource_path, id),
client_key: None,
}
}
pub(crate) fn nested(http: HttpClient, base_url: &str, sub_path: &str) -> Self {
Self {
ctx: ResourceContext::collection(http, base_url, sub_path),
client_key: None,
}
}
pub fn with_client_key(mut self, client_key: impl Into<String>) -> Self {
self.client_key = Some(client_key.into());
self
}
fn base_params(&self) -> QueryParams {
let mut params = QueryParams::new();
params.add_str("clientKey", self.client_key.clone());
params
}
pub async fn get(&self) -> ApifyClientResult<Option<RequestQueue>> {
get_resource(&self.ctx, None, &QueryParams::new()).await
}
pub async fn update<T: Serialize>(&self, new_fields: &T) -> ApifyClientResult<RequestQueue> {
update_resource(&self.ctx, None, new_fields).await
}
pub async fn delete(&self) -> ApifyClientResult<()> {
delete_resource(&self.ctx, None).await
}
pub async fn list_head(&self, limit: Option<i64>) -> ApifyClientResult<RequestQueueHead> {
let mut params = self.base_params();
params.add_int("limit", limit);
get_resource_required(&self.ctx, Some("head"), ¶ms).await
}
pub async fn add_request(
&self,
request: &RequestQueueRequest,
forefront: bool,
) -> ApifyClientResult<RequestQueueOperationInfo> {
let mut params = self.base_params();
params.add_bool("forefront", Some(forefront));
let body = serde_json::to_vec(request)?;
post_with_body(
&self.ctx,
Some("requests"),
¶ms,
Some(body),
"application/json",
)
.await
}
pub async fn get_request(&self, id: &str) -> ApifyClientResult<Option<RequestQueueRequest>> {
get_resource(
&self.ctx,
Some(&format!("requests/{}", encode_path_segment(id))),
&self.base_params(),
)
.await
}
pub async fn update_request(
&self,
request: &RequestQueueRequest,
forefront: bool,
) -> ApifyClientResult<RequestQueueOperationInfo> {
let id = request.id.clone().ok_or_else(|| {
crate::error::ApifyClientError::InvalidArgument(
"request.id is required to update a request".to_string(),
)
})?;
let mut params = self.base_params();
params.add_bool("forefront", Some(forefront));
let url = params.apply_to_url(
&self
.ctx
.url(Some(&format!("requests/{}", encode_path_segment(&id)))),
);
let body = serde_json::to_vec(request)?;
let mut headers = std::collections::HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let response = self
.ctx
.http
.call(HttpRequest {
method: HttpMethod::Put,
url,
headers,
body: Some(body),
timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
})
.await?;
crate::common::parse_data_envelope(&response.body)
}
pub async fn delete_request(&self, id: &str) -> ApifyClientResult<()> {
let params = self.base_params();
let url = params.apply_to_url(
&self
.ctx
.url(Some(&format!("requests/{}", encode_path_segment(id)))),
);
self.ctx
.http
.call(HttpRequest {
method: HttpMethod::Delete,
url,
headers: Default::default(),
body: None,
timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
})
.await?;
Ok(())
}
pub async fn list_and_lock_head(
&self,
lock_secs: i64,
limit: Option<i64>,
) -> ApifyClientResult<LockedRequestQueueHead> {
let mut params = self.base_params();
params
.add_int("lockSecs", Some(lock_secs))
.add_int("limit", limit);
post_action(&self.ctx, Some("head/lock"), ¶ms, None, None).await
}
pub async fn batch_add_requests(
&self,
requests: &[RequestQueueRequest],
options: BatchAddRequestsOptions,
) -> ApifyClientResult<BatchRequestsOperationResult> {
if requests.is_empty() {
return Err(ApifyClientError::InvalidArgument(
"RequestQueueClient::batch_add_requests requires at least 1 request".to_string(),
));
}
let max_parallel = options
.max_parallel
.unwrap_or(DEFAULT_MAX_PARALLEL_BATCH_ADD_REQUESTS)
.max(1);
let payload_limit_bytes = MAX_PAYLOAD_SIZE_BYTES
- (MAX_PAYLOAD_SIZE_BYTES as f64 * SAFETY_BUFFER_PERCENT).ceil() as usize;
let chunks = chunk_requests_for_batch_add(requests, payload_limit_bytes)?;
let merged = stream::iter(chunks)
.map(|chunk| {
let client = self.clone();
let options = options.clone();
async move {
client
.batch_add_requests_chunk_with_retries(chunk, options)
.await
}
})
.buffer_unordered(max_parallel)
.fold(
BatchRequestsOperationResult::default(),
|mut acc, chunk_result| async move {
acc.processed_requests
.extend(chunk_result.processed_requests);
acc.unprocessed_requests
.extend(chunk_result.unprocessed_requests);
acc
},
)
.await;
Ok(merged)
}
async fn batch_add_requests_chunk_with_retries(
&self,
chunk: Vec<RequestQueueRequest>,
options: BatchAddRequestsOptions,
) -> BatchRequestsOperationResult {
let max_retries = options
.max_unprocessed_requests_retries
.unwrap_or(DEFAULT_MAX_UNPROCESSED_REQUESTS_RETRIES);
let min_delay = options
.min_delay_between_unprocessed_requests_retries
.unwrap_or(DEFAULT_MIN_DELAY_BETWEEN_UNPROCESSED_REQUESTS_RETRIES);
let mut remaining = chunk;
let mut processed = Vec::new();
for attempt in 0..=max_retries {
match self
.batch_add_requests_raw(&remaining, options.forefront)
.await
{
Ok(result) => {
let processed_keys: HashSet<&str> = result
.processed_requests
.iter()
.filter_map(|p| p.unique_key.as_deref())
.collect();
remaining.retain(|r| !processed_keys.contains(dedup_key(r)));
processed.extend(result.processed_requests);
if remaining.is_empty() {
return BatchRequestsOperationResult {
processed_requests: processed,
unprocessed_requests: Vec::new(),
};
}
if attempt == max_retries {
return BatchRequestsOperationResult {
processed_requests: processed,
unprocessed_requests: result.unprocessed_requests,
};
}
}
Err(_) => {
return BatchRequestsOperationResult {
processed_requests: processed,
unprocessed_requests: remaining
.iter()
.map(|r| UnprocessedRequest {
unique_key: dedup_key(r).to_string(),
url: r.url.clone(),
method: r.method.clone(),
})
.collect(),
};
}
}
let backoff = min_delay.saturating_mul(2u32.saturating_pow(attempt));
sleep_public(crate::http_client::randomized_delay(backoff)).await;
}
BatchRequestsOperationResult {
processed_requests: processed,
unprocessed_requests: remaining
.iter()
.map(|r| UnprocessedRequest {
unique_key: dedup_key(r).to_string(),
url: r.url.clone(),
method: r.method.clone(),
})
.collect(),
}
}
async fn batch_add_requests_raw(
&self,
requests: &[RequestQueueRequest],
forefront: bool,
) -> ApifyClientResult<BatchRequestsOperationResult> {
let mut params = self.base_params();
params.add_bool("forefront", Some(forefront));
let body = serde_json::to_vec(requests)?;
post_with_body(
&self.ctx,
Some("requests/batch"),
¶ms,
Some(body),
"application/json",
)
.await
}
pub async fn batch_delete_requests<T: Serialize>(
&self,
requests: &[T],
) -> ApifyClientResult<BatchRequestsOperationResult> {
if requests.is_empty() || requests.len() > MAX_REQUESTS_PER_BATCH_OPERATION {
return Err(ApifyClientError::InvalidArgument(format!(
"RequestQueueClient::batch_delete_requests accepts between 1 and {MAX_REQUESTS_PER_BATCH_OPERATION} requests per call, got {}",
requests.len()
)));
}
delete_with_body(
&self.ctx,
Some("requests/batch"),
&self.base_params(),
&requests,
)
.await
}
pub async fn list_requests(
&self,
options: ListRequestsOptions,
) -> ApifyClientResult<RequestQueueRequestsPage> {
let mut params = self.base_params();
params
.add_int("limit", options.limit)
.add_str("exclusiveStartId", options.exclusive_start_id)
.add_str("cursor", options.cursor)
.add_csv("filter", options.filter.as_deref());
get_resource_required(&self.ctx, Some("requests"), ¶ms).await
}
pub async fn prolong_request_lock(
&self,
id: &str,
lock_secs: i64,
forefront: bool,
) -> ApifyClientResult<RequestLockInfo> {
let mut params = self.base_params();
params
.add_int("lockSecs", Some(lock_secs))
.add_bool("forefront", Some(forefront));
let url = params.apply_to_url(
&self
.ctx
.url(Some(&format!("requests/{}/lock", encode_path_segment(id)))),
);
let response = self
.ctx
.http
.call(HttpRequest {
method: HttpMethod::Put,
url,
headers: Default::default(),
body: None,
timeout: crate::clients::base::MEDIUM_REQUEST_TIMEOUT,
})
.await?;
crate::common::parse_data_envelope(&response.body)
}
pub async fn delete_request_lock(&self, id: &str, forefront: bool) -> ApifyClientResult<()> {
let mut params = self.base_params();
params.add_bool("forefront", Some(forefront));
let url = params.apply_to_url(
&self
.ctx
.url(Some(&format!("requests/{}/lock", encode_path_segment(id)))),
);
self.ctx
.http
.call(HttpRequest {
method: HttpMethod::Delete,
url,
headers: Default::default(),
body: None,
timeout: crate::clients::base::SMALL_REQUEST_TIMEOUT,
})
.await?;
Ok(())
}
pub fn paginate_requests(&self, page_limit: Option<i64>) -> RequestQueueRequestsIterator {
RequestQueueRequestsIterator {
client: self.clone(),
page_limit,
buffer: std::collections::VecDeque::new(),
next_cursor: None,
exhausted: false,
}
}
pub async fn unlock_requests(&self) -> ApifyClientResult<UnlockRequestsResult> {
post_action(
&self.ctx,
Some("requests/unlock"),
&self.base_params(),
None,
None,
)
.await
}
}
pub struct RequestQueueRequestsIterator {
client: RequestQueueClient,
page_limit: Option<i64>,
buffer: std::collections::VecDeque<RequestQueueRequest>,
next_cursor: Option<String>,
exhausted: bool,
}
impl RequestQueueRequestsIterator {
pub async fn next(&mut self) -> ApifyClientResult<Option<RequestQueueRequest>> {
if let Some(item) = self.buffer.pop_front() {
return Ok(Some(item));
}
if self.exhausted {
return Ok(None);
}
let page = self
.client
.list_requests(ListRequestsOptions {
limit: self.page_limit,
cursor: self.next_cursor.clone(),
..Default::default()
})
.await?;
if page.items.is_empty() {
self.exhausted = true;
return Ok(None);
}
match page.next_cursor {
Some(cursor) if !cursor.is_empty() => self.next_cursor = Some(cursor),
_ => self.exhausted = true,
}
self.buffer.extend(page.items);
Ok(self.buffer.pop_front())
}
}
#[cfg(test)]
mod batch_add_tests {
use super::{
chunk_requests_for_batch_add, dedup_key, slice_requests_by_byte_length,
MAX_REQUESTS_PER_BATCH_OPERATION,
};
use crate::models::RequestQueueRequest;
fn request(url: &str, unique_key: Option<&str>) -> RequestQueueRequest {
RequestQueueRequest {
id: None,
url: url.to_string(),
unique_key: unique_key.map(str::to_string),
method: None,
user_data: None,
extra: Default::default(),
}
}
#[test]
fn dedup_key_falls_back_to_url() {
let with_key = request("https://example.com", Some("k1"));
assert_eq!(dedup_key(&with_key), "k1");
let without_key = request("https://example.com/no-key", None);
assert_eq!(dedup_key(&without_key), "https://example.com/no-key");
}
#[test]
fn byte_slice_returns_everything_when_under_budget() {
let requests: Vec<_> = (0..5)
.map(|i| request(&format!("https://example.com/{i}"), None))
.collect();
let sliced = slice_requests_by_byte_length(&requests, 1_000_000, 0).unwrap();
assert_eq!(sliced.len(), 5);
}
#[test]
fn byte_slice_takes_a_limited_prefix() {
let requests: Vec<_> = (0..10)
.map(|i| request(&format!("https://example.com/{i}"), None))
.collect();
let sliced = slice_requests_by_byte_length(&requests, 50, 0).unwrap();
assert_eq!(
sliced.len(),
1,
"budget of 50 bytes should admit exactly one ~30-byte request"
);
}
#[test]
fn byte_slice_errors_on_oversized_single_request() {
let huge_url = format!("https://example.com/{}", "x".repeat(1000));
let requests = vec![request(&huge_url, None)];
let err = slice_requests_by_byte_length(&requests, 100, 3).unwrap_err();
let message = err.to_string();
assert!(
message.contains("index 3"),
"error should name the absolute index of the oversized request: {message}"
);
}
#[test]
fn chunking_splits_by_count_when_bytes_are_plentiful() {
let requests: Vec<_> = (0..(MAX_REQUESTS_PER_BATCH_OPERATION * 2 + 3))
.map(|i| request(&format!("https://example.com/{i}"), None))
.collect();
let chunks = chunk_requests_for_batch_add(&requests, 10_000_000).unwrap();
let sizes: Vec<usize> = chunks.iter().map(Vec::len).collect();
assert_eq!(
sizes,
vec![
MAX_REQUESTS_PER_BATCH_OPERATION,
MAX_REQUESTS_PER_BATCH_OPERATION,
3
]
);
let total: usize = sizes.iter().sum();
assert_eq!(total, requests.len());
}
#[test]
fn chunking_splits_by_byte_budget_when_tighter_than_count_cap() {
let requests: Vec<_> = (0..6)
.map(|i| request(&format!("https://example.com/{i}"), None))
.collect();
let chunks = chunk_requests_for_batch_add(&requests, 100).unwrap();
assert!(
chunks.len() > 1,
"a tight byte budget must force more than one chunk, got {}",
chunks.len()
);
let total: usize = chunks.iter().map(Vec::len).sum();
assert_eq!(
total,
requests.len(),
"every request must end up in exactly one chunk"
);
}
}