use std::fmt;
pub type Result<T> = std::result::Result<T, Error>;
type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;
const ERROR_BODY_LIMIT: usize = 512;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("HTTP transport error: {0}")]
Transport(#[source] TransportError),
#[error("URL error: {0}")]
Url(#[source] UrlError),
#[error("response parse error: {0}")]
Parse(#[source] ParseError),
#[error("OpenCellID API error ({code}): {message}")]
Api {
code: ApiErrorCode,
message: String,
},
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("missing configuration: {0}")]
MissingConfig(&'static str),
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct TransportError {
message: String,
#[source]
source: Option<BoxedCause>,
}
impl TransportError {
pub(crate) fn new(err: reqwest::Error) -> Self {
Self {
message: err.to_string(),
source: Some(Box::new(err)),
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Error::Transport(TransportError::new(err))
}
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct UrlError {
message: String,
#[source]
source: Option<BoxedCause>,
}
impl UrlError {
pub(crate) fn new(err: url::ParseError) -> Self {
Self {
message: err.to_string(),
source: Some(Box::new(err)),
}
}
}
impl From<url::ParseError> for Error {
fn from(err: url::ParseError) -> Self {
Error::Url(UrlError::new(err))
}
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct ParseError {
message: String,
#[source]
source: Option<BoxedCause>,
}
impl ParseError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self { message: message.into(), source: None }
}
pub(crate) fn with_source(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self { message: message.into(), source: Some(Box::new(source)) }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorCode {
CellNotFound,
InvalidApiKey,
InvalidInput,
NeedsWhitelisting,
ServerError,
TooManyRequests,
DailyLimitExceeded,
#[non_exhaustive]
Unknown(u16),
}
impl ApiErrorCode {
pub fn from_code(code: u16) -> Self {
match code {
1 => Self::CellNotFound,
2 => Self::InvalidApiKey,
3 => Self::InvalidInput,
4 => Self::NeedsWhitelisting,
5 => Self::ServerError,
6 => Self::TooManyRequests,
7 => Self::DailyLimitExceeded,
other => Self::Unknown(other),
}
}
pub fn is_retryable(self) -> bool {
matches!(self, Self::ServerError | Self::TooManyRequests)
}
}
impl fmt::Display for ApiErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CellNotFound => f.write_str("cell_not_found"),
Self::InvalidApiKey => f.write_str("invalid_api_key"),
Self::InvalidInput => f.write_str("invalid_input"),
Self::NeedsWhitelisting => f.write_str("needs_whitelisting"),
Self::ServerError => f.write_str("server_error"),
Self::TooManyRequests => f.write_str("too_many_requests"),
Self::DailyLimitExceeded => f.write_str("daily_limit_exceeded"),
Self::Unknown(c) => write!(f, "unknown({c})"),
}
}
}
pub(crate) fn truncate_for_diagnostic(body: &str) -> String {
if body.is_empty() {
return String::new();
}
let total = body.len();
let limit = ERROR_BODY_LIMIT;
let prefix = if total > limit {
let cut = body
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i < limit)
.last()
.unwrap_or(0);
&body[..cut]
} else {
body
};
let mut out: String = prefix.escape_debug().collect();
if total > limit {
use std::fmt::Write as _;
let _ = write!(out, "…({total} bytes total)");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_error_code_round_trip() {
let cases = [
(1u16, ApiErrorCode::CellNotFound, false),
(2, ApiErrorCode::InvalidApiKey, false),
(3, ApiErrorCode::InvalidInput, false),
(4, ApiErrorCode::NeedsWhitelisting, false),
(5, ApiErrorCode::ServerError, true),
(6, ApiErrorCode::TooManyRequests, true),
(7, ApiErrorCode::DailyLimitExceeded, false),
(99, ApiErrorCode::Unknown(99), false),
];
for (code, expected, retryable) in cases {
let got = ApiErrorCode::from_code(code);
assert_eq!(got, expected, "from_code({code})");
assert_eq!(got.is_retryable(), retryable, "is_retryable for {code}");
}
}
#[test]
fn api_error_code_display_is_stable() {
assert_eq!(ApiErrorCode::CellNotFound.to_string(), "cell_not_found");
assert_eq!(ApiErrorCode::Unknown(42).to_string(), "unknown(42)");
}
#[test]
fn truncate_caps_length() {
let body = "a".repeat(2000);
let out = truncate_for_diagnostic(&body);
assert!(out.len() < body.len());
assert!(out.ends_with("(2000 bytes total)"));
}
#[test]
fn truncate_escapes_newlines() {
let body = "ok\nINJECT level=ERROR";
let out = truncate_for_diagnostic(body);
assert!(!out.contains('\n'));
assert!(out.contains("\\n"));
}
#[test]
fn truncate_passes_short_body_through() {
let body = "short";
assert_eq!(truncate_for_diagnostic(body), "short");
}
#[test]
fn parse_error_chain_via_std_error_source() {
use std::error::Error as _;
let inner = std::io::Error::other("disk gone");
let pe = ParseError::with_source("response read", inner);
assert!(pe.source().is_some());
let none = ParseError::new("bare");
assert!(none.source().is_none());
}
}