Handling iroh Connection Errors and Timeouts: Relay Actor Resilience Patterns
Iroh handles connection errors and timeouts through a dedicated relay actor that classifies failures into distinct phases—dialing, handshake, and established connection—each with specific timeout constants and exponential backoff retry logic.
The n0-computer/iroh repository implements robust error handling for relay connections through a specialized actor model in iroh/src/socket/transports/relay/actor.rs. Understanding how to handle iroh connection errors and timeouts is essential for building resilient peer-to-peer applications that gracefully recover from network instability.
Understanding iroh Relay Connection Error Types
The relay actor models failures explicitly using three distinct error enums that correspond to different phases of the connection lifecycle.
RelayConnectionError: The Top-Level Classification
RelayConnectionError serves as the primary error type returned from the actor's run loop. Defined in iroh/src/socket/transports/relay/actor.rs at lines 216-224, this enum distinguishes between three critical failure phases:
- Dial: Failed to establish a TCP/QUIC connection
- Handshake: Connected but failed to complete the relay handshake
- Established: Connection dropped after successful establishment
DialError: Connection Establishment Failures
DialError captures failures that occur while attempting to open a TCP/QUIC connection. Located at lines 252-256 in actor.rs, this variant includes a dedicated Timeout variant governed by the CONNECT_TIMEOUT constant and wraps low-level ConnectError types for DNS resolution or transport failures.
RunError: Post-Establishment Failures
After a connection is established, RunError (lines 227-240 in actor.rs) handles runtime failures including send timeouts, ping timeouts, and stream closure events. These errors wrap around the Established variant of RelayConnectionError when they cause connection termination.
Timeout Constants and Their Applications
Iroh applies specific timeouts at distinct phases of the relay connection lifecycle to prevent indefinite blocking.
CONNECT_TIMEOUT for the Dial Phase
The dial timeout limits the entire dial-and-handshake phase, covering DNS resolution, TCP/QUIC connection, and TLS handshake. In dial_relay() (lines 80-85), iroh uses tokio::time::timeout(CONNECT_TIMEOUT, …) to enforce this limit. The default value is typically 10 seconds, though the exact duration depends on the RelayConnectionOptions configuration.
PING_INTERVAL for Keepalive and Send Operations
The ping timeout determines connection health after establishment. If a ping is sent without a corresponding pong within this interval, the connection is considered dead. This same interval serves as the send timeout in run_sending() (lines 42-44), limiting how long a batch of outbound datagrams may take to transmit.
RELAY_INACTIVE_CLEANUP_TIME for Resource Management
To prevent resource leaks, iroh implements an inactive relay cleanup timeout defined at lines 64-66 in actor.rs. If no datagrams are sent for a configurable period, the actor shuts down the connection, except for the home relay which remains persistent.
The Exponential Backoff Retry Loop
When errors occur, the outer run() loop implements sophisticated retry logic with exponential backoff to handle transient failures without overwhelming the network.
The backoff strategy is constructed via build_backoff() (lines 50-57) with the following characteristics:
- Minimum delay: 10 milliseconds
- Maximum delay: 16 seconds
- Jitter: Enabled to prevent thundering herds
- Retry limit: Unlimited
let mut backoff = Self::build_backoff(); // Creates exponential backoff
while let Err(err) = self.run_once().await { // Returns RelayConnectionError
warn!("{err:#}");
let was_established = matches!(err, RelayConnectionError::Established { .. });
if !was_established {
// Dialing or handshake failed → wait before retrying
if let Some(delay) = backoff.next() {
debug!("retry in {delay:?}");
time::sleep(delay).await;
}
} else {
// Connection had been established → reset backoff and reconnect immediately
backoff = Self::build_backoff();
}
}
This logic distinguishes between initial connection failures (which back off) and dropped established connections (which reset the backoff and reconnect immediately).
Detecting Errors in Application Code
When using the public RelayActor API, failures propagate as anyhow::Error values that can be downcast to specific RelayConnectionError variants for programmatic handling.
use iroh::relay::RelayActor;
use iroh::socket::transports::relay::RelayConnectionError;
#[tokio::main]
async fn main() {
// Build a RelayActor (details omitted for brevity)
let (actor, handle) = RelayActor::new(/* … */);
let mut actor_fut = tokio::spawn(actor.run(/* … */));
// Wait for the actor to finish (e.g. on shutdown)
if let Err(err) = actor_fut.await.unwrap() {
// Match on the concrete error kind
if let Some(conn_err) = err.downcast_ref::<RelayConnectionError>() {
match conn_err {
RelayConnectionError::Dial { source } => {
eprintln!("Dial failed: {}", source);
}
RelayConnectionError::Handshake { source } => {
eprintln!("Handshake timed out or failed: {}", source);
}
RelayConnectionError::Established { source } => {
eprintln!("Connection lost after being up: {}", source);
}
}
} else {
eprintln!("Unexpected error: {}", err);
}
}
}
This pattern allows applications to distinguish between temporary network unavailability (dial failures) and persistent configuration issues (handshake failures).
Customizing Timeouts for Network Conditions
When default values don't suit your network environment, you can adjust timeouts before creating a relay connection. Note that these constants are defined in iroh/src/socket/transports/relay/actor.rs and may require custom configuration through the ClientBuilder or RelayConnectionOptions.
use iroh::relay::client::ClientBuilder;
use iroh::base::RelayUrl;
use std::time::Duration;
async fn custom_dial(url: RelayUrl) -> Result<(), anyhow::Error> {
// Build a client with a longer dial timeout (30 s)
let builder = ClientBuilder::new(url, /* secret_key */ Default::default(), /* dns */ Default::default())
.tls_client_config(/* ... */)
.address_family_selector(|| false); // IPv4 only for this example
// Manually apply a timeout larger than the default CONNECT_TIMEOUT
let client = tokio::time::timeout(Duration::from_secs(30), builder.connect())
.await?
.map_err(|e| anyhow::anyhow!("connect error: {}", e))?;
// Use the client (ping/pong) – any ping timeout will be reported as RunError::PingTimeout
let ping_tracker = iroh_relay::PingTracker::default();
// … send a ping, await pong, handle errors …
Ok(())
}
For production deployments on high-latency links, consider increasing PING_INTERVAL to 60 seconds and RELAY_INACTIVE_CLEANUP_TIME to 300 seconds to prevent premature connection teardown.
Summary
- Error Classification: Iroh uses
RelayConnectionError,DialError, andRunErrorto distinguish between dialing failures, handshake failures, and established connection drops iniroh/src/socket/transports/relay/actor.rs. - Timeout Hierarchy:
CONNECT_TIMEOUTgoverns initial connection,PING_INTERVALhandles keepalive and send timeouts, andRELAY_INACTIVE_CLEANUP_TIMEmanages resource cleanup. - Retry Logic: The
run()loop implements exponential backoff (10ms to 16s) for initial failures but resets backoff immediately for dropped established connections. - Error Handling: Applications can downcast
anyhow::ErrortoRelayConnectionErrorto implement specific recovery strategies for different failure modes. - Configuration: Timeout constants can be adjusted through the
ClientBuilderor custom configuration structures to accommodate high-latency or unstable networks.
Frequently Asked Questions
What is the default dial timeout in iroh?
The default dial timeout is controlled by the CONNECT_TIMEOUT constant in iroh/src/socket/transports/relay/actor.rs (lines 80-85). While the exact value depends on the build configuration, it typically defaults to 10 seconds, covering DNS resolution, TCP/QUIC connection establishment, and TLS handshake completion.
How does iroh distinguish between initial connection failures and dropped established connections?
Iroh distinguishes these through the RelayConnectionError enum variants. A Dial or Handshake variant indicates the connection never reached an operational state, while an Established variant indicates the connection was previously healthy and then failed. The retry logic in run() treats these differently: initial failures trigger exponential backoff, while established connection drops reset the backoff and reconnect immediately.
Can I disable the exponential backoff for relay connections?
The backoff is hardcoded in build_backoff() at lines 50-57 of actor.rs with settings of 10ms minimum and 16s maximum delay. While you cannot disable it without modifying the source code, you can effectively minimize delays by handling errors at the application level and immediately triggering a new RelayActor instance when specific error variants occur.
What happens when a ping timeout occurs in an established relay connection?
When a ping is sent without a corresponding pong within the PING_INTERVAL duration, the run_connected() or run_sending() method returns RunError::PingTimeout. This error propagates through the Established variant of RelayConnectionError, causing the outer run() loop to reset the exponential backoff and immediately attempt to re-establish the connection with the relay server.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →