Skip to content

Commit 672d398

Browse files
fix(probe): tell a body that never arrived from one that is not the resource
reqwest reports both as a decode error, so the transport step and the decode step now fail with their own types: a truncated or timed out body stays transient, while a body that is not the resource is a verdict about the endpoint. Also folds the status table into `classified`, so the absence meaning is only ever read from the response type.
1 parent a2b609d commit 672d398

4 files changed

Lines changed: 107 additions & 99 deletions

File tree

crates/foreign-chain-inspector/src/aptos/inspector.rs

Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ where
100100
}
101101
}
102102

103-
/// A transaction the chain does not have is a verdict about that transaction.
104103
impl HasAbsenceMeaning for TransactionResponse {
105104
const ABSENCE: AbsenceMeaning = AbsenceMeaning::TransactionIsAbsent;
106105
}
@@ -114,38 +113,45 @@ impl<T: HasAbsenceMeaning> ClassifyRpcOutcome for Result<T, AptosRpcError> {
114113
type Response = T;
115114

116115
fn classified(self) -> Result<T, ForeignChainInspectionError> {
117-
self.map_err(|error| classify_rest_error(error, T::ABSENCE))
118-
}
119-
}
116+
let error = match self {
117+
Ok(response) => return Ok(response),
118+
Err(error) => error,
119+
};
120120

