#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadFormat {
Json,
Xml,
Text,
Binary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Header {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectorRequest {
pub connector: String,
pub body: Vec<u8>,
pub additional_headers: Vec<Header>,
pub timeout_hint_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectorRequestValidationError {
message: String,
}
impl core::fmt::Display for ConnectorRequestValidationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.message)
}
}
impl core::error::Error for ConnectorRequestValidationError {}
impl ConnectorRequest {
pub fn builder(connector: impl Into<String>) -> ConnectorRequestBuilder {
ConnectorRequestBuilder {
request: Self {
connector: connector.into(),
body: Vec::new(),
additional_headers: Vec::new(),
timeout_hint_ms: None,
},
}
}
pub fn validate(&self) -> Result<(), ConnectorRequestValidationError> {
let connector = self.connector.trim();
if connector.is_empty() || connector.len() > 64 {
return Err(validation_error("connector must be 1..=64 bytes"));
}
if connector != self.connector {
return Err(validation_error("connector must not have surrounding whitespace"));
}
if !connector
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(validation_error("connector must match [A-Za-z0-9_-]+"));
}
if self.timeout_hint_ms == Some(0) {
return Err(validation_error("timeout_hint_ms must be > 0 when specified"));
}
for header in &self.additional_headers {
if !is_http_token(&header.name) {
return Err(validation_error("header name is not an RFC 9110 token"));
}
if !header
.value
.bytes()
.all(|byte| byte == b'\t' || (b' '..=b'~').contains(&byte))
{
return Err(validation_error("header value must contain only HTTP field-value characters"));
}
}
Ok(())
}
}
fn validation_error(message: &str) -> ConnectorRequestValidationError {
ConnectorRequestValidationError {
message: message.to_string(),
}
}
fn is_http_token(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
pub struct ConnectorRequestBuilder {
request: ConnectorRequest,
}
impl ConnectorRequestBuilder {
pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.request.body = body.into();
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.request.additional_headers.push(Header {
name: name.into(),
value: value.into(),
});
self
}
pub fn additional_headers(mut self, headers: Vec<Header>) -> Self {
self.request.additional_headers = headers;
self
}
pub fn timeout_hint_ms(mut self, timeout_hint_ms: u32) -> Self {
self.request.timeout_hint_ms = Some(timeout_hint_ms);
self
}
pub fn build(self) -> Result<ConnectorRequest, ConnectorRequestValidationError> {
self.request.validate()?;
Ok(self.request)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectorResponse {
pub status: u16,
pub format: PayloadFormat,
pub body: Vec<u8>,
pub headers: Vec<Header>,
pub upstream_trace_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectorErrorCode {
NotFound,
Timeout,
ConnectError,
NetworkError,
PayloadInvalid,
ResponseTooLarge,
ConcurrencyLimited,
ConnectorLimitExceeded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectorError {
pub code: ConnectorErrorCode,
pub message: String,
pub upstream_status: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionContext {
pub run_id: String,
pub started_at_unix_ms: u64,
pub deadline_unix_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogRecord {
pub level: LogLevel,
pub message: String,
pub attrs_json: String,
}
pub mod connector {
use super::{ConnectorError, ConnectorRequest, ConnectorResponse, Header};
pub fn call(req: ConnectorRequest) -> Result<ConnectorResponse, ConnectorError> {
req.validate().map_err(|error| ConnectorError {
code: super::ConnectorErrorCode::PayloadInvalid,
message: error.to_string(),
upstream_status: None,
})?;
super::backend::connector_call(req)
}
pub fn call_batch(reqs: Vec<ConnectorRequest>) -> Vec<Result<ConnectorResponse, ConnectorError>> {
let mut valid = Vec::new();
let mut slots: Vec<Option<Result<ConnectorResponse, ConnectorError>>> = (0..reqs.len()).map(|_| None).collect();
for (index, request) in reqs.into_iter().enumerate() {
match request.validate() {
Ok(()) => valid.push((index, request)),
Err(error) => {
slots[index] = Some(Err(ConnectorError {
code: super::ConnectorErrorCode::PayloadInvalid,
message: error.to_string(),
upstream_status: None,
}));
}
}
}
let (indices, valid_requests): (Vec<_>, Vec<_>) = valid.into_iter().unzip();
for (index, result) in indices
.into_iter()
.zip(super::backend::connector_call_batch(valid_requests))
{
slots[index] = Some(result);
}
slots
.into_iter()
.map(|slot| slot.expect("every connector batch slot is filled"))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryOn {
pub connect_error: bool,
pub timeout: bool,
pub network_error: bool,
pub server_error: bool,
pub too_many_requests: bool,
}
impl Default for RetryOn {
fn default() -> Self {
Self {
connect_error: true,
timeout: false,
network_error: false,
server_error: false,
too_many_requests: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_backoff_ms: u32,
pub max_backoff_ms: u32,
pub retry_on: RetryOn,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff_ms: 100,
max_backoff_ms: 1_000,
retry_on: RetryOn::default(),
}
}
}
pub fn call_with_retry(req: ConnectorRequest, policy: RetryPolicy) -> Result<ConnectorResponse, ConnectorError> {
let attempts = policy.max_attempts.max(1);
let deadline_unix_ms = super::execution::get_context().deadline_unix_ms;
for attempt in 1..=attempts {
let started_unix_ms = now_unix_ms();
let outcome = call(req.clone());
let last_attempt_ms = now_unix_ms().saturating_sub(started_unix_ms);
let Some(reason) = retry_reason(&outcome, policy.retry_on) else {
return outcome;
};
if attempt == attempts {
return outcome;
}
let backoff_ms = backoff_ms(attempt, policy);
let remaining_ms = deadline_unix_ms.saturating_sub(now_unix_ms());
if remaining_ms <= u64::from(backoff_ms).saturating_add(last_attempt_ms) {
return outcome;
}
super::warn(
"connector call retried",
serde_json::json!({
"connector": req.connector,
"attempt": attempt,
"reason": reason,
"backoff_ms": backoff_ms,
}),
);
sleep_ms(backoff_ms);
}
unreachable!("the loop returns on the last attempt")
}
fn retry_reason(outcome: &Result<ConnectorResponse, ConnectorError>, retry_on: RetryOn) -> Option<&'static str> {
use super::ConnectorErrorCode as Code;
match outcome {
Ok(response) => match response.status {
429 if retry_on.too_many_requests => Some("too-many-requests"),
500..=599 if retry_on.server_error => Some("server-error"),
_ => None,
},
Err(error) => match error.code {
Code::ConnectError if retry_on.connect_error => Some("connect-error"),
Code::Timeout if retry_on.timeout => Some("timeout"),
Code::NetworkError if retry_on.network_error => Some("network-error"),
_ => None,
},
}
}
fn backoff_ms(attempt: u32, policy: RetryPolicy) -> u32 {
let exponential = policy
.initial_backoff_ms
.saturating_mul(1u32.checked_shl(attempt - 1).unwrap_or(u32::MAX))
.min(policy.max_backoff_ms);
if exponential == 0 {
return 0;
}
let seed = super::uuid::generate_uuid_v4()
.chars()
.filter(|c| c.is_ascii_hexdigit())
.take(8)
.fold(0u32, |acc, c| {
acc.wrapping_mul(16)
.wrapping_add(c.to_digit(16).unwrap_or(0))
});
seed % (exponential + 1)
}
fn now_unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
fn sleep_ms(ms: u32) {
if ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(u64::from(ms)));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CallJsonOptions {
pub additional_headers: Vec<Header>,
pub timeout_hint_ms: Option<u32>,
}
#[derive(Debug)]
pub enum CallJsonError {
Encode(String),
Connector(ConnectorError),
UnexpectedStatus {
status: u16,
body: Vec<u8>,
},
Decode {
status: u16,
message: String,
},
}
impl core::fmt::Display for CallJsonError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Encode(message) => write!(f, "failed to encode the request body as JSON: {message}"),
Self::Connector(err) => {
write!(f, "connector call failed ({:?}): {}", err.code, err.message)
}
Self::UnexpectedStatus {
status,
..
} => {
write!(f, "connector returned an unexpected status: status={status}")
}
Self::Decode {
status,
message,
} => {
write!(f, "failed to decode the connector response (status={status}): {message}")
}
}
}
}
impl core::error::Error for CallJsonError {}
pub fn call_json<Req, Resp>(connector: &str, req: &Req) -> Result<Resp, CallJsonError>
where
Req: serde::Serialize + ?Sized,
Resp: serde::de::DeserializeOwned,
{
call_json_with(connector, req, CallJsonOptions::default())
}
pub fn call_json_with<Req, Resp>(
connector: &str,
req: &Req,
options: CallJsonOptions,
) -> Result<Resp, CallJsonError>
where
Req: serde::Serialize + ?Sized,
Resp: serde::de::DeserializeOwned,
{
let body = serde_json::to_vec(req).map_err(|err| CallJsonError::Encode(err.to_string()))?;
let mut builder = ConnectorRequest::builder(connector)
.body(body)
.additional_headers(options.additional_headers);
if let Some(timeout_hint_ms) = options.timeout_hint_ms {
builder = builder.timeout_hint_ms(timeout_hint_ms);
}
let request = builder.build().map_err(|error| {
CallJsonError::Connector(ConnectorError {
code: super::ConnectorErrorCode::PayloadInvalid,
message: error.to_string(),
upstream_status: None,
})
})?;
let response = call(request).map_err(CallJsonError::Connector)?;
if !(200..300).contains(&response.status) {
return Err(CallJsonError::UnexpectedStatus {
status: response.status,
body: response.body,
});
}
serde_json::from_slice(&response.body).map_err(|err| CallJsonError::Decode {
status: response.status,
message: err.to_string(),
})
}
}
pub mod execution {
use super::ExecutionContext;
pub fn get_context() -> ExecutionContext {
super::backend::get_context()
}
}
pub mod uuid {
pub fn generate_uuid_v7() -> String {
super::backend::generate_uuid(7)
}
pub fn generate_uuid_v4() -> String {
super::backend::generate_uuid(4)
}
}
pub fn debug(message: &str, attrs: impl serde::Serialize) {
backend::log(LogLevel::Debug, message, &to_attrs_json(&attrs));
}
pub fn info(message: &str, attrs: impl serde::Serialize) {
backend::log(LogLevel::Info, message, &to_attrs_json(&attrs));
}
pub fn warn(message: &str, attrs: impl serde::Serialize) {
backend::log(LogLevel::Warn, message, &to_attrs_json(&attrs));
}
pub fn error(message: &str, attrs: impl serde::Serialize) {
backend::log(LogLevel::Error, message, &to_attrs_json(&attrs));
}
fn to_attrs_json(attrs: &impl serde::Serialize) -> String {
serde_json::to_string(attrs)
.unwrap_or_else(|err| serde_json::json!({ "attrs_serialize_error": err.to_string() }).to_string())
}
#[cfg(target_arch = "wasm32")]
mod backend {
macro_rules! select_host_backend {
($(($feature:literal, $module:ident)),+ $(,)?) => {
select_host_backend!(@pick [] $(($feature, $module))+);
select_host_backend!(@require_one [] $(($feature, $module))+);
select_host_backend!(@reject_multi $(($feature, $module))+);
};
(@pick [$($prev:literal)*] ($feature:literal, $module:ident) $($rest:tt)*) => {
#[cfg(all(feature = $feature, not(any($(feature = $prev),*))))]
use crate::$module::bindings::makeshop::runtime_host as rh;
select_host_backend!(@pick [$($prev)* $feature] $($rest)*);
};
(@pick [$($prev:literal)*]) => {};
(@require_one [$($seen:literal)*] ($feature:literal, $module:ident) $($rest:tt)*) => {
select_host_backend!(@require_one [$($seen)* $feature] $($rest)*);
};
(@require_one [$($seen:literal)*]) => {
#[cfg(not(any($(feature = $seen),*)))]
compile_error!(
"a wasm32 build must enable exactly one entrypoint contract feature (for example features = [\"finalize-checkout-validation-v1\"])"
);
};
(@reject_multi ($feature:literal, $module:ident) $($rest:tt)*) => {
select_host_backend!(@reject_pairs $feature, $($rest)*);
select_host_backend!(@reject_multi $($rest)*);
};
(@reject_multi) => {};
(@reject_pairs $head:literal, $(($feature:literal, $module:ident))*) => {
#[cfg(all(feature = $head, any($(feature = $feature),*)))]
compile_error!(
"a wasm32 build must not enable more than one entrypoint contract feature (one guest exports one entrypoint under one contract)"
);
};
}
select_host_backend!(
("finalize-checkout-validation-v1", finalize_checkout_validation),
("order-completed-v1", order_completed),
("order-completed-content-v1", order_completed_content),
);
use super::{
ConnectorError,
ConnectorErrorCode,
ConnectorRequest,
ConnectorResponse,
ExecutionContext,
Header,
LogLevel,
PayloadFormat,
};
pub(super) fn connector_call(req: ConnectorRequest) -> Result<ConnectorResponse, ConnectorError> {
rh::connector::call(&to_wit_request(req))
.map(from_wit_response)
.map_err(from_wit_error)
}
pub(super) fn connector_call_batch(reqs: Vec<ConnectorRequest>) -> Vec<Result<ConnectorResponse, ConnectorError>> {
let wit_reqs: Vec<_> = reqs.into_iter().map(to_wit_request).collect();
rh::connector::call_batch(&wit_reqs)
.into_iter()
.map(|result| result.map(from_wit_response).map_err(from_wit_error))
.collect()
}
pub(super) fn get_context() -> ExecutionContext {
let context = rh::execution::get_context();
ExecutionContext {
run_id: context.run_id,
started_at_unix_ms: context.started_at_unix_ms,
deadline_unix_ms: context.deadline_unix_ms,
}
}
pub(super) fn generate_uuid(version: u8) -> String {
match version {
7 => rh::uuid::generate_uuid_v7(),
_ => rh::uuid::generate_uuid_v4(),
}
}
pub(super) fn log(level: LogLevel, message: &str, attrs_json: &str) {
match level {
LogLevel::Debug => rh::logging::debug(message, attrs_json),
LogLevel::Info => rh::logging::info(message, attrs_json),
LogLevel::Warn => rh::logging::warn(message, attrs_json),
LogLevel::Error => rh::logging::error(message, attrs_json),
}
}
fn from_wit_format(format: rh::connector::PayloadFormat) -> PayloadFormat {
match format {
rh::connector::PayloadFormat::Json => PayloadFormat::Json,
rh::connector::PayloadFormat::Xml => PayloadFormat::Xml,
rh::connector::PayloadFormat::Text => PayloadFormat::Text,
rh::connector::PayloadFormat::Binary => PayloadFormat::Binary,
}
}
fn to_wit_request(req: ConnectorRequest) -> rh::connector::ConnectorRequest {
rh::connector::ConnectorRequest {
connector: req.connector,
body: req.body,
additional_headers: req
.additional_headers
.into_iter()
.map(|header| rh::connector::Header {
name: header.name,
value: header.value,
})
.collect(),
timeout_hint_ms: req.timeout_hint_ms,
}
}
fn from_wit_response(res: rh::connector::ConnectorResponse) -> ConnectorResponse {
ConnectorResponse {
status: res.status,
format: from_wit_format(res.format),
body: res.body,
headers: res
.headers
.into_iter()
.map(|header| Header {
name: header.name,
value: header.value,
})
.collect(),
upstream_trace_id: res.upstream_trace_id,
}
}
fn from_wit_error(err: rh::connector::ConnectorError) -> ConnectorError {
use rh::connector::ConnectorErrorCode as Wit;
ConnectorError {
code: match err.code {
Wit::NotFound => ConnectorErrorCode::NotFound,
Wit::Timeout => ConnectorErrorCode::Timeout,
Wit::ConnectError => ConnectorErrorCode::ConnectError,
Wit::NetworkError => ConnectorErrorCode::NetworkError,
Wit::PayloadInvalid => ConnectorErrorCode::PayloadInvalid,
Wit::ResponseTooLarge => ConnectorErrorCode::ResponseTooLarge,
Wit::ConcurrencyLimited => ConnectorErrorCode::ConcurrencyLimited,
Wit::ConnectorLimitExceeded => ConnectorErrorCode::ConnectorLimitExceeded,
},
message: err.message,
upstream_status: err.upstream_status,
}
}
}
#[cfg(test)]
mod tests {
use super::connector::{CallJsonError, RetryOn, RetryPolicy, call_json, call_with_retry};
use super::{ConnectorError, ConnectorErrorCode, ConnectorRequest};
use crate::testing::{TestHost, json_response};
#[derive(serde::Serialize)]
struct Req {
member_id: &'static str,
}
#[derive(Debug, serde::Deserialize, PartialEq)]
struct Resp {
approved: bool,
}
#[test]
fn connector_request_builder_rejects_invalid_state_before_host_call() {
assert!(ConnectorRequest::builder("").build().is_err());
assert!(ConnectorRequest::builder(" erp ").build().is_err());
assert!(
ConnectorRequest::builder("erp")
.timeout_hint_ms(0)
.build()
.is_err()
);
assert!(
ConnectorRequest::builder("erp")
.header("bad header", "value")
.build()
.is_err()
);
assert!(
ConnectorRequest::builder("erp")
.header("x-note", "ok\r\ninjected: yes")
.build()
.is_err()
);
}
#[test]
fn connector_request_builder_constructs_valid_request() {
let request = ConnectorRequest::builder("erp-v2")
.body(br#"{"member_id":"M000001"}"#.to_vec())
.header("content-type", "application/json")
.timeout_hint_ms(500)
.build()
.expect("valid connector request");
assert_eq!(request.connector, "erp-v2");
assert_eq!(request.additional_headers.len(), 1);
assert_eq!(request.timeout_hint_ms, Some(500));
}
#[test]
fn call_json_decodes_2xx_response() {
let host = TestHost::new().with_connector("erp", |req| {
assert_eq!(req.body, br#"{"member_id":"m-1"}"#.to_vec());
json_response(200, r#"{"approved":true}"#)
});
let result: Result<Resp, _> = host.run(|| {
call_json(
"erp",
&Req {
member_id: "m-1",
},
)
});
assert_eq!(
result.unwrap(),
Resp {
approved: true
}
);
}
#[test]
fn call_json_classifies_non_2xx_as_unexpected_status() {
let host = TestHost::new().with_connector("erp", |_req| json_response(503, "oops"));
let result: Result<Resp, _> = host.run(|| {
call_json(
"erp",
&Req {
member_id: "m-1",
},
)
});
match result.unwrap_err() {
CallJsonError::UnexpectedStatus {
status,
body,
} => {
assert_eq!(status, 503);
assert_eq!(body, b"oops".to_vec());
}
other => panic!("expected UnexpectedStatus: {other:?}"),
}
}
#[test]
fn call_json_classifies_broken_body_as_decode() {
let host = TestHost::new().with_connector("erp", |_req| json_response(200, "not-json"));
let result: Result<Resp, _> = host.run(|| {
call_json(
"erp",
&Req {
member_id: "m-1",
},
)
});
assert!(matches!(
result.unwrap_err(),
CallJsonError::Decode {
status: 200,
..
}
));
}
#[test]
fn log_attrs_serialize_failure_keeps_the_log_with_the_reason() {
struct Broken;
impl serde::Serialize for Broken {
fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("attrs are broken"))
}
}
let host = TestHost::new();
host.run(|| {
crate::host::info("still logged", Broken);
});
let logs = host.logs();
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].message, "still logged");
assert!(
logs[0].attrs_json.contains("attrs_serialize_error") && logs[0].attrs_json.contains("attrs are broken"),
"attrs must record why serialization failed: {}",
logs[0].attrs_json
);
}
#[test]
fn call_with_retry_resends_connect_error_by_default() {
let attempts = std::rc::Rc::new(std::cell::Cell::new(0u32));
let seen = std::rc::Rc::clone(&attempts);
let host = TestHost::new().with_connector("erp", move |_req| {
seen.set(seen.get() + 1);
if seen.get() < 3 {
return Err(ConnectorError {
code: ConnectorErrorCode::ConnectError,
message: "connection refused".to_string(),
upstream_status: None,
});
}
json_response(200, r#"{"member_id":"m-1"}"#)
});
let response = host
.run(|| call_with_retry(request(), fast_policy(RetryOn::default())))
.expect("the third attempt succeeds");
assert_eq!(response.status, 200);
assert_eq!(attempts.get(), 3);
}
#[test]
fn call_with_retry_leaves_ambiguous_failures_alone_by_default() {
for code in [ConnectorErrorCode::Timeout, ConnectorErrorCode::NetworkError] {
let attempts = std::rc::Rc::new(std::cell::Cell::new(0u32));
let seen = std::rc::Rc::clone(&attempts);
let host = TestHost::new().with_connector("erp", move |_req| {
seen.set(seen.get() + 1);
Err(ConnectorError {
code,
message: "ambiguous".to_string(),
upstream_status: None,
})
});
let error = host
.run(|| call_with_retry(request(), fast_policy(RetryOn::default())))
.expect_err("must not be re-sent by default");
assert_eq!(error.code, code);
assert_eq!(attempts.get(), 1, "{code:?} must be attempted once");
}
}
#[test]
fn call_with_retry_never_resends_failures_a_retry_cannot_fix() {
let retry_everything = RetryOn {
connect_error: true,
timeout: true,
network_error: true,
server_error: true,
too_many_requests: true,
};
for code in [
ConnectorErrorCode::NotFound,
ConnectorErrorCode::PayloadInvalid,
ConnectorErrorCode::ResponseTooLarge,
ConnectorErrorCode::ConcurrencyLimited,
ConnectorErrorCode::ConnectorLimitExceeded,
] {
let attempts = std::rc::Rc::new(std::cell::Cell::new(0u32));
let seen = std::rc::Rc::clone(&attempts);
let host = TestHost::new().with_connector("erp", move |_req| {
seen.set(seen.get() + 1);
Err(ConnectorError {
code,
message: "terminal".to_string(),
upstream_status: None,
})
});
let error = host
.run(|| call_with_retry(request(), fast_policy(retry_everything)))
.expect_err("must surface the terminal failure");
assert_eq!(error.code, code);
assert_eq!(attempts.get(), 1, "{code:?} must be attempted once");
}
}
#[test]
fn call_with_retry_returns_the_last_status_when_attempts_run_out() {
let attempts = std::rc::Rc::new(std::cell::Cell::new(0u32));
let seen = std::rc::Rc::clone(&attempts);
let host = TestHost::new().with_connector("erp", move |_req| {
seen.set(seen.get() + 1);
json_response(503, "unavailable")
});
let retry_on = RetryOn {
server_error: true,
..RetryOn::default()
};
let response = host
.run(|| call_with_retry(request(), fast_policy(retry_on)))
.expect("a 5xx is a response, not an error");
assert_eq!(response.status, 503);
assert_eq!(attempts.get(), 3);
}
#[test]
fn call_with_retry_honours_a_single_attempt() {
let attempts = std::rc::Rc::new(std::cell::Cell::new(0u32));
let seen = std::rc::Rc::clone(&attempts);
let host = TestHost::new().with_connector("erp", move |_req| {
seen.set(seen.get() + 1);
Err(ConnectorError {
code: ConnectorErrorCode::ConnectError,
message: "connection refused".to_string(),
upstream_status: None,
})
});
let policy = RetryPolicy {
max_attempts: 1,
..fast_policy(RetryOn::default())
};
let _ = host.run(|| call_with_retry(request(), policy));
assert_eq!(attempts.get(), 1);
}
#[test]
fn call_with_retry_stops_when_the_deadline_leaves_no_room() {
let attempts = std::rc::Rc::new(std::cell::Cell::new(0u32));
let seen = std::rc::Rc::clone(&attempts);
let host = TestHost::new()
.with_execution_context(super::ExecutionContext {
run_id: "run-out-of-time".to_string(),
started_at_unix_ms: 0,
deadline_unix_ms: 0,
})
.with_connector("erp", move |_req| {
seen.set(seen.get() + 1);
Err(ConnectorError {
code: ConnectorErrorCode::ConnectError,
message: "connection refused".to_string(),
upstream_status: None,
})
});
let error = host
.run(|| call_with_retry(request(), fast_policy(RetryOn::default())))
.expect_err("no time is left for another attempt");
assert_eq!(error.code, ConnectorErrorCode::ConnectError);
assert_eq!(attempts.get(), 1, "the backoff must not be spent past the deadline");
}
fn request() -> ConnectorRequest {
ConnectorRequest::builder("erp")
.body(b"{}".to_vec())
.build()
.expect("valid request")
}
fn fast_policy(retry_on: RetryOn) -> RetryPolicy {
RetryPolicy {
max_attempts: 3,
initial_backoff_ms: 0,
max_backoff_ms: 0,
retry_on,
}
}
#[test]
fn call_json_passes_through_connector_error() {
let host = TestHost::new().with_connector("erp", |_req| {
Err(ConnectorError {
code: ConnectorErrorCode::Timeout,
message: "upstream timeout".to_string(),
upstream_status: None,
})
});
let result: Result<Resp, _> = host.run(|| {
call_json(
"erp",
&Req {
member_id: "m-1",
},
)
});
match result.unwrap_err() {
CallJsonError::Connector(err) => assert_eq!(err.code, ConnectorErrorCode::Timeout),
other => panic!("expected Connector: {other:?}"),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
mod backend {
use super::{ConnectorError, ConnectorRequest, ConnectorResponse, ExecutionContext, LogLevel};
pub(super) fn connector_call(req: ConnectorRequest) -> Result<ConnectorResponse, ConnectorError> {
crate::testing::dispatch_connector(req)
}
pub(super) fn connector_call_batch(reqs: Vec<ConnectorRequest>) -> Vec<Result<ConnectorResponse, ConnectorError>> {
reqs.into_iter()
.map(crate::testing::dispatch_connector)
.collect()
}
pub(super) fn get_context() -> ExecutionContext {
crate::testing::dispatch_context()
}
pub(super) fn generate_uuid(version: u8) -> String {
crate::testing::dispatch_uuid(version)
}
pub(super) fn log(level: LogLevel, message: &str, attrs_json: &str) {
crate::testing::dispatch_log(level, message, attrs_json);
}
}