mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
A websocket trigger is permanently disabled (with a critical alert) when a single connect attempt in get_consumer fails. Gateway endpoints fronted by edge proxies (e.g. Discord behind Cloudflare) sporadically answer the upgrade handshake with a transient 502/520, so a long-lived trigger that reconnects frequently eventually catches one and dies until a human re-enables it. Retry transient failures (HTTP 5xx/429 handshake responses and IO errors) up to 5 attempts with exponential backoff before surfacing the error, and report retry progress through the trigger's error status. Permanent-looking errors (bad URL, other 4xx, protocol/TLS mismatch) still disable immediately. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
154 lines
5.9 KiB
Rust
154 lines
5.9 KiB
Rust
//! End-to-end tests for the websocket trigger connect-retry behavior: a mock
|
|
//! TCP server rejects the websocket upgrade with a configurable HTTP status a
|
|
//! number of times before completing a real handshake, and the tests assert
|
|
//! which failures `get_consumer` retries.
|
|
//!
|
|
//! This lives in an integration-test binary (own process) because it sets
|
|
//! ALLOW_PRIVATE_WEBSOCKET_URLS — the mock server listens on 127.0.0.1, which
|
|
//! the SSRF check blocks — and that process-global env var must not leak into
|
|
//! the crate's unit tests, which assert loopback URLs are rejected.
|
|
|
|
use std::sync::{
|
|
atomic::{AtomicU32, Ordering},
|
|
Arc,
|
|
};
|
|
|
|
use tokio::{
|
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
|
net::TcpListener,
|
|
sync::{broadcast, RwLock},
|
|
};
|
|
use windmill_trigger::{listener::ListeningTrigger, Listener};
|
|
use windmill_trigger_websocket::{
|
|
WebsocketConfig, WebsocketTrigger, ALLOW_PRIVATE_WEBSOCKET_URLS_ENV,
|
|
};
|
|
|
|
/// Mock server: rejects the first `failures` upgrade requests with
|
|
/// `status_line` and closes, then completes real websocket handshakes and
|
|
/// parks the connection open. Returns the bound address and the
|
|
/// connection-attempt counter.
|
|
async fn mock_ws_server(
|
|
failures: u32,
|
|
status_line: &'static str,
|
|
) -> (std::net::SocketAddr, Arc<AtomicU32>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let attempts = Arc::new(AtomicU32::new(0));
|
|
let served = attempts.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let (mut socket, _) = listener.accept().await.unwrap();
|
|
let n = served.fetch_add(1, Ordering::SeqCst);
|
|
if n < failures {
|
|
// Drain the request head, then reject the upgrade.
|
|
let mut reader = BufReader::new(&mut socket);
|
|
let mut line = String::new();
|
|
loop {
|
|
line.clear();
|
|
let read = reader.read_line(&mut line).await.unwrap_or(0);
|
|
if read == 0 || line == "\r\n" {
|
|
break;
|
|
}
|
|
}
|
|
socket
|
|
.write_all(
|
|
format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\n\r\n").as_bytes(),
|
|
)
|
|
.await
|
|
.ok();
|
|
} else if let Ok(ws) = tokio_tungstenite::accept_async(socket).await {
|
|
tokio::spawn(async move {
|
|
let _open = ws;
|
|
std::future::pending::<()>().await
|
|
});
|
|
}
|
|
}
|
|
});
|
|
(addr, attempts)
|
|
}
|
|
|
|
fn trigger(url: String, trigger_mode: bool) -> ListeningTrigger<WebsocketConfig> {
|
|
ListeningTrigger {
|
|
path: "f/test/ws".to_string(),
|
|
is_flow: false,
|
|
workspace_id: "test".to_string(),
|
|
edited_by: "test".to_string(),
|
|
permissioned_as: "u/test".to_string(),
|
|
trigger_config: WebsocketConfig {
|
|
url,
|
|
filters: vec![],
|
|
filter_logic: "and".to_string(),
|
|
initial_messages: None,
|
|
url_runnable_args: None,
|
|
can_return_message: false,
|
|
can_return_error_result: false,
|
|
heartbeat: None,
|
|
},
|
|
script_path: "f/test/script".to_string(),
|
|
trigger_mode,
|
|
error_handling: None,
|
|
suspended_mode: false,
|
|
}
|
|
}
|
|
|
|
async fn get_consumer_result(
|
|
lt: &ListeningTrigger<WebsocketConfig>,
|
|
err_message: Arc<RwLock<Option<String>>>,
|
|
) -> windmill_common::error::Result<Option<<WebsocketTrigger as Listener>::Consumer>> {
|
|
// The static-URL path of `get_consumer` never touches the DB; a lazy pool
|
|
// satisfies the signature without a running postgres.
|
|
let db: windmill_common::DB =
|
|
sqlx::Pool::connect_lazy("postgres://unused:unused@127.0.0.1:1/unused").unwrap();
|
|
let (_killpill_tx, killpill_rx) = broadcast::channel::<()>(1);
|
|
WebsocketTrigger
|
|
.get_consumer(&db, lt, err_message, killpill_rx)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transient_502s_are_retried_until_the_upgrade_succeeds() {
|
|
std::env::set_var(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV, "true");
|
|
let (addr, attempts) = mock_ws_server(2, "502 Bad Gateway").await;
|
|
let lt = trigger(format!("ws://{addr}"), true);
|
|
let err_message = Arc::new(RwLock::new(None));
|
|
|
|
let consumer = get_consumer_result(<, err_message.clone())
|
|
.await
|
|
.expect("connect should succeed after retries");
|
|
|
|
assert!(consumer.is_some(), "expected an established connection");
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
|
// Retry progress was reported through the shared status lock.
|
|
let status = err_message.read().await.clone().unwrap();
|
|
assert!(status.contains("attempt 2/5"), "got status: {status}");
|
|
assert!(status.contains("502"), "got status: {status}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_transient_http_errors_fail_on_the_first_attempt() {
|
|
std::env::set_var(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV, "true");
|
|
let (addr, attempts) = mock_ws_server(u32::MAX, "404 Not Found").await;
|
|
let lt = trigger(format!("ws://{addr}"), true);
|
|
|
|
let err = get_consumer_result(<, Arc::new(RwLock::new(None)))
|
|
.await
|
|
.expect_err("a 404 upgrade response should not be retried");
|
|
|
|
assert!(err.to_string().contains("404"), "got error: {err}");
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capture_mode_fails_fast_even_on_transient_errors() {
|
|
std::env::set_var(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV, "true");
|
|
let (addr, attempts) = mock_ws_server(u32::MAX, "502 Bad Gateway").await;
|
|
let lt = trigger(format!("ws://{addr}"), false);
|
|
|
|
let err = get_consumer_result(<, Arc::new(RwLock::new(None)))
|
|
.await
|
|
.expect_err("capture mode should surface the first failure immediately");
|
|
|
|
assert!(err.to_string().contains("502"), "got error: {err}");
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
|
}
|