mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 14:24:47 -07:00
293 lines
8.4 KiB
Rust
293 lines
8.4 KiB
Rust
#![doc = include_str!("../README.md")]
|
|
|
|
use std::{borrow::Cow, fmt, io, path::PathBuf, result, time};
|
|
|
|
use thiserror::Error;
|
|
|
|
pub type Result<T, E = Error> = result::Result<T, E>;
|
|
|
|
/// Convert `Option<T>` → `Result<T>` without panicking.
|
|
///
|
|
/// Replaces `.unwrap()` in query paths so a missing value returns
|
|
/// HTTP 500 instead of crashing the server (`panic = "abort"`).
|
|
pub trait OptionData<T> {
|
|
fn data(self) -> Result<T>;
|
|
}
|
|
|
|
impl<T> OptionData<T> for Option<T> {
|
|
#[inline]
|
|
fn data(self) -> Result<T> {
|
|
self.ok_or(Error::Internal("data unavailable"))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum Error {
|
|
#[error(transparent)]
|
|
IO(#[from] io::Error),
|
|
|
|
#[cfg(feature = "corepc")]
|
|
#[error(transparent)]
|
|
CorepcRPC(#[from] corepc_jsonrpc::error::Error),
|
|
|
|
#[cfg(feature = "jiff")]
|
|
#[error(transparent)]
|
|
Jiff(#[from] jiff::Error),
|
|
|
|
#[cfg(feature = "fjall")]
|
|
#[error(transparent)]
|
|
Fjall(#[from] fjall::Error),
|
|
|
|
#[cfg(feature = "vecdb")]
|
|
#[error(transparent)]
|
|
VecDB(#[from] vecdb::Error),
|
|
|
|
#[cfg(feature = "vecdb")]
|
|
#[error(transparent)]
|
|
RawDB(#[from] vecdb::RawDBError),
|
|
|
|
#[cfg(feature = "ureq")]
|
|
#[error(transparent)]
|
|
Ureq(#[from] ureq::Error),
|
|
|
|
#[error(transparent)]
|
|
SystemTimeError(#[from] time::SystemTimeError),
|
|
|
|
#[cfg(feature = "bitcoin")]
|
|
#[error(transparent)]
|
|
BitcoinConsensusEncode(#[from] bitcoin::consensus::encode::Error),
|
|
|
|
#[cfg(feature = "bitcoin")]
|
|
#[error(transparent)]
|
|
BitcoinBip34Error(#[from] bitcoin::block::Bip34Error),
|
|
|
|
#[cfg(feature = "bitcoin")]
|
|
#[error(transparent)]
|
|
BitcoinHexError(#[from] bitcoin::consensus::encode::FromHexError),
|
|
|
|
#[cfg(feature = "bitcoin")]
|
|
#[error(transparent)]
|
|
BitcoinFromScriptError(#[from] bitcoin::address::FromScriptError),
|
|
|
|
#[cfg(feature = "bitcoin")]
|
|
#[error(transparent)]
|
|
BitcoinHexToArrayError(#[from] bitcoin::hex::HexToArrayError),
|
|
|
|
#[cfg(feature = "pco")]
|
|
#[error(transparent)]
|
|
Pco(#[from] pco::errors::PcoError),
|
|
|
|
#[cfg(feature = "serde_json")]
|
|
#[error(transparent)]
|
|
SerdeJSON(#[from] serde_json::Error),
|
|
|
|
#[cfg(feature = "tokio")]
|
|
#[error(transparent)]
|
|
TokioJoin(#[from] tokio::task::JoinError),
|
|
|
|
#[error("ZeroCopy error")]
|
|
ZeroCopyError,
|
|
|
|
#[error("Wrong length, expected: {expected}, received: {received}")]
|
|
WrongLength { expected: usize, received: usize },
|
|
|
|
#[error("Wrong address type")]
|
|
WrongAddrType,
|
|
|
|
#[error("Date cannot be indexed, must be 2009-01-03, 2009-01-09 or greater")]
|
|
UnindexableDate,
|
|
|
|
#[error("Quick cache error")]
|
|
QuickCacheError,
|
|
|
|
#[error("The provided address appears to be invalid")]
|
|
InvalidAddr,
|
|
|
|
#[error("Invalid network")]
|
|
InvalidNetwork,
|
|
|
|
#[error("The provided TXID appears to be invalid")]
|
|
InvalidTxid,
|
|
|
|
#[error("Mempool data is not available")]
|
|
MempoolNotAvailable,
|
|
|
|
#[error("Address not found in the blockchain (no transaction history)")]
|
|
UnknownAddr,
|
|
|
|
#[error("Failed to find the TXID in the blockchain")]
|
|
UnknownTxid,
|
|
|
|
#[error("Unsupported type ({0})")]
|
|
UnsupportedType(String),
|
|
|
|
// Generic errors with context
|
|
#[error("{0}")]
|
|
NotFound(String),
|
|
|
|
#[error("{0}")]
|
|
OutOfRange(Cow<'static, str>),
|
|
|
|
#[error("{0}")]
|
|
Parse(String),
|
|
|
|
#[error("Internal error: {0}")]
|
|
Internal(&'static str),
|
|
|
|
#[error("Authentication failed")]
|
|
AuthFailed,
|
|
|
|
// Series-specific errors
|
|
#[error("{0}")]
|
|
SeriesNotFound(SeriesNotFound),
|
|
|
|
#[error("'{series}' doesn't support the requested index. Try: {supported}")]
|
|
SeriesUnsupportedIndex { series: String, supported: String },
|
|
|
|
#[error("No series specified")]
|
|
NoSeries,
|
|
|
|
#[error("No data available")]
|
|
NoData,
|
|
|
|
#[error("Request weight {requested} exceeds maximum {max}")]
|
|
WeightExceeded { requested: usize, max: usize },
|
|
|
|
#[error("Too many unspent transaction outputs (>1000).")]
|
|
TooManyUtxos,
|
|
|
|
#[error("Deserialization error: {0}")]
|
|
Deserialization(String),
|
|
|
|
#[error("Fetch failed after retries: {0}")]
|
|
FetchFailed(String),
|
|
|
|
#[error("HTTP {status}: {url}")]
|
|
HttpStatus { status: u16, url: String },
|
|
|
|
#[error("Version mismatch at {path:?}: expected {expected}, found {found}")]
|
|
VersionMismatch {
|
|
path: PathBuf,
|
|
expected: usize,
|
|
found: usize,
|
|
},
|
|
}
|
|
|
|
impl Error {
|
|
/// Returns true if this error is due to a file lock (another process has the database open).
|
|
/// Lock errors are transient and should not trigger data deletion.
|
|
#[cfg(feature = "vecdb")]
|
|
pub fn is_lock_error(&self) -> bool {
|
|
matches!(self, Error::VecDB(e) if e.is_lock_error())
|
|
}
|
|
|
|
/// Returns true if this error indicates data corruption or version incompatibility.
|
|
/// These errors may require resetting/deleting the data to recover.
|
|
#[cfg(feature = "vecdb")]
|
|
pub fn is_data_error(&self) -> bool {
|
|
matches!(self, Error::VecDB(e) if e.is_data_error())
|
|
|| matches!(self, Error::VersionMismatch { .. })
|
|
}
|
|
|
|
/// Returns true if this network/fetch error indicates a permanent/blocking condition
|
|
/// that won't be resolved by retrying (e.g., DNS failure, connection refused, blocked endpoint).
|
|
/// Returns false for transient errors worth retrying (timeouts, rate limits, server errors).
|
|
pub fn is_network_permanently_blocked(&self) -> bool {
|
|
match self {
|
|
#[cfg(feature = "ureq")]
|
|
Error::Ureq(e) => is_ureq_error_permanent(e),
|
|
Error::IO(e) => is_io_error_permanent(e),
|
|
// 403 Forbidden suggests IP/geo blocking; 429 and 5xx are transient
|
|
Error::HttpStatus { status, .. } => *status == 403,
|
|
// Other errors are data/parsing related, not network - treat as transient
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "ureq")]
|
|
fn is_ureq_error_permanent(e: &ureq::Error) -> bool {
|
|
let msg = format!("{:?}", e);
|
|
msg.contains("nodename nor servname")
|
|
|| msg.contains("Name or service not known")
|
|
|| msg.contains("No such host")
|
|
|| msg.contains("connection refused")
|
|
|| msg.contains("Connection refused")
|
|
|| msg.contains("certificate")
|
|
|| msg.contains("SSL")
|
|
|| msg.contains("TLS")
|
|
|| msg.contains("handshake")
|
|
}
|
|
|
|
fn is_io_error_permanent(e: &std::io::Error) -> bool {
|
|
use std::io::ErrorKind::*;
|
|
match e.kind() {
|
|
// Permanent errors
|
|
ConnectionRefused | PermissionDenied | AddrNotAvailable => true,
|
|
// Check the error message for DNS failures
|
|
_ => {
|
|
let msg = e.to_string();
|
|
msg.contains("nodename nor servname")
|
|
|| msg.contains("Name or service not known")
|
|
|| msg.contains("No such host")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Maximum length of a user-supplied series name in error messages before
|
|
/// truncating with an ellipsis.
|
|
const SERIES_NAME_MAX_DISPLAY_LEN: usize = 100;
|
|
|
|
/// Truncate a user-supplied series name for inclusion in an error message,
|
|
/// appending an ellipsis if it exceeds the display cap. Used for both
|
|
/// `SeriesNotFound` and `SeriesUnsupportedIndex` so far-too-long names don't
|
|
/// blow up the response body.
|
|
pub fn truncate_series_name(mut series: String) -> String {
|
|
if series.len() > SERIES_NAME_MAX_DISPLAY_LEN {
|
|
series.truncate(SERIES_NAME_MAX_DISPLAY_LEN);
|
|
series.push_str("...");
|
|
}
|
|
series
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SeriesNotFound {
|
|
pub series: String,
|
|
pub suggestions: Vec<&'static str>,
|
|
pub total_matches: usize,
|
|
}
|
|
|
|
impl SeriesNotFound {
|
|
pub fn new(series: String, suggestions: Vec<&'static str>, total_matches: usize) -> Self {
|
|
Self {
|
|
series: truncate_series_name(series),
|
|
suggestions,
|
|
total_matches,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for SeriesNotFound {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "'{}' not found", self.series)?;
|
|
|
|
if self.suggestions.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let quoted: Vec<_> = self.suggestions.iter().map(|s| format!("'{s}'")).collect();
|
|
write!(f, ", did you mean {}?", quoted.join(", "))?;
|
|
|
|
let remaining = self.total_matches.saturating_sub(self.suggestions.len());
|
|
if remaining > 0 {
|
|
write!(
|
|
f,
|
|
" ({remaining} more — /api/series/search?q={} for all)",
|
|
self.series
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|