use std::{
collections::BTreeMap,
fmt,
hash::{BuildHasher, Hasher},
io::Write,
time::Duration,
};
use http::{
header::{HeaderMap, HeaderValue, ACCEPT},
Method,
};
use crate::{
Batch, BatchExpiresAfter, BatchInputLine, BatchListOptions, BatchPage, BatchResultLine, Client, CreateBatchRequest,
DeletedFile, Error, FileObject, FileUploadOptions,
};
pub struct CreateBatchFromRequests<'a> {
pub requests: &'a [BatchInputLine],
pub metadata: Option<BTreeMap<String, String>>,
pub output_expires_after: Option<BatchExpiresAfter>,
}
impl<'a> CreateBatchFromRequests<'a> {
pub fn new(requests: &'a [BatchInputLine]) -> Self {
Self { requests, metadata: None, output_expires_after: None }
}
}
pub enum BatchCreateInput<'a> {
File(&'a CreateBatchRequest),
Requests(&'a CreateBatchFromRequests<'a>),
}
impl<'a> From<&'a CreateBatchRequest> for BatchCreateInput<'a> {
fn from(value: &'a CreateBatchRequest) -> Self {
Self::File(value)
}
}
impl<'a> From<&'a CreateBatchFromRequests<'a>> for BatchCreateInput<'a> {
fn from(value: &'a CreateBatchFromRequests<'a>) -> Self {
Self::Requests(value)
}
}
#[derive(Clone, Copy, Debug)]
pub struct BatchWaitOptions {
pub timeout: Duration,
pub poll_interval: Duration,
}
impl Default for BatchWaitOptions {
fn default() -> Self {
Self { timeout: Duration::from_secs(600), poll_interval: Duration::from_secs(5) }
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct BatchResultsOptions {
pub max_bytes: Option<usize>,
}
pub enum BatchOperationError {
Request(Error),
Submission {
input_file_id: String,
idempotency_key: String,
source: Error,
},
WaitTimeout {
batch_id: String,
last_batch: Option<Box<Batch>>,
},
NotReady {
batch: Box<Batch>,
},
ResultLine {
file_id: String,
line: usize,
source: Error,
},
}
impl From<Error> for BatchOperationError {
fn from(value: Error) -> Self {
Self::Request(value)
}
}
impl fmt::Display for BatchOperationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Request(error) => fmt::Display::fmt(error, f),
Self::Submission { .. } => {
f.write_str("Basal batch creation failed after upload; recovery context is available")
}
Self::WaitTimeout { .. } => f.write_str("Basal batch wait timed out; server work continues"),
Self::NotReady { .. } => f.write_str("Basal batch results are not ready"),
Self::ResultLine { line, .. } => write!(f, "invalid Basal batch result at line {line}"),
}
}
}
impl fmt::Debug for BatchOperationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl std::error::Error for BatchOperationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Request(source) | Self::Submission { source, .. } | Self::ResultLine { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Copy)]
pub struct Files<'a> {
client: &'a Client,
}
#[derive(Clone, Copy)]
pub struct Batches<'a> {
client: &'a Client,
}
impl Client {
pub fn files(&self) -> Files<'_> {
Files { client: self }
}
pub fn batches(&self) -> Batches<'_> {
Batches { client: self }
}
}
impl Files<'_> {
pub async fn create(&self, filename: &str, data: &[u8], options: FileUploadOptions) -> Result<FileObject, Error> {
self.client.upload_file(filename, data, options).await
}
pub async fn retrieve(&self, id: &str) -> Result<FileObject, Error> {
self.client.get_file(id).await
}
pub async fn list(
&self,
options: BatchListOptions<'_>,
purpose: Option<&str>,
) -> Result<BatchPage<FileObject>, Error> {
self.client.list_files(options, purpose).await
}
pub async fn delete(&self, id: &str) -> Result<DeletedFile, Error> {
self.client.delete_file(id).await
}
pub async fn content(&self, id: &str) -> Result<Vec<u8>, Error> {
self.client.download_file(id).await
}
pub async fn content_with_limit(&self, id: &str, max_bytes: usize) -> Result<Vec<u8>, Error> {
super::batches::id_component(id, "file_")?;
if max_bytes == 0 {
return Err(Error::InvalidRequest("download max_bytes must be positive"));
}
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
self.client
.request_bytes_bounded(Method::GET, &format!("v1/files/{id}/content"), (None, None), headers, max_bytes)
.await
}
}
impl<'a> Batches<'a> {
pub async fn create<'r>(
&self,
input: impl Into<BatchCreateInput<'r>>,
idempotency_key: Option<&str>,
) -> Result<Batch, BatchOperationError> {
match input.into() {
BatchCreateInput::File(request) => {
self.client.create_batch(request, idempotency_key).await.map_err(Into::into)
}
BatchCreateInput::Requests(request) => {
validate_key(idempotency_key)?;
validate_common(request)?;
let data = encode_requests(request.requests)?;
let key = idempotency_key.map(str::to_owned).unwrap_or_else(new_key);
let file = self.client.upload_file("batch-input.jsonl", &data, FileUploadOptions::default()).await?;
let mut create = CreateBatchRequest::new(&file.id);
create.metadata = request.metadata.clone();
create.output_expires_after = request.output_expires_after.clone();
self.client.create_batch(&create, Some(&key)).await.map_err(|source| BatchOperationError::Submission {
input_file_id: file.id,
idempotency_key: key,
source,
})
}
}
}
pub async fn retrieve(&self, id: &str) -> Result<Batch, Error> {
self.client.get_batch(id).await
}
pub async fn list(&self, options: BatchListOptions<'_>) -> Result<BatchPage<Batch>, Error> {
self.client.list_batches(options).await
}
pub async fn cancel(&self, id: &str) -> Result<Batch, Error> {
self.client.cancel_batch(id).await
}
pub async fn wait(&self, id: &str, options: BatchWaitOptions) -> Result<Batch, BatchOperationError> {
super::batches::id_component(id, "batch_")?;
if options.timeout.is_zero()
|| options.poll_interval.is_zero()
|| tokio::time::Instant::now().checked_add(options.timeout).is_none()
|| tokio::time::Instant::now().checked_add(options.poll_interval).is_none()
{
return Err(Error::InvalidRequest("wait durations must be positive and representable").into());
}
let mut last_batch = None;
let polling = async {
loop {
let batch = self.retrieve(id).await?;
if terminal(&batch.status) {
return Ok(batch);
}
last_batch = Some(Box::new(batch));
tokio::time::sleep(options.poll_interval).await;
}
};
match tokio::time::timeout(options.timeout, polling).await {
Ok(result) => result,
Err(_) => Err(BatchOperationError::WaitTimeout { batch_id: id.to_owned(), last_batch }),
}
}
pub async fn results(&self, id: &str) -> Result<BatchResults<'a>, BatchOperationError> {
self.results_with_options(id, BatchResultsOptions::default()).await
}
pub async fn results_with_options(
&self,
id: &str,
options: BatchResultsOptions,
) -> Result<BatchResults<'a>, BatchOperationError> {
if options.max_bytes == Some(0) {
return Err(Error::InvalidRequest("download max_bytes must be positive").into());
}
let batch = self.retrieve(id).await?;
if !terminal(&batch.status) {
return Err(BatchOperationError::NotReady { batch: Box::new(batch) });
}
let mut files = Vec::new();
for file in [&batch.output_file_id, &batch.error_file_id].into_iter().flatten() {
if !files.contains(file) {
files.push(file.clone());
}
}
Ok(BatchResults {
client: self.client,
batch,
files,
next_file: 0,
current: Vec::new(),
offset: 0,
line: 0,
max_bytes: options.max_bytes,
finished: false,
})
}
}
pub struct BatchResults<'a> {
client: &'a Client,
batch: Batch,
files: Vec<String>,
next_file: usize,
current: Vec<u8>,
offset: usize,
line: usize,
max_bytes: Option<usize>,
finished: bool,
}
impl BatchResults<'_> {
pub fn batch(&self) -> &Batch {
&self.batch
}
pub async fn next(&mut self) -> Result<Option<BatchResultLine>, BatchOperationError> {
if self.finished {
return Ok(None);
}
loop {
if self.offset < self.current.len() {
let rest = &self.current[self.offset..];
let length = rest.iter().position(|b| *b == b'\n').unwrap_or(rest.len());
let record = parse_result(&rest[..length]);
self.offset += length + usize::from(length < rest.len());
self.line += 1;
return record.map(Some).map_err(|source| {
self.finished = true;
self.current = Vec::new();
BatchOperationError::ResultLine {
file_id: self.files[self.next_file - 1].clone(),
line: self.line,
source,
}
});
}
self.current = Vec::new();
if self.next_file == self.files.len() {
self.finished = true;
return Ok(None);
}
let id = &self.files[self.next_file];
self.finished = true;
let bytes = match self.max_bytes {
Some(limit) => self.client.files().content_with_limit(id, limit).await?,
None => self.client.files().content(id).await?,
};
self.finished = false;
self.current = bytes;
self.offset = 0;
self.line = 0;
self.next_file += 1;
}
}
}
fn terminal(status: &str) -> bool {
matches!(status, "completed" | "failed" | "expired" | "cancelled")
}
fn validate_key(key: Option<&str>) -> Result<(), Error> {
if key.is_some_and(|k| k.is_empty() || k.len() > 128 || !k.bytes().all(|b| (0x21..=0x7e).contains(&b))) {
return Err(Error::InvalidRequest("invalid idempotency key"));
}
Ok(())
}
fn validate_common(request: &CreateBatchFromRequests<'_>) -> Result<(), Error> {
if request.metadata.as_ref().is_some_and(|m| {
m.len() > 16 || m.iter().any(|(k, v)| k.is_empty() || k.chars().count() > 64 || v.chars().count() > 512)
}) {
return Err(Error::InvalidRequest("invalid batch metadata"));
}
if request
.output_expires_after
.as_ref()
.is_some_and(|e| e.anchor != "created_at" || !(3600..=2_592_000).contains(&e.seconds))
{
return Err(Error::InvalidRequest("invalid output retention"));
}
Ok(())
}
fn new_key() -> String {
let random = std::collections::hash_map::RandomState::new();
let mut a = random.build_hasher();
a.write_u8(0);
let mut b = random.build_hasher();
b.write_u8(1);
format!("basal-sdk-{:016x}{:016x}", a.finish(), b.finish())
}
struct JsonlBuffer {
bytes: Vec<u8>,
limit_hit: bool,
}
impl Write for JsonlBuffer {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if bytes.len() > 200_000_000usize.saturating_sub(self.bytes.len()) {
self.limit_hit = true;
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "batch input exceeds 200 MB"));
}
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn encode_requests(requests: &[BatchInputLine]) -> Result<Vec<u8>, Error> {
if requests.is_empty() || requests.len() > 50_000 {
return Err(Error::InvalidRequest("batch requires 1-50000 requests"));
}
let mut ids = std::collections::HashSet::new();
let mut out = JsonlBuffer { bytes: Vec::new(), limit_hit: false };
for record in requests {
if record.custom_id.is_empty() || record.custom_id.len() > 256 || !ids.insert(record.custom_id.as_str()) {
return Err(Error::InvalidRequest("batch custom_id must be unique and contain 1-256 UTF-8 bytes"));
}
if record.method != "POST"
|| record.url != "/v1/basal"
|| record.body.extra.get("stream").and_then(serde_json::Value::as_bool) == Some(true)
{
return Err(Error::InvalidRequest("batch records require nonstreaming POST /v1/basal"));
}
let encoded = serde_json::to_writer(&mut out, record);
if out.limit_hit {
return Err(Error::InvalidRequest("batch input exceeds 200 MB"));
}
encoded.map_err(Error::Encode)?;
out.write_all(b"\n").map_err(|_| Error::InvalidRequest("batch input exceeds 200 MB"))?;
}
Ok(out.bytes)
}
fn parse_result(line: &[u8]) -> Result<BatchResultLine, Error> {
let value: serde_json::Value = serde_json::from_slice(line).map_err(Error::Protocol)?;
if value.get("response").is_none() || value.get("error").is_none() {
return Err(Error::InvalidResponse("batch result requires response and error fields"));
}
let record: BatchResultLine = serde_json::from_value(value).map_err(Error::Protocol)?;
if record.id.is_empty()
|| record.custom_id.is_empty()
|| record.response.is_some() == record.error.is_some()
|| record
.response
.as_ref()
.is_some_and(|r| !(100..=599).contains(&r.status_code) || r.request_id.is_empty() || !r.body.is_object())
|| record.error.as_ref().is_some_and(|e| e.code.is_empty())
{
return Err(Error::InvalidResponse("invalid batch result envelope"));
}
Ok(record)
}