use std::time::Duration;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("configuration error: {message}")]
Configuration {
message: String,
},
#[error(
"missing credentials: this request must be signed, but the client was built without an API key and private key"
)]
MissingCredentials,
#[error("invalid private key: {message}")]
Key {
message: String,
},
#[error("key generation error: {message}")]
KeyGeneration {
message: String,
},
#[error("signing error: {message}")]
Signing {
message: String,
},
#[error("invalid request: {message}")]
InvalidRequest {
message: String,
},
#[cfg(feature = "rest")]
#[error("transport error: {0}")]
Transport(#[source] reqwest::Error),
#[error("failed to serialize request body: {0}")]
Serialize(#[source] serde_json::Error),
#[error("failed to deserialize response from {method} {path}: {source}")]
Deserialize {
method: String,
path: String,
#[source]
source: serde_json::Error,
body: String,
},
#[error("{0}")]
Api(#[from] ApiError),
#[cfg(feature = "agent")]
#[error("agent error: {message}")]
Agent {
message: String,
},
#[error("unexpected response: HTTP {status}: {body}")]
Unexpected {
status: u16,
body: String,
},
}
impl Error {
#[cfg(feature = "rest")]
pub(crate) fn configuration(message: impl Into<String>) -> Self {
Self::Configuration {
message: message.into(),
}
}
#[cfg(feature = "rest")]
pub(crate) fn key(message: impl Into<String>) -> Self {
Self::Key {
message: message.into(),
}
}
#[cfg(feature = "agent")]
pub(crate) fn agent(message: impl Into<String>) -> Self {
Self::Agent {
message: message.into(),
}
}
pub(crate) fn invalid_request(message: impl Into<String>) -> Self {
Self::InvalidRequest {
message: message.into(),
}
}
pub const fn status(&self) -> Option<u16> {
match self {
Self::Api(api) => Some(api.status),
Self::Unexpected { status, .. } => Some(*status),
_ => None,
}
}
pub const fn api_error(&self) -> Option<&ApiError> {
match self {
Self::Api(api) => Some(api),
_ => None,
}
}
pub fn is_rate_limited(&self) -> bool {
matches!(self, Self::Api(api) if api.kind == ApiErrorKind::RateLimited)
}
pub const fn is_auth_error(&self) -> bool {
match self {
Self::MissingCredentials => true,
Self::Api(api) => {
matches!(
api.kind,
ApiErrorKind::Unauthorized | ApiErrorKind::Forbidden
)
}
_ => false,
}
}
pub fn retry_after(&self) -> Option<Duration> {
self.api_error().and_then(|api| api.retry_after)
}
}
#[derive(Debug, Clone)]
pub struct ApiError {
pub status: u16,
pub kind: ApiErrorKind,
pub message: String,
pub error_id: Option<String>,
pub timestamp: Option<i64>,
pub retry_after: Option<Duration>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"API error (HTTP {} {}): {}",
self.status, self.kind, self.message
)?;
if let Some(id) = &self.error_id {
write!(f, " [error_id={id}]")?;
}
Ok(())
}
}
impl std::error::Error for ApiError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorKind {
BadRequest,
Unauthorized,
Forbidden,
NotFound,
Conflict,
RateLimited,
Server,
Other,
}
#[cfg(feature = "rest")]
impl ApiErrorKind {
const fn from_status(status: u16) -> Self {
match status {
400 => Self::BadRequest,
401 => Self::Unauthorized,
403 => Self::Forbidden,
404 => Self::NotFound,
409 => Self::Conflict,
429 => Self::RateLimited,
500..=599 => Self::Server,
_ => Self::Other,
}
}
}
impl std::fmt::Display for ApiErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::BadRequest => "bad request",
Self::Unauthorized => "unauthorized",
Self::Forbidden => "forbidden",
Self::NotFound => "not found",
Self::Conflict => "conflict",
Self::RateLimited => "rate limited",
Self::Server => "server error",
Self::Other => "error",
};
f.write_str(s)
}
}
#[cfg(feature = "rest")]
#[derive(Debug, serde::Deserialize, Default)]
struct ErrorPayload {
#[serde(default)]
message: Option<String>,
#[serde(default)]
error_id: Option<String>,
#[serde(default)]
timestamp: Option<i64>,
}
#[cfg(feature = "rest")]
const MAX_BODY_PREVIEW: usize = 2048;
#[cfg(feature = "rest")]
fn truncate_body(body: &str) -> String {
if body.len() <= MAX_BODY_PREVIEW {
body.to_owned()
} else {
let mut end = MAX_BODY_PREVIEW;
while !body.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes total)", &body[..end], body.len())
}
}
#[cfg(feature = "rest")]
pub(crate) fn classify_error_response(
status: u16,
retry_after: Option<Duration>,
body: &[u8],
) -> Error {
let payload = serde_json::from_slice::<ErrorPayload>(body).unwrap_or_default();
let kind = ApiErrorKind::from_status(status);
if payload.message.is_some()
|| payload.error_id.is_some()
|| !matches!(kind, ApiErrorKind::Other)
{
Error::Api(ApiError {
status,
kind,
message: payload
.message
.unwrap_or_else(|| reason_phrase(status).to_owned()),
error_id: payload.error_id,
timestamp: payload.timestamp,
retry_after,
})
} else {
Error::Unexpected {
status,
body: truncate_body(&String::from_utf8_lossy(body)),
}
}
}
#[cfg(feature = "rest")]
const fn reason_phrase(status: u16) -> &'static str {
match status {
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
429 => "Too Many Requests",
500 => "Internal Server Error",
_ => "Error",
}
}
#[cfg(all(test, feature = "rest"))]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn err_body(status: u16) -> Error {
let body = br#"{"message":"No such pair: BTC-BTC","error_id":"7d85b5e7-d0f0-4696-b7b5-a300d0d03a5e","timestamp":3318215482991}"#;
classify_error_response(status, None, body)
}
#[test]
fn classifies_known_status_codes() {
let cases = [
(400u16, ApiErrorKind::BadRequest),
(401, ApiErrorKind::Unauthorized),
(403, ApiErrorKind::Forbidden),
(404, ApiErrorKind::NotFound),
(409, ApiErrorKind::Conflict),
(429, ApiErrorKind::RateLimited),
(503, ApiErrorKind::Server),
];
for (status, kind) in cases {
let err = err_body(status);
let api = err.api_error().expect("structured api error");
assert_eq!(api.status, status);
assert_eq!(api.kind, kind);
assert_eq!(api.message, "No such pair: BTC-BTC");
assert_eq!(err.status(), Some(status));
}
}
#[test]
fn detects_rate_limit_and_retry_after() {
let err = classify_error_response(
429,
Some(Duration::from_secs(5)),
br#"{"message":"Rate Limit Exceeded","error_id":"x"}"#,
);
assert!(err.is_rate_limited());
assert_eq!(err.retry_after(), Some(Duration::from_secs(5)));
}
#[test]
fn detects_auth_errors() {
assert!(err_body(401).is_auth_error());
assert!(err_body(403).is_auth_error());
assert!(Error::MissingCredentials.is_auth_error());
assert!(!err_body(400).is_auth_error());
}
#[test]
fn known_status_classifies_even_without_a_structured_body() {
let err =
classify_error_response(429, Some(Duration::from_secs(5)), b"<html>slow down</html>");
assert!(err.is_rate_limited());
assert_eq!(err.status(), Some(429));
assert_eq!(err.retry_after(), Some(Duration::from_secs(5)));
let err = classify_error_response(502, None, b"<html>bad gateway</html>");
assert_eq!(
err.api_error().map(|api| api.kind),
Some(ApiErrorKind::Server)
);
}
#[test]
fn unknown_status_with_unstructured_body_is_unexpected() {
let err = classify_error_response(418, None, b"i am a teapot");
assert!(matches!(err, Error::Unexpected { status: 418, .. }));
assert_eq!(err.status(), Some(418));
assert!(err.api_error().is_none());
}
}