1use polyoxide_core::ApiError;
2use thiserror::Error;
3
4use crate::types::ParseTickSizeError;
5
6#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum ClobError {
15 #[error(transparent)]
17 Api(#[from] ApiError),
18
19 #[error("Crypto error: {0}")]
21 Crypto(String),
22
23 #[error("Alloy error: {0}")]
25 Alloy(String),
26
27 #[error(transparent)]
29 InvalidTickSize(#[from] ParseTickSizeError),
30
31 #[error("FAK order killed unmatched: {message}")]
45 FakUnmatched { message: String },
46
47 #[error("FOK order killed unfilled: {message}")]
53 FokUnfilled { message: String },
54}
55
56fn classify_order_kill(message: &str) -> Option<ClobError> {
77 let m = message.to_ascii_lowercase();
78 let owned = || message.to_string();
79
80 if m.contains("fak order") && (m.contains("no match") || m.contains("no orders found")) {
81 return Some(ClobError::FakUnmatched { message: owned() });
82 }
83 if m.contains("fok order") && (m.contains("fully filled") || m.contains("killed")) {
84 return Some(ClobError::FokUnfilled { message: owned() });
85 }
86 None
87}
88
89impl ClobError {
90 pub(crate) async fn from_response(response: reqwest::Response) -> Self {
92 match ApiError::from_response(response).await {
98 ApiError::Validation(msg) => {
99 classify_order_kill(&msg).unwrap_or(Self::Api(ApiError::Validation(msg)))
100 }
101 other => Self::Api(other),
102 }
103 }
104
105 pub fn is_retriable(&self) -> bool {
117 match self {
118 Self::Api(e) => e.is_retriable(),
119 Self::FakUnmatched { .. } | Self::FokUnfilled { .. } => false,
120 Self::Crypto(_) | Self::Alloy(_) | Self::InvalidTickSize(_) => false,
121 }
122 }
123
124 pub(crate) fn validation(msg: impl Into<String>) -> Self {
126 Self::Api(ApiError::Validation(msg.into()))
127 }
128
129 #[cfg_attr(not(feature = "gamma"), allow(dead_code))]
131 pub(crate) fn service(msg: impl Into<String>) -> Self {
132 Self::Api(ApiError::Api {
133 status: 0,
134 message: msg.into(),
135 })
136 }
137}
138
139impl From<alloy::signers::Error> for ClobError {
140 fn from(err: alloy::signers::Error) -> Self {
141 Self::Alloy(err.to_string())
142 }
143}
144
145impl From<alloy::hex::FromHexError> for ClobError {
146 fn from(err: alloy::hex::FromHexError) -> Self {
147 Self::Alloy(err.to_string())
148 }
149}
150
151impl From<reqwest::Error> for ClobError {
152 fn from(err: reqwest::Error) -> Self {
153 Self::Api(ApiError::Network(err))
154 }
155}
156
157impl From<url::ParseError> for ClobError {
158 fn from(err: url::ParseError) -> Self {
159 Self::Api(ApiError::Url(err))
160 }
161}
162
163impl From<serde_json::Error> for ClobError {
164 fn from(err: serde_json::Error) -> Self {
165 Self::Api(ApiError::Serialization(err))
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn test_service_error_is_api_not_validation() {
175 let err = ClobError::service("Gamma client failed");
176 match &err {
177 ClobError::Api(ApiError::Api { status, message }) => {
178 assert_eq!(*status, 0);
179 assert_eq!(message, "Gamma client failed");
180 }
181 other => panic!("Expected ApiError::Api, got {:?}", other),
182 }
183 }
184
185 #[test]
186 fn test_validation_error() {
187 let err = ClobError::validation("bad input");
188 match &err {
189 ClobError::Api(ApiError::Validation(msg)) => {
190 assert_eq!(msg, "bad input");
191 }
192 other => panic!("Expected ApiError::Validation, got {:?}", other),
193 }
194 }
195
196 #[test]
197 fn test_service_and_validation_are_distinct() {
198 let service = ClobError::service("service failure");
199 let validation = ClobError::validation("validation failure");
200
201 let service_msg = format!("{}", service);
202 let validation_msg = format!("{}", validation);
203
204 assert_ne!(service_msg, validation_msg);
206 assert!(service_msg.contains("service failure"));
207 assert!(validation_msg.contains("validation failure"));
208 }
209
210 #[test]
211 fn test_crypto_error() {
212 let err = ClobError::Crypto("signing failed".into());
213 assert!(err.to_string().contains("signing failed"));
214 assert!(matches!(err, ClobError::Crypto(_)));
215 }
216
217 #[test]
218 fn test_alloy_error() {
219 let err = ClobError::Alloy("hex decode failed".into());
220 assert!(err.to_string().contains("hex decode failed"));
221 assert!(matches!(err, ClobError::Alloy(_)));
222 }
223
224 #[test]
225 fn test_invalid_tick_size_from_str() {
226 let err: Result<crate::types::TickSize, _> = "0.5".try_into();
227 let clob_err = ClobError::from(err.unwrap_err());
228 assert!(matches!(clob_err, ClobError::InvalidTickSize(_)));
229 assert!(clob_err.to_string().contains("0.5"));
230 }
231
232 #[test]
233 fn test_from_serde_json_error() {
234 let json_err = serde_json::from_str::<String>("not valid json").unwrap_err();
235 let clob_err = ClobError::from(json_err);
236 assert!(matches!(
237 clob_err,
238 ClobError::Api(ApiError::Serialization(_))
239 ));
240 }
241
242 #[test]
243 fn test_from_url_parse_error() {
244 let url_err = url::Url::parse("://bad").unwrap_err();
245 let clob_err = ClobError::from(url_err);
246 assert!(matches!(clob_err, ClobError::Api(ApiError::Url(_))));
247 }
248
249 const FAK_UNMATCHED: &str = "no orders found to match with FAK order. \
253FAK orders are partially filled or killed if no match is found.";
254 const FOK_UNFILLED: &str =
255 "order couldn't be fully filled. FOK orders are fully filled or killed.";
256
257 #[test]
258 fn test_classify_recognizes_verbatim_venue_messages() {
259 assert!(matches!(
260 classify_order_kill(FAK_UNMATCHED),
261 Some(ClobError::FakUnmatched { .. })
262 ));
263 assert!(matches!(
264 classify_order_kill(FOK_UNFILLED),
265 Some(ClobError::FokUnfilled { .. })
266 ));
267 }
268
269 #[test]
270 fn test_classify_preserves_message_verbatim() {
271 match classify_order_kill(FAK_UNMATCHED) {
272 Some(ClobError::FakUnmatched { message }) => assert_eq!(message, FAK_UNMATCHED),
273 other => panic!("expected FakUnmatched, got {other:?}"),
274 }
275 }
276
277 #[test]
278 fn test_classify_is_case_insensitive() {
279 assert!(matches!(
281 classify_order_kill(&FAK_UNMATCHED.to_uppercase()),
282 Some(ClobError::FakUnmatched { .. })
283 ));
284 assert!(matches!(
285 classify_order_kill(&FOK_UNFILLED.to_lowercase()),
286 Some(ClobError::FokUnfilled { .. })
287 ));
288 }
289
290 #[test]
291 fn test_classify_tolerates_curly_apostrophe_in_fok_message() {
292 let curly = "order couldn\u{2019}t be fully filled. FOK orders are fully filled or killed.";
295 assert!(matches!(
296 classify_order_kill(curly),
297 Some(ClobError::FokUnfilled { .. })
298 ));
299 }
300
301 #[test]
302 fn test_classify_does_not_capture_neighbouring_400s() {
303 for msg in [
307 "Invalid order payload",
308 "the order owner has to be the owner of the API KEY",
309 "the order signer address has to be the address of the API KEY",
310 "'0x1234' address banned",
311 "'0x1234' address in closed only mode",
312 "Too many orders in payload: 20, max allowed: 15",
313 "invalid post-only order: order crosses book",
314 "order 0xabc is invalid. Price (100) breaks minimum tick size rule: 0.1",
315 "order 0xabc is invalid. Size (1) lower than the minimum: 5",
316 "order 0xabc is invalid. Duplicated.",
317 "order 0xabc crosses the book",
318 "not enough balance / allowance",
319 "invalid expiration",
320 "order canceled in the CTF exchange contract",
321 "order match delayed due to market conditions",
322 "the market is not yet ready to process new orders",
323 "invalid amount for a marketable BUY order ($0.50), min size: 1",
324 ] {
325 assert!(
326 classify_order_kill(msg).is_none(),
327 "must not classify as a kill outcome: {msg}"
328 );
329 }
330 }
331
332 #[test]
333 fn test_classify_requires_both_tokens() {
334 assert!(classify_order_kill("FAK order rejected: bad payload").is_none());
336 assert!(classify_order_kill("no orders found for this market").is_none());
337 assert!(classify_order_kill("FOK order rejected: bad payload").is_none());
338 }
339
340 #[test]
343 fn test_kill_outcomes_are_not_retriable() {
344 assert!(!ClobError::FakUnmatched {
346 message: FAK_UNMATCHED.into()
347 }
348 .is_retriable());
349 assert!(!ClobError::FokUnfilled {
350 message: FOK_UNFILLED.into()
351 }
352 .is_retriable());
353 }
354
355 #[test]
356 fn test_local_failures_are_not_retriable() {
357 assert!(!ClobError::Crypto("signing failed".into()).is_retriable());
358 assert!(!ClobError::Alloy("hex decode failed".into()).is_retriable());
359 assert!(!ClobError::validation("bad input").is_retriable());
360 }
361
362 #[test]
363 fn test_transient_failures_are_retriable() {
364 assert!(ClobError::Api(ApiError::RateLimit("slow down".into())).is_retriable());
365 assert!(ClobError::Api(ApiError::Timeout).is_retriable());
366 assert!(ClobError::Api(ApiError::Api {
367 status: 500,
368 message: "order timed out".into()
369 })
370 .is_retriable());
371 assert!(ClobError::Api(ApiError::Api {
373 status: 425,
374 message: String::new()
375 })
376 .is_retriable());
377 }
378
379 #[test]
380 fn test_kill_outcome_display_names_the_order_type() {
381 let fak = ClobError::FakUnmatched {
383 message: FAK_UNMATCHED.into(),
384 };
385 assert!(fak.to_string().starts_with("FAK order killed unmatched:"));
386 let fok = ClobError::FokUnfilled {
387 message: FOK_UNFILLED.into(),
388 };
389 assert!(fok.to_string().starts_with("FOK order killed unfilled:"));
390 }
391}