1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
use serde::Deserialize;
use thiserror::Error;
/// Everything a shardd SDK call can fail with.
#[derive(Debug, Error)]
pub enum ShardError {
/// 400 — the server rejected the request shape (missing nonce,
/// oversized note, invalid amount).
#[error("invalid input: {0}")]
InvalidInput(String),
/// 401 — the API key is missing, malformed, or revoked.
#[error("unauthorized: {0}")]
Unauthorized(String),
/// 403 — the API key is valid but lacks permission for this
/// bucket / action, or the account is frozen.
#[error("forbidden: {0}")]
Forbidden(String),
/// 404 — bucket, account, or route not found.
#[error("not found: {0}")]
NotFound(String),
/// 422 — the debit would exceed available balance plus any
/// `max_overdraft`. `available_balance` tells you how short you are.
///
/// `hold_blocking` is set when the rejection is caused only by
/// the implicit `hold_multiplier × |amount|` reservation — the
/// bare debit math (`available_balance + amount`) would have
/// cleared the floor. Callers can retry with `skip_hold: true`
/// (or a smaller explicit `hold_amount`); `hint`, when present,
/// is a one-line operator-facing message saying as much.
#[error("insufficient funds: balance={balance}, available={available_balance}")]
InsufficientFunds {
balance: i64,
available_balance: i64,
/// The `max_overdraft` the request opted into (0 if none).
limit: i64,
/// True when the rejection is purely caused by the implicit
/// hold; false otherwise.
hold_blocking: bool,
/// Operator-facing hint when `hold_blocking`. None otherwise.
hint: Option<String>,
},
/// 402 — the account is out of credits and no top-up plan is active.
#[error("payment required")]
PaymentRequired,
/// 503/504, or timeout/connection failure after failover was exhausted.
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
/// Client-side timeout. Retries could succeed.
#[error("request timed out")]
RequestTimeout,
/// The response body didn't match the expected shape.
#[error("decode error: {0}")]
Decode(String),
/// Transport-level failure (DNS, TLS, connect, etc.) that exhausted
/// every candidate edge.
#[error("network error: {0}")]
Network(String),
}
impl ShardError {
/// `true` for errors where retrying (on the same edge, after a
/// backoff, or on a different edge) might succeed. Applied by the
/// internal failover loop; also useful for app-level retry policy.
pub fn is_retryable(&self) -> bool {
matches!(
self,
ShardError::ServiceUnavailable(_) | ShardError::RequestTimeout | ShardError::Network(_)
)
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct GatewayErrorBody {
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub balance: Option<i64>,
#[serde(default)]
pub available_balance: Option<i64>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub hold_blocking: Option<bool>,
#[serde(default)]
pub hint: Option<String>,
}
impl GatewayErrorBody {
pub fn message_text(&self) -> String {
self.error
.clone()
.or_else(|| self.message.clone())
.unwrap_or_else(|| "unknown error".to_string())
}
}
pub(crate) fn from_status(status: u16, body: Option<GatewayErrorBody>) -> ShardError {
let text = body
.as_ref()
.map(|b| b.message_text())
.unwrap_or_else(|| format!("HTTP {status}"));
match status {
400 => ShardError::InvalidInput(text),
401 => ShardError::Unauthorized(text),
402 => ShardError::PaymentRequired,
403 => ShardError::Forbidden(text),
404 => ShardError::NotFound(text),
422 => {
let b = body.unwrap_or(GatewayErrorBody {
error: None,
message: None,
balance: None,
available_balance: None,
limit: None,
hold_blocking: None,
hint: None,
});
ShardError::InsufficientFunds {
balance: b.balance.unwrap_or(0),
available_balance: b.available_balance.unwrap_or(0),
limit: b.limit.unwrap_or(0),
hold_blocking: b.hold_blocking.unwrap_or(false),
hint: b.hint,
}
}
503 | 504 => ShardError::ServiceUnavailable(text),
_ => ShardError::Decode(format!("unexpected HTTP {status}: {text}")),
}
}