fynd-rpc 0.112.0

HTTP RPC server for Fynd DEX router
//! API error types and error response handling.

use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use fynd_core::{EncodingOptionsError, OrderValidationError, SolveError};
pub use fynd_rpc_types::ErrorResponse;
use tracing::warn;

/// API error type that converts to HTTP responses.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    /// Invalid request format or parameters.
    #[error("bad request: {0}")]
    BadRequest(String),

    /// A well-formed request that breaks a validation rule. Answers 400 like [`Self::BadRequest`],
    /// with the rule's own code so a client can tell which field to fix.
    #[error("invalid request: {0}")]
    InvalidRequest(#[from] RequestValidationError),

    /// Solve operation failed.
    #[error("solve failed: {0}")]
    SolveFailed(#[from] SolveError),

    /// Queue is full, try again later.
    #[error("service overloaded, try again later")]
    ServiceOverloaded,

    /// Required service data has not been initialized yet.
    #[error("data not ready: {0}")]
    NotReady(String),

    /// Internal server error.
    #[error("internal error: {0}")]
    Internal(String),

    /// Market data is stale.
    #[error("market data stale: last update {age_ms}ms ago")]
    #[non_exhaustive]
    StaleData {
        /// Milliseconds since the last successful market-data update.
        age_ms: u64,
    },
}

/// A rule a quote request broke before solving. Every variant has a stable code, see
/// [`Self::code`].
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum RequestValidationError {
    /// `orders` is empty.
    #[error("no orders provided")]
    NoOrders,
    /// An order failed [`fynd_core::Order::validate`].
    #[error("invalid order {order_id}: {source}")]
    InvalidOrder {
        /// The `id` of the order that failed.
        order_id: String,
        /// Why it failed.
        source: OrderValidationError,
    },
    /// The encoding options failed [`fynd_core::EncodingOptions::validate`].
    #[error("invalid encoding options: {0}")]
    InvalidEncodingOptions(#[from] EncodingOptionsError),
}

impl RequestValidationError {
    /// The stable machine-readable code the error response carries for this rule.
    pub fn code(&self) -> &'static str {
        match self {
            Self::NoOrders => "NO_ORDERS",
            Self::InvalidOrder { source, .. } => match source {
                OrderValidationError::SameTokens => "SAME_TOKENS",
                OrderValidationError::ZeroAmount => "ZERO_AMOUNT",
                other => {
                    warn!(?other, "unhandled OrderValidationError variant");
                    "INVALID_ORDER"
                }
            },
            Self::InvalidEncodingOptions(source) => match source {
                EncodingOptionsError::InvalidSlippage(_) => "INVALID_SLIPPAGE",
                EncodingOptionsError::ClientFeeTooHigh { .. } => "CLIENT_FEE_TOO_HIGH",
                other => {
                    warn!(?other, "unhandled EncodingOptionsError variant");
                    "INVALID_ENCODING_OPTIONS"
                }
            },
        }
    }
}

/// Maps a [`SolveError`] to its stable machine-readable code.
///
/// Shared by the HTTP error response and the request-replay capture log so both
/// use identical codes.
pub(crate) fn solve_error_code(err: &SolveError) -> &'static str {
    match err {
        SolveError::NoRouteFound { .. } => "NO_ROUTE_FOUND",
        // A rejected route is still "we could not give you a route": the wire code stays the one
        // integrators already branch on, and the reason says which kind it was.
        SolveError::RouteRejected { .. } => "NO_ROUTE_FOUND",
        SolveError::InsufficientLiquidity { .. } => "INSUFFICIENT_LIQUIDITY",
        SolveError::Timeout { .. } => "TIMEOUT",
        SolveError::QueueFull => "QUEUE_FULL",
        SolveError::AlgorithmError(_) => "ALGORITHM_ERROR",
        SolveError::MarketDataStale { .. } => "STALE_DATA",
        SolveError::InvalidOrder(_) => "INVALID_ORDER",
        SolveError::Internal(_) => "INTERNAL_ERROR",
        SolveError::InvalidWorkerPools(_) => "INVALID_WORKER_POOLS",
        SolveError::NotReady(_) => "NOT_READY",
        SolveError::ComputationFailed(_) => "COMPUTATION_FAILED",
        SolveError::FailedEncoding(_) => "FAILED_ENCODING",
        SolveError::EncodingUnavailable(_) => "ENCODING_UNAVAILABLE",
        SolveError::PriceCheckFailed { .. } => "PRICE_CHECK_FAILED",
        SolveError::MaxGasExceeded => "MAX_GAS_EXCEEDED",
        SolveError::MissingData(_) => "MISSING_DATA",
        SolveError::SimulationFailed(_) => "SIMULATION_FAILED",
        other => {
            warn!(?other, "unhandled SolveError variant");
            "INTERNAL_ERROR"
        }
    }
}

impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match self {
            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
            ApiError::InvalidRequest(_) => StatusCode::BAD_REQUEST,
            ApiError::SolveFailed(e) => match e {
                SolveError::QueueFull => StatusCode::SERVICE_UNAVAILABLE,
                SolveError::Timeout { .. } => StatusCode::SERVICE_UNAVAILABLE,
                SolveError::MarketDataStale { .. } => StatusCode::SERVICE_UNAVAILABLE,
                SolveError::ComputationFailed(_) => StatusCode::SERVICE_UNAVAILABLE,
                SolveError::EncodingUnavailable(_) => StatusCode::NOT_IMPLEMENTED,
                _ => StatusCode::UNPROCESSABLE_ENTITY,
            },
            ApiError::ServiceOverloaded => StatusCode::SERVICE_UNAVAILABLE,
            ApiError::NotReady(_) => StatusCode::SERVICE_UNAVAILABLE,
            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
            ApiError::StaleData { .. } => StatusCode::SERVICE_UNAVAILABLE,
        }
    }

    fn error_response(&self) -> HttpResponse {
        let code = match self {
            ApiError::BadRequest(_) => "BAD_REQUEST",
            ApiError::InvalidRequest(e) => e.code(),
            ApiError::SolveFailed(e) => solve_error_code(e),
            ApiError::ServiceOverloaded => "SERVICE_OVERLOADED",
            ApiError::NotReady(_) => "NOT_READY",
            ApiError::Internal(_) => "INTERNAL_ERROR",
            ApiError::StaleData { .. } => "STALE_DATA",
        };

        let response = ErrorResponse::new(self.to_string(), code.to_string());

        HttpResponse::build(self.status_code()).json(response)
    }
}

impl From<serde_json::Error> for ApiError {
    fn from(err: serde_json::Error) -> Self {
        ApiError::BadRequest(format!("invalid JSON: {}", err))
    }
}

#[cfg(test)]
mod tests {
    use actix_web::{body::to_bytes, http::StatusCode, ResponseError};
    use fynd_core::{OrderValidationError, SolveError};
    use num_bigint::BigUint;
    use serde_json::Value;

    use super::{ApiError, RequestValidationError};

    async fn json_body(err: ApiError) -> (StatusCode, Value) {
        let status = err.status_code();
        let resp = err.error_response();
        let bytes = to_bytes(resp.into_body())
            .await
            .unwrap();
        let body: Value = serde_json::from_slice(&bytes).unwrap();
        (status, body)
    }

    #[actix_web::test]
    async fn test_bad_request() {
        let (status, body) = json_body(ApiError::BadRequest("missing field".into())).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(body["code"], "BAD_REQUEST");
    }

    /// The status is 400 and the code is the rule's own; the handler tests cover each rule's code.
    #[actix_web::test]
    async fn test_invalid_request() {
        let err = RequestValidationError::InvalidOrder {
            order_id: "o1".to_string(),
            source: OrderValidationError::SameTokens,
        };
        let (status, body) = json_body(ApiError::InvalidRequest(err)).await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        assert_eq!(body["code"], "SAME_TOKENS");
    }

    #[actix_web::test]
    async fn test_no_route_found() {
        let (status, body) =
            json_body(ApiError::SolveFailed(SolveError::no_route_found("order-1"))).await;
        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(body["code"], "NO_ROUTE_FOUND");
    }

    /// A rejected route is a route the deployment could not price, not a server fault: it keeps
    /// the wire code integrators already branch on.
    #[actix_web::test]
    async fn test_route_rejected() {
        let (status, body) = json_body(ApiError::SolveFailed(SolveError::route_rejected(
            "order-1",
            fynd_core::types::RouteRejection::FallbackPoolMissing,
        )))
        .await;

        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(body["code"], "NO_ROUTE_FOUND");
    }

    #[actix_web::test]
    async fn test_insufficient_liquidity() {
        let err = SolveError::insufficient_liquidity(BigUint::from(100u64), BigUint::from(50u64));
        let (status, body) = json_body(ApiError::SolveFailed(err)).await;
        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(body["code"], "INSUFFICIENT_LIQUIDITY");
    }

    #[actix_web::test]
    async fn test_timeout() {
        let (status, body) = json_body(ApiError::SolveFailed(SolveError::timeout(100))).await;
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body["code"], "TIMEOUT");
    }

    #[actix_web::test]
    async fn test_queue_full() {
        let (status, body) = json_body(ApiError::SolveFailed(SolveError::QueueFull)).await;
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body["code"], "QUEUE_FULL");
    }

    #[actix_web::test]
    async fn test_service_overloaded() {
        let (status, body) = json_body(ApiError::ServiceOverloaded).await;
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body["code"], "SERVICE_OVERLOADED");
    }

    #[actix_web::test]
    async fn test_not_ready() {
        let (status, body) =
            json_body(ApiError::NotReady("Tycho head is unavailable".into())).await;
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body["code"], "NOT_READY");
        assert_eq!(body["error"], "data not ready: Tycho head is unavailable");
    }

    #[actix_web::test]
    async fn test_internal_error() {
        let (status, body) = json_body(ApiError::Internal("db down".into())).await;
        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
        assert_eq!(body["code"], "INTERNAL_ERROR");
    }

    #[actix_web::test]
    async fn test_stale_data() {
        let (status, body) = json_body(ApiError::StaleData { age_ms: 90_000 }).await;
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body["code"], "STALE_DATA");
    }

    #[actix_web::test]
    async fn test_market_data_stale_via_solve_failed() {
        let err = SolveError::market_data_stale(5_000);
        let (status, body) = json_body(ApiError::SolveFailed(err)).await;
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body["code"], "STALE_DATA");
    }

    #[actix_web::test]
    async fn test_encoding_unavailable_returns_501() {
        let err =
            SolveError::EncodingUnavailable("no router configured for this chain".to_string());
        let (status, body) = json_body(ApiError::SolveFailed(err)).await;
        assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
        assert_eq!(body["code"], "ENCODING_UNAVAILABLE");
    }

    #[actix_web::test]
    async fn test_failed_encoding_returns_422() {
        let err = SolveError::FailedEncoding("missing permit2 signature".to_string());
        let (status, body) = json_body(ApiError::SolveFailed(err)).await;
        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(body["code"], "FAILED_ENCODING");
    }
}