Skip to content

Commit 1130e72

Browse files
committed
gw: retry+exponential backoff for port_attrs lazy fetch
Right after registration, the WireGuard handshake hasn't completed yet and the agent's TCP port isn't reachable. The previous one-shot fetch would fail and leave the cache empty, falling back to pp=false until the next connection (which would itself eat one more failed fetch). Move the timeout/retry policy into a dedicated config block so it can be tuned per deployment: [core.proxy.port_attrs_fetch] timeout = "10s" # per-attempt Info() RPC timeout max_retries = 5 # extra attempts after the initial try backoff_initial = "1s" # doubles each retry up to backoff_max backoff_max = "30s" Worst-case 1+2+4+8+16+30 ≈ 1 min covers a reasonable WG warmup window. Bail out early when the instance is no longer in state (recycled while queued) — the unknown-instance error chain is the signal.
1 parent 70c5340 commit 1130e72

3 files changed

Lines changed: 78 additions & 20 deletions

File tree

gateway/gateway.toml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ max_connections_per_app = 2000
6161
# Whether to read PROXY protocol from inbound connections (e.g. from Cloudflare).
6262
inbound_pp_enabled = false
6363

64+
[core.proxy.port_attrs_fetch]
65+
# Background lazy-fetch of port_attrs from legacy CVM agents.
66+
# Single Info() RPC timeout.
67+
timeout = "10s"
68+
# Retries cover the WireGuard / agent warmup window after registration.
69+
max_retries = 5
70+
# Exponential backoff between retries; doubles each attempt up to backoff_max.
71+
backoff_initial = "1s"
72+
backoff_max = "30s"
73+
6474
[core.proxy.timeouts]
6575
# Timeout for establishing a connection to the target app.
6676
connect = "5s"
@@ -85,8 +95,6 @@ shutdown = "5s"
8595
total = "5h"
8696
# Timeout for proxy protocol header.
8797
pp_header = "5s"
88-
# Timeout for the background lazy fetch of port_attrs from a legacy CVM.
89-
port_attrs_fetch = "10s"
9098

9199
[core.recycle]
92100
enabled = true

gateway/src/config.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ pub struct ProxyConfig {
124124
/// (e.g. when behind a PP-aware load balancer like Cloudflare).
125125
#[serde(default)]
126126
pub inbound_pp_enabled: bool,
127+
/// Background lazy-fetch behaviour for `port_attrs` (legacy CVMs).
128+
pub port_attrs_fetch: PortAttrsFetchConfig,
129+
}
130+
131+
#[derive(Debug, Clone, Deserialize)]
132+
pub struct PortAttrsFetchConfig {
133+
/// Timeout for a single `Info()` RPC attempt.
134+
#[serde(with = "serde_duration")]
135+
pub timeout: Duration,
136+
/// Maximum number of attempts after the initial try (0 = no retry).
137+
/// Retries cover the window where a freshly-registered CVM hasn't
138+
/// finished its WireGuard handshake yet.
139+
pub max_retries: u32,
140+
/// Delay before the first retry; doubles on each subsequent retry,
141+
/// capped at `backoff_max`.
142+
#[serde(with = "serde_duration")]
143+
pub backoff_initial: Duration,
144+
#[serde(with = "serde_duration")]
145+
pub backoff_max: Duration,
127146
}
128147

129148
#[derive(Debug, Clone, Deserialize)]
@@ -152,10 +171,6 @@ pub struct Timeouts {
152171
/// Timeout for reading the proxy protocol header from inbound connections.
153172
#[serde(with = "serde_duration")]
154173
pub pp_header: Duration,
155-
/// Timeout for the background lazy fetch of `port_attrs` from a legacy CVM
156-
/// agent's `Info()` RPC.
157-
#[serde(with = "serde_duration")]
158-
pub port_attrs_fetch: Duration,
159174
}
160175

161176
#[derive(Debug, Clone, Deserialize, Serialize)]

