Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions crates/cow-venue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ videre-sdk = { path = "../videre-sdk", optional = true }
# reach a guest that links this slice.
nexum-sdk = { path = "../nexum-sdk", optional = true }
# `assembly` slice: the chain-edge order projections and orderbook
# submission bodies. Express-declared (not workspace-inherited) so the
# guest build never inherits the native `http-client` feature.
# submission bodies. The `client` slice also enables it for the typed
# `OrderbookApiErrorType` classifier boundary. Express-declared (not
# workspace-inherited) so the guest build never inherits the native
# `http-client` feature.
cowprotocol = { version = "0.2.0", default-features = false, optional = true }
# `body` slice: the order body fields are alloy `Address`/`U256`, and
# `borsh` supplies their borsh impls (ruint for `U256`). Optional only
Expand Down Expand Up @@ -60,8 +62,8 @@ toml = { workspace = true }
thiserror = { workspace = true }
# The conformance kit: holds the body codec to its published vector set.
videre-test = { path = "../videre-test" }
# Parity tests only: the upstream errorType enum and `retry_hint()` the
# shipped table is reconciled against. Never a runtime dependency.
# Parity tests: the upstream `retry_hint()` the shipped table is
# reconciled against.
cowprotocol = { version = "0.2.0", default-features = false }

[features]
Expand All @@ -71,7 +73,7 @@ cowprotocol = { version = "0.2.0", default-features = false }
# single slice without pulling the codec or the keeper transitively.
default = ["body"]
body = ["dep:borsh", "dep:videre-sdk", "dep:alloy-primitives"]
client = ["body", "dep:nexum-sdk"]
client = ["body", "dep:nexum-sdk", "dep:cowprotocol"]
# Chain-edge order assembly, shared by the adapter's submit and the
# keeper's legacy submit path. Carries no component glue, so a keeper
# module can link it without exporting the adapter face.
Expand Down
13 changes: 10 additions & 3 deletions crates/cow-venue/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ use std::sync::{PoisonError, RwLock};