121-
fn classify_rest_error(
122-
error: AptosRpcError,
123-
absence: AbsenceMeaning,
124-
) -> ForeignChainInspectionError {
125-
let message = error.to_string();
126-
match error {
127-
// Aptos answers 404 both for a transaction it does not have and for a path it does not
128-
// route; only the resource that was read tells the two apart.
129-
AptosRpcError::ApiError { status: 404, .. } => match absence {
130-
AbsenceMeaning::TransactionIsAbsent => ForeignChainInspectionError::TransactionNotFound,
131-
AbsenceMeaning::ApiIsNotServed => {
121+
let message = error.to_string();
122+
Err(match error {
123+
// Aptos answers 404 both for a transaction it lacks and for a path it does not route.
124+
AptosRpcError::ApiError { status: 404, .. } => match T::ABSENCE {
125+
AbsenceMeaning::TransactionIsAbsent => {
126+
ForeignChainInspectionError::TransactionNotFound
127+
}
128+
AbsenceMeaning::ApiIsNotServed => {
129+
ForeignChainInspectionError::RpcRequestRejected(message)
130+
}
131+
},
132+
// Rate limits and server errors are provider hiccups → transient, so the
133+
// affected provider is dropped from the quorum instead of blocking it.
134+
AptosRpcError::ApiError {
135+
status: 408 | 429, ..
136+
} => ForeignChainInspectionError::RpcRequestFailed(message),
137+
AptosRpcError::ApiError { status, .. } if status >= 500 => {
138+
ForeignChainInspectionError::RpcRequestFailed(message)
139+
}
140+
// Remaining 4xx (400/401/403/410, …) are deterministic rejections —
141+
// retrying cannot change them, so they count as substantive verdicts.
142+
AptosRpcError::ApiError { .. } => {
132143
ForeignChainInspectionError::RpcRequestRejected(message)
133144
}
134-
},
135-
// Rate limits and server errors are provider hiccups → transient, so the affected
136-
// provider is dropped from the quorum instead of blocking it.
137-
AptosRpcError::ApiError {
138-
status: 408 | 429, ..
139-
} => ForeignChainInspectionError::RpcRequestFailed(message),
140-
AptosRpcError::ApiError { status, .. } if status >= 500 => {
141-
ForeignChainInspectionError::RpcRequestFailed(message)
142-
}
143-
// Remaining 4xx (400/401/403/410, …) are deterministic rejections — retrying cannot
144-
// change them, so they count as substantive verdicts.
145-
AptosRpcError::ApiError { .. } => ForeignChainInspectionError::RpcRequestRejected(message),
146-
// Named so a probe can report a slow provider as timed out rather than unreachable.
147-
AptosRpcError::Http(error) if error.is_timeout() => ForeignChainInspectionError::Timeout,
148-
AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(message),
145+
// Split timeout from rest of http errors for reporting.
146+
AptosRpcError::Http(error) if error.is_timeout() => {
147+
ForeignChainInspectionError::Timeout
148+
}
149+
AptosRpcError::Http(_) => ForeignChainInspectionError::RpcRequestFailed(message),
150+
// A body that will not decode is not transient.
151+
AptosRpcError::MalformedBody(_) => {
152+
ForeignChainInspectionError::MalformedRpcResponse(message)
153+
}
154+
})
149155
}
150156
}
151157

@@ -337,10 +343,7 @@ mod tests {
337343
status: *status,
338344
body: body.clone(),
339345
}),
340-
Err(other) => Err(AptosRpcError::ApiError {
341-
status: 500,
342-
body: other.to_string(),
343-
}),
346+
Err(other) => unreachable!("MockAptosClient models only ApiError, got {other}"),
344347
};
345348
std::future::ready(r)
346349
}
@@ -354,10 +357,7 @@ mod tests {
354357
status: *status,
355358
body: body.clone(),
356359
}),
357-
Err(other) => Err(AptosRpcError::ApiError {
358-
status: 500,
359-
body: other.to_string(),
360-
}),
360+
Err(other) => unreachable!("MockAptosClient models only ApiError, got {other}"),
361361
};
362362
std::future::ready(r)
363363
}
@@ -878,21 +878,20 @@ mod tests {
878878
#[case::internal_error(500)]
879879
#[case::bad_request(400)]
880880
#[case::unauthorized(401)]
881-
fn classify_rest_error__should_read_every_status_but_404_alike(#[case] status: u16) {
881+
fn classified__should_read_every_status_but_404_alike(#[case] status: u16) {
882+
// Given
883+
let read_as_transaction: Result<TransactionResponse, _> =
884+
Err(MockAptosClient::error(status));
885+
let read_as_api_root: Result<LedgerInfoResponse, _> = Err(MockAptosClient::error(status));
886+
882887
// When
883-
let as_transaction = classify_rest_error(
884-
MockAptosClient::error(status),
885-
AbsenceMeaning::TransactionIsAbsent,
886-
);
887-
let as_api_root = classify_rest_error(
888-
MockAptosClient::error(status),
889-
AbsenceMeaning::ApiIsNotServed,
890-
);
888+
let from_transaction = read_as_transaction.classified().unwrap_err();
889+
let from_api_root = read_as_api_root.classified().unwrap_err();
891890

892891
// Then
893892
assert_eq!(
894-
std::mem::discriminant(&as_transaction),
895-
std::mem::discriminant(&as_api_root)
893+
std::mem::discriminant(&from_transaction),
894+
std::mem::discriminant(&from_api_root)
896895
);
897896
}
898897
}

crates/foreign-chain-inspector/src/lib.rs

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -470,33 +470,25 @@ fn is_retryable_status(status_code: u16) -> bool {
470470
matches!(status_code, REQUEST_TIMEOUT | TOO_MANY_REQUESTS) || status_code >= SERVER_ERROR
471471
}
472472

473-
/// What a provider's "not found" answer means for the resource that was read.
474-
///
475-
/// HTTP 404 and gRPC `NOT_FOUND` are the one wire condition whose verdict depends on what was
476-
/// asked for rather than on the status itself, so the resource answers and the chain's status
477-
/// table reads the answer.
473+
/// What a provider's "not found" answer means for the resource that was read: the one wire
474+
/// condition whose verdict depends on what was asked for rather than on the status itself.
478475
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479476
pub(crate) enum AbsenceMeaning {
480-
/// The chain may legitimately not hold it, so absence is the chain's own verdict.
477+
/// The chain may legitimately not hold it.
481478
TransactionIsAbsent,
482-
/// Every node serving this chain's API holds it, so absence says the endpoint serves a
483-
/// different API.
479+
/// Every node serving this chain's API holds it.
484480
ApiIsNotServed,
485481
}
486482

487-
/// The [`AbsenceMeaning`] of the resource a response carries, stated once beside that response
488-
/// rather than at each call that reads it.
489-
///
490-
/// [`ClassifyRpcOutcome::classified`] requires it, so a call reading a response that has no
491-
/// answer to the question does not compile.
483+
/// The [`AbsenceMeaning`] of the resource a response carries. Required by
484+
/// [`ClassifyRpcOutcome::classified`], so a response that never answered cannot be classified.
492485
pub(crate) trait HasAbsenceMeaning {
493486
const ABSENCE: AbsenceMeaning;
494487
}
495488

496-
/// Reads a chain client's outcome as an inspection outcome: the transport says how the call
497-
/// failed, the response type says what an absent resource means, and the call site says nothing.
498-
///
499-
/// One implementation per transport, each holding that chain's whole status table.
489+
/// Reads a chain client's outcome as an inspection outcome: the response type supplies what
490+
/// absence means, so the call site supplies nothing. One implementation per transport, each
491+
/// holding that chain's status table.
500492
pub(crate) trait ClassifyRpcOutcome {
501493
type Response;
502494

crates/foreign-chain-inspector/tests/aptos_inspector.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,29 @@ async fn extract__should_classify_http_errors_by_status(
209209
assert_eq!(error.is_transient(), expected_transient);
210210
}
211211

212+
#[tokio::test]
213+
async fn extract__should_reject_a_response_that_does_not_carry_the_resource() {
214+
// Given — a URL that answers but serves something other than the Aptos REST API.
215+
let server = MockServer::start();
216+
server.mock(|when, then| {
217+
when.method(GET).path(tx_path());
218+
then.status(200)
219+
.header("content-type", "text/html")
220+
.body("<html><body>Sign in to continue</body></html>");
221+
});
222+
let inspector = inspector_for(&server);
223+
224+
// When
225+
let response = inspector
226+
.extract(tx_id(), AptosFinality::Committed, vec![])
227+
.await;
228+
229+
// Then — the endpoint is wrong, not slow, so retrying it cannot help.
230+
let error = response.expect_err("extract should fail");
231+
assert_matches!(error, ForeignChainInspectionError::MalformedRpcResponse(_));
232+
assert!(!error.is_transient());
233+
}
234+
212235
#[tokio::test]
213236
async fn extract__should_reject_response_with_mismatched_hash() {
214237
// Given — the backend echoes a different transaction than queried.

crates/foreign-chain-rpc-interfaces/src/aptos.rs

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use reqwest::Url;
22
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
3-
use serde::Deserialize;
3+
use serde::{Deserialize, de::DeserializeOwned};
44
use std::future::Future;
55
use std::time::Duration;
66

@@ -44,9 +44,11 @@ pub struct EventGuid {
4444
#[derive(Debug, thiserror::Error)]
4545
pub enum AptosRpcError {
4646
#[error("HTTP request failed: {0}")]
47-
Http(#[from] reqwest::Error),
47+
Http(reqwest::Error),
4848
#[error("Aptos API returned HTTP {status}: {body}")]
4949
ApiError { status: u16, body: String },
50+
#[error("failed to decode the Aptos API response: {0}")]
51+
MalformedBody(serde_json::Error),
5052
}
5153

5254
/// Partial response of the API root: the ledger info every Aptos node reports.
@@ -106,6 +108,26 @@ impl ReqwestAptosClient {
106108
.expect("Aptos rpc_url is validated as a URL by node-config before reaching here");
107109
Self { base, client }
108110
}
111+
112+
async fn get_json<T: DeserializeOwned>(&self, url: Url) -> Result<T, AptosRpcError> {
113+
let response = self
114+
.client
115+
.get(url)
116+
.send()
117+
.await
118+
.map_err(AptosRpcError::Http)?;
119+
let status = response.status();
120+
if !status.is_success() {
121+
let body = response.text().await.unwrap_or_default();
122+
return Err(AptosRpcError::ApiError {
123+
status: status.as_u16(),
124+
body,
125+
});
126+
}
127+
128+
let body = response.bytes().await.map_err(AptosRpcError::Http)?;
129+
serde_json::from_slice(&body).map_err(AptosRpcError::MalformedBody)
130+
}
109131
}
110132

111133
/// Appends `transactions/by_hash/{hash}` to `base`, preserving its path and query string (so a
@@ -124,42 +146,14 @@ impl AptosRpcClient for ReqwestAptosClient {
124146
&self,
125147
tx_hash_hex: &str,
126148
) -> impl Future<Output = Result<TransactionResponse, AptosRpcError>> + Send {
127-
let url = build_request_url(&self.base, tx_hash_hex);
128-
let client = self.client.clone();
129-
async move {
130-
let response = client.get(url).send().await?;
131-
let status = response.status();
132-
if !status.is_success() {
133-
let body = response.text().await.unwrap_or_default();
134-
return Err(AptosRpcError::ApiError {
135-
status: status.as_u16(),
136-
body,
137-
});
138-
}
139-
let parsed = response.json::<TransactionResponse>().await?;
140-
Ok(parsed)
141-
}
149+
self.get_json(build_request_url(&self.base, tx_hash_hex))
142150
}
143151

144-
/// The ledger info lives at the API root, so the base URL is requested as configured.
145152
fn get_ledger_info(
146153
&self,
147154
) -> impl Future<Output = Result<LedgerInfoResponse, AptosRpcError>> + Send {
148-
let url = self.base.clone();
149-
let client = self.client.clone();
150-
async move {
151-
let response = client.get(url).send().await?;
152-
let status = response.status();
153-
if !status.is_success() {
154-
let body = response.text().await.unwrap_or_default();
155-
return Err(AptosRpcError::ApiError {
156-
status: status.as_u16(),
157-
body,
158-
});
159-
}
160-
let parsed = response.json::<LedgerInfoResponse>().await?;
161-
Ok(parsed)
162-
}
155+
// The ledger info lives at the API root.
156+
self.get_json(self.base.clone())
163157
}
164158
}
165159

0 commit comments

Comments
 (0)