gateway/src/proxy/port_attrs.rs

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,12 @@ pub(crate) fn should_send_pp(state: &Proxy, instance_id: &str, port: u16) -> boo
4545
/// Spawn the background lazy-fetch worker. Should be called once at startup.
4646
pub(crate) fn spawn_fetcher(state: Proxy, mut rx: UnboundedReceiver<String>) {
4747
let in_flight: Arc<Mutex<HashSet<String>>> = Default::default();
48-
let timeout = state.config.proxy.timeouts.port_attrs_fetch;
4948
tokio::spawn(async move {
5049
while let Some(instance_id) = rx.recv().await {
51-
// Dedupe: only spawn one fetch per instance at a time. After the
52-
// fetch completes, the entry is removed so a later registration
53-
// (with new compose_hash) can trigger a fresh fetch via the same
54-
// path.
50+
// Dedupe: only one fetch per instance at a time. The entry is
51+
// removed once the retry loop terminates (success, exhausted,
52+
// or unknown-instance), so a later registration with a new
53+
// compose_hash can re-trigger via the same path.
5554
{
5655
let mut in_flight = in_flight.lock().expect("port_attrs in_flight poisoned");
5756
if !in_flight.insert(instance_id.clone()) {
@@ -62,15 +61,7 @@ pub(crate) fn spawn_fetcher(state: Proxy, mut rx: UnboundedReceiver<String>) {
6261
let in_flight = in_flight.clone();
6362
let id = instance_id.clone();
6463
tokio::spawn(async move {
65-
match tokio::time::timeout(timeout, fetch_and_store(&state, &id)).await {
66-
Ok(Ok(())) => debug!("port_attrs cached for instance {id}"),
67-
Ok(Err(err)) => {
68-
warn!("port_attrs fetch for instance {id} failed: {err:#}")
69-
}
70-
Err(_) => {
71-
warn!("port_attrs fetch for instance {id} timed out after {timeout:?}")
72-
}
73-
}
64+
fetch_with_retry(&state, &id).await;
7465
in_flight
7566
.lock()
7667
.expect("port_attrs in_flight poisoned")
@@ -80,6 +71,50 @@ pub(crate) fn spawn_fetcher(state: Proxy, mut rx: UnboundedReceiver<String>) {
8071
});
8172
}
8273

74+
async fn fetch_with_retry(state: &Proxy, instance_id: &str) {
75+
let cfg = &state.config.proxy.port_attrs_fetch;
76+
let mut attempt = 0u32;
77+
let mut backoff = cfg.backoff_initial;
78+
loop {
79+
match tokio::time::timeout(cfg.timeout, fetch_and_store(state, instance_id)).await {
80+
Ok(Ok(())) => {
81+
debug!("port_attrs cached for instance {instance_id} (attempt {attempt})");
82+
return;
83+
}
84+
Ok(Err(err)) if is_unknown_instance(&err) => {
85+
// Instance was recycled while the fetch was queued. No
86+
// point retrying — the instance is gone.
87+
debug!("port_attrs fetch for {instance_id}: instance no longer exists, giving up");
88+
return;
89+
}
90+
Ok(Err(err)) => {
91+
warn!("port_attrs fetch for {instance_id} failed (attempt {attempt}): {err:#}");
92+
}
93+
Err(_) => {
94+
warn!(
95+
"port_attrs fetch for {instance_id} timed out after {:?} (attempt {attempt})",
96+
cfg.timeout
97+
);
98+
}
99+
}
100+
if attempt >= cfg.max_retries {
101+
warn!(
102+
"port_attrs fetch for {instance_id} giving up after {} attempts",
103+
attempt + 1
104+
);
105+
return;
106+
}
107+
tokio::time::sleep(backoff).await;
108+
attempt += 1;
109+
backoff = (backoff * 2).min(cfg.backoff_max);
110+
}
111+
}
112+
113+
fn is_unknown_instance(err: &anyhow::Error) -> bool {
114+
err.chain()
115+
.any(|e| e.to_string().contains("unknown instance"))
116+
}
117+
83118
async fn fetch_and_store(state: &Proxy, instance_id: &str) -> Result<()> {
84119
let (ip, agent_port) = {
85120
let guard = state.lock();

0 commit comments

Comments
 (0)