use std::{fmt, path::PathBuf, time::Duration};
use serde_json::Value;
use uuid::Uuid;
use crate::api::types;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ErrorCode {
OutOfCredits,
RateLimit,
PromptNsfw,
IpDetected,
JobFailed,
Timeout,
Validation,
ConfirmationRejected,
Canceled,
JobNotCancellable,
ApprovalRequired,
RunEnded,
SessionBusy,
AccessDenied,
InsufficientCredits,
BackendError,
Other(String),
}
impl ErrorCode {
pub fn from_wire(code: &str) -> Self {
match code {
"out_of_credits" => Self::OutOfCredits,
"rate_limit" => Self::RateLimit,
"prompt_nsfw" => Self::PromptNsfw,
"ip_detected" => Self::IpDetected,
"job_failed" => Self::JobFailed,
"timeout" => Self::Timeout,
"validation" => Self::Validation,
"confirmation_rejected" => Self::ConfirmationRejected,
"canceled" => Self::Canceled,
"job_not_cancellable" => Self::JobNotCancellable,
"approval_required" => Self::ApprovalRequired,
"run_ended" => Self::RunEnded,
"session_busy" => Self::SessionBusy,
"access_denied" => Self::AccessDenied,
"insufficient_credits" => Self::InsufficientCredits,
"backend_error" => Self::BackendError,
other => Self::Other(other.to_owned()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::OutOfCredits => "out_of_credits",
Self::RateLimit => "rate_limit",
Self::PromptNsfw => "prompt_nsfw",
Self::IpDetected => "ip_detected",
Self::JobFailed => "job_failed",
Self::Timeout => "timeout",
Self::Validation => "validation",
Self::ConfirmationRejected => "confirmation_rejected",
Self::Canceled => "canceled",
Self::JobNotCancellable => "job_not_cancellable",
Self::ApprovalRequired => "approval_required",
Self::RunEnded => "run_ended",
Self::SessionBusy => "session_busy",
Self::AccessDenied => "access_denied",
Self::InsufficientCredits => "insufficient_credits",
Self::BackendError => "backend_error",
Self::Other(code) => code,
}
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApiError {
pub status: u16,
pub code: Option<ErrorCode>,
pub title: Option<String>,
pub detail: Option<String>,
pub job_id: Option<Uuid>,
pub request_id: Option<String>,
pub retry_after: Option<Duration>,
pub body: Option<Value>,
}
impl ApiError {
pub(crate) fn from_parts(status: u16, retry_after: Option<Duration>, body: &[u8]) -> Self {
let json: Option<Value> = serde_json::from_slice(body).ok();
let text = |key: &str| {
json.as_ref()
.and_then(|v| v.get(key))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned)
};
let detail = text("detail").or_else(|| {
let raw = String::from_utf8_lossy(body).trim().to_owned();
(json.is_none() && !raw.is_empty()).then(|| raw.chars().take(300).collect())
});
Self {
status,
code: text("code").map(|c| ErrorCode::from_wire(&c)),
title: text("title"),
detail,
job_id: text("job_id").and_then(|id| Uuid::parse_str(&id).ok()),
request_id: text("request_id"),
retry_after,
body: json,
}
}
pub fn is_out_of_credits(&self) -> bool {
matches!(
self.code,
Some(ErrorCode::OutOfCredits | ErrorCode::InsufficientCredits)
) || (self.status == 402 && self.title.as_deref() == Some("Budget Exceeded"))
}
pub fn is_plan_required(&self) -> bool {
self.status == 402 && self.title.as_deref() == Some("Upgrade Required")
}
pub fn is_rate_limited(&self) -> bool {
self.status == 429 || self.code == Some(ErrorCode::RateLimit)
}
pub fn is_duplicate_submission(&self) -> bool {
self.status == 409 && self.code.is_none() && self.job_id.is_some()
}
pub fn is_unauthorized(&self) -> bool {
self.status == 401
}
pub fn is_not_found(&self) -> bool {
self.status == 404
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "HTTP {}", self.status)?;
if let Some(title) = &self.title {
write!(f, " {title}")?;
}
if let Some(code) = &self.code {
write!(f, " ({code})")?;
}
if let Some(detail) = &self.detail {
write!(f, ": {detail}")?;
}
if let Some(job) = &self.job_id {
write!(f, " [job {job}]")?;
}
if let Some(request) = &self.request_id {
write!(f, " [request {request}]")?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct JobError {
pub job_id: Uuid,
pub status: String,
pub code: ErrorCode,
pub kind: String,
pub message: String,
pub credits_refunded: Option<bool>,
pub job: Box<types::Job>,
}
impl JobError {
pub(crate) fn from_job(job: &types::Job) -> Self {
if job.status == "canceled" {
let cancellation = job.cancellation.as_ref();
let refunded = cancellation.and_then(|c| match c.settlement.as_str() {
"refunded" => Some(true),
"charged" => Some(false),
_ => None,
});
return Self {
job_id: job.id,
status: job.status.clone(),
code: ErrorCode::Canceled,
kind: "canceled".into(),
message: cancellation
.map(|c| c.message.clone())
.filter(|m| !m.is_empty())
.or_else(|| job.status_message.clone())
.unwrap_or_else(|| "the job was canceled".into()),
credits_refunded: refunded,
job: Box::new(job.clone()),
};
}
let failure = job.failure.as_ref();
let kind = failure
.map(|f| f.kind.clone())
.unwrap_or_else(|| "error".into());
let code = failure
.and_then(|f| f.code.as_deref())
.or_else(|| job.error.as_ref().and_then(|e| e.code.as_deref()))
.filter(|c| !c.is_empty())
.map(ErrorCode::from_wire)
.unwrap_or(if kind == "moderated" {
ErrorCode::PromptNsfw
} else {
ErrorCode::JobFailed
});
let message = failure
.map(|f| f.message.clone())
.or_else(|| job.error.as_ref().and_then(|e| e.detail.clone()))
.unwrap_or_else(|| "generation failed".into());
Self {
job_id: job.id,
status: job.status.clone(),
code,
kind,
message,
credits_refunded: failure.and_then(|f| f.credits_refunded),
job: Box::new(job.clone()),
}
}
pub fn is_moderated(&self) -> bool {
self.kind == "moderated"
}
}
impl fmt::Display for JobError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"job {} {} ({}): {}",
self.job_id, self.status, self.code, self.message
)
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum Error {
Api(Box<ApiError>),
Job(Box<JobError>),
Timeout {
job_id: Option<Uuid>,
},
Transport(reqwest::Error),
Decode {
status: Option<u16>,
message: String,
},
Config(String),
InvalidRequest(String),
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl Error {
pub fn code(&self) -> Option<&ErrorCode> {
match self {
Self::Api(err) => err.code.as_ref(),
Self::Job(err) => Some(&err.code),
Self::Timeout { .. } => Some(&ErrorCode::Timeout),
_ => None,
}
}
pub fn status(&self) -> Option<u16> {
match self {
Self::Api(err) => Some(err.status),
Self::Decode { status, .. } => *status,
_ => None,
}
}
pub fn as_api(&self) -> Option<&ApiError> {
match self {
Self::Api(err) => Some(err.as_ref()),
_ => None,
}
}
pub fn as_job(&self) -> Option<&JobError> {
match self {
Self::Job(err) => Some(err.as_ref()),
_ => None,
}
}
pub fn is_out_of_credits(&self) -> bool {
match self {
Self::Api(err) => err.is_out_of_credits(),
Self::Job(err) => err.code == ErrorCode::OutOfCredits,
_ => false,
}
}
pub fn is_plan_required(&self) -> bool {
self.as_api().is_some_and(ApiError::is_plan_required)
}
pub fn is_rate_limited(&self) -> bool {
self.as_api().is_some_and(ApiError::is_rate_limited)
}
pub(crate) fn transport(err: reqwest::Error) -> Self {
Self::Transport(err.without_url())
}
pub(crate) fn decode(status: Option<u16>, message: impl fmt::Display) -> Self {
Self::Decode {
status,
message: message.to_string(),
}
}
pub(crate) async fn from_response(response: reqwest::Response) -> Self {
let status = response.status().as_u16();
let retry_after = crate::transport::retry_after(&response);
match response.bytes().await {
Ok(body) => Self::Api(Box::new(ApiError::from_parts(status, retry_after, &body))),
Err(_) => Self::Api(Box::new(ApiError::from_parts(status, retry_after, &[]))),
}
}
pub async fn from_api(err: progenitor_client::Error<()>) -> Self {
use progenitor_client::Error as E;
match err {
E::UnexpectedResponse(response) => Self::from_response(response).await,
E::ErrorResponse(value) => Self::Api(Box::new(ApiError {
status: value.status().as_u16(),
code: None,
title: None,
detail: None,
job_id: None,
request_id: None,
retry_after: None,
body: None,
})),
E::CommunicationError(err) | E::ResponseBodyError(err) => Self::transport(err),
E::InvalidResponsePayload(bytes, err) => {
let _ = bytes;
Self::decode(None, err)
}
E::InvalidRequest(message) => Self::InvalidRequest(message),
E::InvalidUpgrade(err) => Self::transport(err),
E::Custom(message) => Self::InvalidRequest(message),
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Api(err) => write!(f, "{err}"),
Self::Job(err) => write!(f, "{err}"),
Self::Timeout { job_id: Some(id) } => write!(
f,
"timed out waiting for job {id}; it is still running and was not canceled"
),
Self::Timeout { job_id: None } => write!(f, "timed out"),
Self::Transport(err) => write!(f, "request failed: {err}"),
Self::Decode { status, message } => match status {
Some(status) => write!(f, "could not decode the HTTP {status} response: {message}"),
None => write!(f, "could not decode the response: {message}"),
},
Self::Config(message) => f.write_str(message),
Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Transport(err) => Some(err),
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn problem_body_maps_to_typed_fields() {
let body = br#"{"type":"about:blank","title":"Payment Required","status":402,
"detail":"The available credits cannot cover this generation.","code":"out_of_credits",
"request_id":"req_1"}"#;
let err = ApiError::from_parts(402, None, body);
assert_eq!(err.code, Some(ErrorCode::OutOfCredits));
assert!(err.is_out_of_credits());
assert!(!err.is_plan_required());
assert_eq!(err.request_id.as_deref(), Some("req_1"));
assert!(err.to_string().contains("out_of_credits"));
}
#[test]
fn plan_refusal_is_not_a_balance_refusal() {
let body = br#"{"type":"about:blank","title":"Upgrade Required","status":402,"detail":"a plan limit"}"#;
let err = ApiError::from_parts(402, None, body);
assert!(err.is_plan_required());
assert!(!err.is_out_of_credits());
}
#[test]
fn duplicate_submission_names_the_job() {
let id = Uuid::new_v4();
let body = format!(
r#"{{"type":"about:blank","title":"Conflict","status":409,"detail":"already","job_id":"{id}"}}"#
);
let err = ApiError::from_parts(409, None, body.as_bytes());
assert!(err.is_duplicate_submission());
assert_eq!(err.job_id, Some(id));
}
#[test]
fn unknown_codes_pass_through_verbatim() {
assert_eq!(
ErrorCode::from_wire("brand_new"),
ErrorCode::Other("brand_new".into())
);
assert_eq!(ErrorCode::from_wire("brand_new").as_str(), "brand_new");
for code in [
"approval_required",
"run_ended",
"session_busy",
"insufficient_credits",
] {
assert_eq!(ErrorCode::from_wire(code).as_str(), code);
assert!(!matches!(ErrorCode::from_wire(code), ErrorCode::Other(_)));
}
}
#[test]
fn non_json_body_keeps_some_text() {
let err = ApiError::from_parts(502, None, b"<html>bad gateway</html>");
assert_eq!(err.detail.as_deref(), Some("<html>bad gateway</html>"));
assert!(err.code.is_none());
}
}