Skip to content

Commit 197a805

Browse files
committed
refactor(channels/webhook): address review feedback on retry logic
Post-review cleanup on dd93422: - Drop dead `last_err` state and replace unreachable tail `bail!` with `unreachable!` β€” the loop always exits via return/bail on the final attempt, so the trailing error was never reached. - Expand comment above the 429/503 Retry-After branch to explain why 429 appears twice: once here (server-supplied delay) and once in the fallback exponential-backoff branch when no Retry-After header was sent. - Note in `compute_backoff` doc that the `retry_max_delay_ms` cap is approximate β€” Β±25% jitter is applied after capping. - Add `send_honors_retry_after_on_503` wiremock test mirroring the 429 case, closing a gap where only 429+Retry-After was covered. - Add schema tests for the 3 new `WebhookConfig` retry fields: `webhook_config_retry_fields_default_to_none` and `webhook_config_retry_fields_roundtrip` (JSON + TOML).
1 parent 174bd01 commit 197a805

2 files changed

Lines changed: 86 additions & 9 deletions

File tree

β€Žcrates/zeroclaw-channels/src/webhook.rsβ€Ž

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ impl WebhookChannel {
8383
}
8484

8585
/// Compute the backoff delay for a given attempt, bounded by `retry_max_delay_ms`
86-
/// and with Β±25% jitter applied.
86+
/// and with Β±25% jitter applied. The cap is approximate: jitter is applied after
87+
/// capping, so the returned delay may exceed `retry_max_delay_ms` by up to 25%.
8788
fn compute_backoff(&self, attempt: u32) -> Duration {
8889
let multiplier = 1_u64.checked_shl(attempt).unwrap_or(u64::MAX);
8990
let base = self.retry_base_delay_ms.saturating_mul(multiplier);
@@ -182,7 +183,6 @@ impl Channel for WebhookChannel {
182183
},
183184
};
184185

185-
let mut last_err: Option<String> = None;
186186
let total_attempts = self.max_retries.saturating_add(1);
187187

188188
for attempt in 0..total_attempts {
@@ -207,7 +207,6 @@ impl Channel for WebhookChannel {
207207
tokio::time::sleep(capped).await;
208208
}
209209
AttemptOutcome::Retry(err_msg) => {
210-
last_err = Some(err_msg.clone());
211210
if attempt + 1 >= total_attempts {
212211
bail!(
213212
"Webhook send failed after {total_attempts} attempt(s); last error: {err_msg}"
@@ -226,11 +225,7 @@ impl Channel for WebhookChannel {
226225
}
227226
}
228227

229-
// Unreachable in practice: loop always returns or bails on the last attempt.
230-
bail!(
231-
"Webhook send failed after {total_attempts} attempt(s){}",
232-
last_err.map(|e| format!(": {e}")).unwrap_or_default()
233-
)
228+
unreachable!("send loop exits via return or bail on the final attempt")
234229
}
235230

236231
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
@@ -402,7 +397,9 @@ impl WebhookChannel {
402397
.await
403398
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
404399

405-
// 429 and 503 may include Retry-After; honor it if present.
400+
// 429 and 503 may include Retry-After; honor it if present. 429 appears here
401+
// *and* in the branch below: here we take the server-supplied delay, below we
402+
// fall back to exponential backoff when no Retry-After header was sent.
406403
if (code == 429 || code == 503)
407404
&& let Some(ms) = retry_after
408405
{
@@ -787,6 +784,49 @@ mod tests {
787784
);
788785
}
789786

787+
#[tokio::test]
788+
async fn send_honors_retry_after_on_503() {
789+
use std::time::Instant;
790+
use wiremock::matchers::{method, path};
791+
use wiremock::{Mock, MockServer, ResponseTemplate};
792+
793+
let mock = MockServer::start().await;
794+
Mock::given(method("POST"))
795+
.and(path("/cb"))
796+
.respond_with(ResponseTemplate::new(503).insert_header("Retry-After", "1"))
797+
.up_to_n_times(1)
798+
.expect(1)
799+
.mount(&mock)
800+
.await;
801+
Mock::given(method("POST"))
802+
.and(path("/cb"))
803+
.respond_with(ResponseTemplate::new(200))
804+
.expect(1)
805+
.mount(&mock)
806+
.await;
807+
808+
let ch = WebhookChannel::new(
809+
8080,
810+
None,
811+
Some(format!("{}/cb", mock.uri())),
812+
None,
813+
None,
814+
None,
815+
Some(2),
816+
Some(10),
817+
Some(2_000),
818+
);
819+
820+
let start = Instant::now();
821+
ch.send(&test_message()).await.unwrap();
822+
let elapsed = start.elapsed();
823+
assert!(
824+
elapsed >= Duration::from_millis(900),
825+
"expected to wait ~1s for Retry-After on 503, elapsed = {:?}",
826+
elapsed
827+
);
828+
}
829+
790830
#[tokio::test]
791831
async fn send_max_retries_zero_disables_retry() {
792832
use wiremock::matchers::{method, path};

β€Žcrates/zeroclaw-config/src/schema.rsβ€Ž

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13037,6 +13037,43 @@ bot_token = "xoxb-tok"
1303713037
assert_eq!(parsed.port, 8080);
1303813038
}
1303913039

13040+
#[test]
13041+
async fn webhook_config_retry_fields_default_to_none() {
13042+
let json = r#"{"port":8080}"#;
13043+
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
13044+
assert!(parsed.max_retries.is_none());
13045+
assert!(parsed.retry_base_delay_ms.is_none());
13046+
assert!(parsed.retry_max_delay_ms.is_none());
13047+
}
13048+
13049+
#[test]
13050+
async fn webhook_config_retry_fields_roundtrip() {
13051+
let wc = WebhookConfig {
13052+
enabled: true,
13053+
port: 8080,
13054+
listen_path: None,
13055+
send_url: Some("https://example.com/cb".into()),
13056+
send_method: None,
13057+
auth_header: None,
13058+
secret: None,
13059+
max_retries: Some(5),
13060+
retry_base_delay_ms: Some(250),
13061+
retry_max_delay_ms: Some(10_000),
13062+
};
13063+
13064+
let json = serde_json::to_string(&wc).unwrap();
13065+
let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
13066+
assert_eq!(parsed.max_retries, Some(5));
13067+
assert_eq!(parsed.retry_base_delay_ms, Some(250));
13068+
assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
13069+
13070+
let toml_str = toml::to_string(&wc).unwrap();
13071+
let parsed: WebhookConfig = toml::from_str(&toml_str).unwrap();
13072+
assert_eq!(parsed.max_retries, Some(5));
13073+
assert_eq!(parsed.retry_base_delay_ms, Some(250));
13074+
assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
13075+
}
13076+
1304013077
// ── WhatsApp config ──────────────────────────────────────
1304113078

1304213079
#[test]

0 commit comments

Comments
Β (0)