newton-core 0.7.1

newton protocol core sdk
//! Whether a failed RPC call still proves the provider is serving requests.
//!
//! Provider-health metrics must separate "the provider answered, and the
//! answer was an error" from "the provider did not answer". A revert,
//! `nonce too low`, `already known`, or `insufficient funds` is a correct
//! answer about the request; retrying it against every provider in an ordered
//! fallback list returns the same answer from each, and none of them is down.

use alloy::transports::{RpcError, TransportErrorKind};
use std::error::Error;

/// JSON-RPC error codes a provider uses to report its own trouble rather than
/// a problem with the request: resource unavailable, limit exceeded, internal
/// error.
const PROVIDER_FAULT_CODES: [i64; 3] = [-32002, -32005, -32603];

/// Message fragments providers use for rate limiting under a generic code.
const PROVIDER_FAULT_MESSAGES: [&str; 3] = ["rate limit", "too many requests", "limit exceeded"];

/// Whether `error` shows the provider answered the request.
///
/// Walks the source chain to the first alloy transport or contract error. A
/// JSON-RPC error response counts as answered unless its code or message
/// reports a provider-side fault. Transport failures, empty responses, and
/// errors that carry no alloy error at all count as not answered, so an
/// unclassifiable failure still marks the provider down.
pub fn provider_answered(error: &(dyn Error + 'static)) -> bool {
    for cause in std::iter::successors(Some(error), |&cause| cause.source()) {
        if let Some(transport) = cause.downcast_ref::<RpcError<TransportErrorKind>>() {
            return transport_answered(transport);
        }
        if let Some(contract) = cause.downcast_ref::<alloy::contract::Error>() {
            return match contract {
                alloy::contract::Error::TransportError(transport) => transport_answered(transport),
                // Every other variant is raised after a response arrived,
                // while decoding it locally.
                _ => true,
            };
        }
    }
    false
}

fn transport_answered(error: &RpcError<TransportErrorKind>) -> bool {
    let Some(payload) = error.as_error_resp() else {
        return false;
    };
    if PROVIDER_FAULT_CODES.contains(&payload.code) {
        return false;
    }
    let message = payload.message.to_lowercase();
    !PROVIDER_FAULT_MESSAGES
        .iter()
        .any(|fragment| message.contains(fragment))
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloy::rpc::json_rpc::ErrorPayload;

    fn response(code: i64, message: &'static str) -> RpcError<TransportErrorKind> {
        RpcError::ErrorResp(ErrorPayload {
            code,
            message: message.into(),
            data: None,
        })
    }

    #[test]
    fn request_errors_count_as_answered() {
        assert!(provider_answered(&response(3, "execution reverted")));
        assert!(provider_answered(&response(-32000, "nonce too low")));
        assert!(provider_answered(&response(-32000, "already known")));
        assert!(provider_answered(&response(
            -32000,
            "insufficient funds for gas * price + value"
        )));
    }

    #[test]
    fn provider_faults_count_as_not_answered() {
        assert!(!provider_answered(&response(-32005, "limit exceeded")));
        assert!(!provider_answered(&response(-32603, "internal error")));
        assert!(!provider_answered(&response(-32000, "Too Many Requests")));
    }

    #[test]
    fn transport_failures_count_as_not_answered() {
        let error: RpcError<TransportErrorKind> = TransportErrorKind::custom_str("connection refused");
        assert!(!provider_answered(&error));
        assert!(!provider_answered(&RpcError::<TransportErrorKind>::NullResp));
    }

    #[test]
    fn contract_errors_delegate_to_the_transport_error() {
        let revert = alloy::contract::Error::TransportError(response(3, "execution reverted"));
        assert!(provider_answered(&revert));
        let down = alloy::contract::Error::TransportError(TransportErrorKind::custom_str("connection reset"));
        assert!(!provider_answered(&down));
    }

    #[test]
    fn wrapped_errors_are_classified_through_the_source_chain() {
        let report = eyre::Report::new(response(3, "execution reverted")).wrap_err("planning failed");
        assert!(provider_answered(report.as_ref()));
        let unrelated = eyre::eyre!("destination RPC returned no latest block");
        assert!(!provider_answered(unrelated.as_ref()));
    }
}