use alloy_primitives::Address;
use cowprotocol::{
ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest,
ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, OrderbookApiErrorType,
QuoteAppData, QuoteRequest,
};
use nexum_sdk::keeper::RetryAction;
use serde::Deserialize;
Expand Down Expand Up @@ -399,7 +400,7 @@ fn refusal_for_submit(response: &http::Response<Vec<u8>>) -> Refusal {
)));
}
match serde_json::from_slice::<ApiError>(response.body()) {
Ok(api) if classification::is_already_submitted(&api.error_type) => Refusal::AlreadyHeld,
Ok(api) if classification::is_already_submitted(api.error_kind()) => Refusal::AlreadyHeld,
Ok(api) => Refusal::Error(classified(&api)),
Err(_) => Refusal::Error(VenueError::Unavailable(format!(
"orderbook status {status}"
Expand Down Expand Up @@ -436,7 +437,13 @@ fn retry_after_ms(response: &http::Response<Vec<u8>>) -> Option<u64> {
/// `rate-limited`, permanent rows (and any future action) are `denied`.
fn classified(api: &ApiError) -> VenueError {
let detail = format!("{}: {}", api.error_type, api.description);
match classification::classify(&api.error_type) {
let action = match api.error_kind() {
// A wire `errorType` the upstream enum does not know is by
// definition unlisted, hence permanent.
OrderbookApiErrorType::Unknown(_) => RetryAction::Drop,
kind => classification::classify(kind),
};
match action {
RetryAction::TryNextBlock => VenueError::Unavailable(detail),
RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit {
retry_after_ms: Some(seconds.saturating_mul(1000)),
Expand Down
80 changes: 49 additions & 31 deletions crates/cow-venue/src/classification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
//! and asserts the generated table agrees.
//!
//! The one non-obvious invariant: an `errorType` absent from the table
//! classifies as [`RetryAction::Drop`]. An unrecognised structured
//! rejection is a permanent contract-level refusal, not a transient
//! transport error, so it must not be retried every block forever.
//! (including [`OrderbookApiErrorType::Unknown`]) classifies as
//! [`RetryAction::Drop`]. An unrecognised structured rejection is a
//! permanent contract-level refusal, not a transient transport error,
//! so it must not be retried every block forever.

use cowprotocol::OrderbookApiErrorType;
use nexum_sdk::keeper::RetryAction;

/// The shipped classification data, embedded verbatim so a parity test
Expand Down Expand Up @@ -71,21 +73,28 @@ pub struct ClassificationTable {
}

impl ClassificationTable {
fn row(&self, error_type: &str) -> Option<&GeneratedRow> {
self.rows.iter().find(|r| r.error_type == error_type)
/// [`OrderbookApiErrorType::Unknown`] is by definition unlisted:
/// the parity tests pin every row to a known variant's exact wire
/// spelling, so only known variants can match a row.
fn row(&self, error_type: &OrderbookApiErrorType) -> Option<&GeneratedRow> {
match error_type {
OrderbookApiErrorType::Unknown(_) => None,
known => self.rows.iter().find(|r| r.error_type == known.as_str()),
}
}

/// The retry action for an orderbook `errorType`. Unlisted types are
/// permanent: [`RetryAction::Drop`].
pub fn classify(&self, error_type: &str) -> RetryAction {
/// The retry action for an orderbook `errorType`. Unlisted types
/// (including [`OrderbookApiErrorType::Unknown`]) are permanent:
/// [`RetryAction::Drop`].
pub fn classify(&self, error_type: &OrderbookApiErrorType) -> RetryAction {
self.row(error_type)
.map_or(RetryAction::Drop, GeneratedRow::retry_action)
}

/// Whether the orderbook is reporting that it already holds this
/// exact order. Such a rejection keeps the watch and records the
/// receipt rather than retrying a fresh submission.
pub fn is_already_submitted(&self, error_type: &str) -> bool {
pub fn is_already_submitted(&self, error_type: &OrderbookApiErrorType) -> bool {
self.row(error_type).is_some_and(|r| r.already_submitted)
}

Expand All @@ -108,14 +117,16 @@ pub fn table() -> ClassificationTable {
}

/// Classify an orderbook `errorType` into a keeper [`RetryAction`] via
/// the shipped table. Unlisted types are permanent ([`RetryAction::Drop`]).
pub fn classify(error_type: &str) -> RetryAction {
table().classify(error_type)
/// the shipped table. Unlisted types (including
/// [`OrderbookApiErrorType::Unknown`]) are permanent
/// ([`RetryAction::Drop`]).
pub fn classify(error_type: OrderbookApiErrorType) -> RetryAction {
table().classify(&error_type)
}

/// Whether an orderbook `errorType` means the order is already held.
pub fn is_already_submitted(error_type: &str) -> bool {
table().is_already_submitted(error_type)
pub fn is_already_submitted(error_type: OrderbookApiErrorType) -> bool {
table().is_already_submitted(&error_type)
}

/// Retry action for a coarse `denied` refusal. The adapter spells a
Expand All @@ -125,7 +136,7 @@ pub fn is_already_submitted(error_type: &str) -> bool {
/// permanent.
pub fn classify_denied(detail: &str) -> RetryAction {
let error_type = detail.split_once(':').map_or(detail, |(prefix, _)| prefix);
match classify(error_type) {
match classify(OrderbookApiErrorType::from(error_type)) {
RetryAction::DropOnRepeat => RetryAction::DropOnRepeat,
_ => RetryAction::Drop,
}
Expand All @@ -136,6 +147,11 @@ mod tests {
use super::*;
use crate::classification_data::{Action, ClassificationError, parse_and_validate};

/// Wire spelling to typed kind, as the adapter's `error_kind()` does.
fn kind(error_type: &str) -> OrderbookApiErrorType {
OrderbookApiErrorType::from(error_type)
}

/// The generated table is non-empty.
#[test]
fn shipped_data_parses() {
Expand All @@ -160,13 +176,13 @@ mod tests {
Action::Drop => RetryAction::Drop,
};
assert_eq!(
classify(&entry.error_type),
classify(kind(&entry.error_type)),
expected,
"classify {}",
entry.error_type,
);
assert_eq!(
is_already_submitted(&entry.error_type),
is_already_submitted(kind(&entry.error_type)),
entry.already_submitted,
"already-submitted {}",
entry.error_type,
Expand All @@ -179,41 +195,43 @@ mod tests {
/// producer the hand-coded classifier lacked.
#[test]
fn known_rows_classify_as_documented() {
assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock);
assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock);
assert_eq!(
classify("TooManyLimitOrders"),
classify(kind("TooManyLimitOrders")),
RetryAction::Backoff { seconds: 30 },
);
assert_eq!(
classify("InvalidEip1271Signature"),
classify(kind("InvalidEip1271Signature")),
RetryAction::DropOnRepeat,
);
assert_eq!(classify("InvalidSignature"), RetryAction::Drop);
assert!(is_already_submitted("DuplicatedOrder"));
assert!(is_already_submitted("DuplicateOrder"));
assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop);
assert!(is_already_submitted(kind("DuplicatedOrder")));
assert!(is_already_submitted(kind("DuplicateOrder")));
}

/// Unlisted (including newly minted) types are permanent, so a
/// contract-level rejection is never retried every block forever.
#[test]
fn unlisted_type_drops() {
assert_eq!(classify("NewlyMintedErrorType"), RetryAction::Drop);
assert!(!is_already_submitted("NewlyMintedErrorType"));
let unknown = kind("NewlyMintedErrorType");
assert!(matches!(unknown, OrderbookApiErrorType::Unknown(_)));
assert_eq!(classify(unknown.clone()), RetryAction::Drop);
assert!(!is_already_submitted(unknown));
}

/// Every retry arm is reachable from the table alone.
#[test]
fn table_reaches_every_arm() {
assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock);
assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock);
assert!(matches!(
classify("TooManyLimitOrders"),
classify(kind("TooManyLimitOrders")),
RetryAction::Backoff { .. }
));
assert_eq!(
classify("InvalidEip1271Signature"),
classify(kind("InvalidEip1271Signature")),
RetryAction::DropOnRepeat,
);
assert_eq!(classify("InvalidSignature"), RetryAction::Drop);
assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop);
}

/// A denied detail re-enters the table by its `errorType` prefix:
Expand Down Expand Up @@ -329,8 +347,8 @@ mod tests {
_ => None,
};
let shepherd = (
classify(&entry.error_type),
is_already_submitted(&entry.error_type),
classify(kind(&entry.error_type)),
is_already_submitted(kind(&entry.error_type)),
);
if upstream != Some(shepherd) {
divergent.push(&entry.error_type);
Expand Down
Loading