1025-network-errors — Network Error Classification
Tutorial
The Problem
Network errors are not all equal: a DNS failure means the host does not exist (do not retry), a timeout might be transient (retry with backoff), a TLS error means a misconfiguration (alert the operator), and an HTTP 5xx means the server is overloaded (retry). Treating all network errors the same leads to either excessive retries or insufficient resilience.
Structuring network errors as an enum with specific variants enables precise recovery strategies. This is the approach taken by the reqwest, hyper, and tonic crates in the Rust ecosystem.
🎯 Learning Outcomes
NetError enum that captures the full taxonomy of network failuresis_retryable and is_client_error classification methodsDisplay for human-readable error messagesCode Example
#![allow(clippy::all)]
// 1025: Network Error Classification (Simulated)
// Classifying and handling network-like errors
use std::fmt;
#[derive(Debug)]
enum NetError {
Timeout { seconds: f64 },
ConnectionRefused(String),
DnsResolutionFailed(String),
TlsError(String),
HttpError { status: u16, body: String },
}
impl fmt::Display for NetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NetError::Timeout { seconds } => write!(f, "timeout after {:.1}s", seconds),
NetError::ConnectionRefused(host) => write!(f, "connection refused: {}", host),
NetError::DnsResolutionFailed(host) => write!(f, "DNS failed: {}", host),
NetError::TlsError(msg) => write!(f, "TLS error: {}", msg),
NetError::HttpError { status, body } => write!(f, "HTTP {}: {}", status, body),
}
}
}
impl std::error::Error for NetError {}
impl NetError {
fn is_retryable(&self) -> bool {
match self {
NetError::Timeout { .. } => true,
NetError::ConnectionRefused(_) => true,
NetError::DnsResolutionFailed(_) => false,
NetError::TlsError(_) => false,
NetError::HttpError { status, .. } => *status >= 500,
}
}
fn is_client_error(&self) -> bool {
matches!(self, NetError::HttpError { status, .. } if *status >= 400 && *status < 500)
}
}
// Simulated network call
fn fetch(url: &str) -> Result<String, NetError> {
match url {
"" => Err(NetError::DnsResolutionFailed("empty url".into())),
"http://timeout" => Err(NetError::Timeout { seconds: 30.0 }),
"http://refused" => Err(NetError::ConnectionRefused("refused:80".into())),
"http://500" => Err(NetError::HttpError {
status: 500,
body: "Internal Server Error".into(),
}),
"http://404" => Err(NetError::HttpError {
status: 404,
body: "Not Found".into(),
}),
url => Ok(format!("response from {}", url)),
}
}
// Retry logic
fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, NetError> {
let mut last_error = None;
for attempt in 0..=max_retries {
match fetch(url) {
Ok(response) => return Ok(response),
Err(e) if e.is_retryable() && attempt < max_retries => {
last_error = Some(e);
// In real code: sleep with exponential backoff
continue;
}
Err(e) => return Err(e),
}
}
Err(last_error.unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success() {
assert!(fetch("http://example.com").is_ok());
}
#[test]
fn test_timeout() {
let err = fetch("http://timeout").unwrap_err();
assert!(matches!(err, NetError::Timeout { .. }));
assert!(err.is_retryable());
}
#[test]
fn test_connection_refused() {
let err = fetch("http://refused").unwrap_err();
assert!(matches!(err, NetError::ConnectionRefused(_)));
assert!(err.is_retryable());
}
#[test]
fn test_dns_not_retryable() {
let err = fetch("").unwrap_err();
assert!(matches!(err, NetError::DnsResolutionFailed(_)));
assert!(!err.is_retryable());
}
#[test]
fn test_http_500_retryable() {
let err = fetch("http://500").unwrap_err();
assert!(err.is_retryable());
assert!(!err.is_client_error());
}
#[test]
fn test_http_404_not_retryable() {
let err = fetch("http://404").unwrap_err();
assert!(!err.is_retryable());
assert!(err.is_client_error());
}
#[test]
fn test_retry_success() {
let result = fetch_with_retry("http://example.com", 3);
assert!(result.is_ok());
}
#[test]
fn test_retry_exhausted() {
let result = fetch_with_retry("http://timeout", 2);
assert!(result.is_err());
}
#[test]
fn test_no_retry_on_client_error() {
let result = fetch_with_retry("http://404", 3);
assert!(result.is_err()); // should fail immediately, no retries
}
#[test]
fn test_display() {
let err = NetError::Timeout { seconds: 5.0 };
assert_eq!(err.to_string(), "timeout after 5.0s");
let err = NetError::HttpError {
status: 503,
body: "Unavailable".into(),
};
assert_eq!(err.to_string(), "HTTP 503: Unavailable");
}
}Key Differences
is_retryable is a method on NetError; OCaml uses a top-level function pattern-matching on the variant.HttpError { status, body }); OCaml uses inline record syntax { status: int; body: string }.fmt::Display explicitly; OCaml typically uses Format.fprintf or derives show via ppx.reqwest::Error exposes is_timeout(), is_connect(), etc. as methods; OCaml HTTP libraries vary in their error modelling.OCaml Approach
OCaml's Cohttp and Eio libraries use exception hierarchies for network errors. A typed approach mirrors Rust:
type net_error =
| Timeout of float
| ConnectionRefused of string
| DnsError of string
| HttpError of { status: int; body: string }
let is_retryable = function
| Timeout _ | ConnectionRefused _ -> true
| DnsError _ -> false
| HttpError { status; _ } -> status >= 500
Full Source
#![allow(clippy::all)]
// 1025: Network Error Classification (Simulated)
// Classifying and handling network-like errors
use std::fmt;
#[derive(Debug)]
enum NetError {
Timeout { seconds: f64 },
ConnectionRefused(String),
DnsResolutionFailed(String),
TlsError(String),
HttpError { status: u16, body: String },
}
impl fmt::Display for NetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NetError::Timeout { seconds } => write!(f, "timeout after {:.1}s", seconds),
NetError::ConnectionRefused(host) => write!(f, "connection refused: {}", host),
NetError::DnsResolutionFailed(host) => write!(f, "DNS failed: {}", host),
NetError::TlsError(msg) => write!(f, "TLS error: {}", msg),
NetError::HttpError { status, body } => write!(f, "HTTP {}: {}", status, body),
}
}
}
impl std::error::Error for NetError {}
impl NetError {
fn is_retryable(&self) -> bool {
match self {
NetError::Timeout { .. } => true,
NetError::ConnectionRefused(_) => true,
NetError::DnsResolutionFailed(_) => false,
NetError::TlsError(_) => false,
NetError::HttpError { status, .. } => *status >= 500,
}
}
fn is_client_error(&self) -> bool {
matches!(self, NetError::HttpError { status, .. } if *status >= 400 && *status < 500)
}
}
// Simulated network call
fn fetch(url: &str) -> Result<String, NetError> {
match url {
"" => Err(NetError::DnsResolutionFailed("empty url".into())),
"http://timeout" => Err(NetError::Timeout { seconds: 30.0 }),
"http://refused" => Err(NetError::ConnectionRefused("refused:80".into())),
"http://500" => Err(NetError::HttpError {
status: 500,
body: "Internal Server Error".into(),
}),
"http://404" => Err(NetError::HttpError {
status: 404,
body: "Not Found".into(),
}),
url => Ok(format!("response from {}", url)),
}
}
// Retry logic
fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, NetError> {
let mut last_error = None;
for attempt in 0..=max_retries {
match fetch(url) {
Ok(response) => return Ok(response),
Err(e) if e.is_retryable() && attempt < max_retries => {
last_error = Some(e);
// In real code: sleep with exponential backoff
continue;
}
Err(e) => return Err(e),
}
}
Err(last_error.unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success() {
assert!(fetch("http://example.com").is_ok());
}
#[test]
fn test_timeout() {
let err = fetch("http://timeout").unwrap_err();
assert!(matches!(err, NetError::Timeout { .. }));
assert!(err.is_retryable());
}
#[test]
fn test_connection_refused() {
let err = fetch("http://refused").unwrap_err();
assert!(matches!(err, NetError::ConnectionRefused(_)));
assert!(err.is_retryable());
}
#[test]
fn test_dns_not_retryable() {
let err = fetch("").unwrap_err();
assert!(matches!(err, NetError::DnsResolutionFailed(_)));
assert!(!err.is_retryable());
}
#[test]
fn test_http_500_retryable() {
let err = fetch("http://500").unwrap_err();
assert!(err.is_retryable());
assert!(!err.is_client_error());
}
#[test]
fn test_http_404_not_retryable() {
let err = fetch("http://404").unwrap_err();
assert!(!err.is_retryable());
assert!(err.is_client_error());
}
#[test]
fn test_retry_success() {
let result = fetch_with_retry("http://example.com", 3);
assert!(result.is_ok());
}
#[test]
fn test_retry_exhausted() {
let result = fetch_with_retry("http://timeout", 2);
assert!(result.is_err());
}
#[test]
fn test_no_retry_on_client_error() {
let result = fetch_with_retry("http://404", 3);
assert!(result.is_err()); // should fail immediately, no retries
}
#[test]
fn test_display() {
let err = NetError::Timeout { seconds: 5.0 };
assert_eq!(err.to_string(), "timeout after 5.0s");
let err = NetError::HttpError {
status: 503,
body: "Unavailable".into(),
};
assert_eq!(err.to_string(), "HTTP 503: Unavailable");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success() {
assert!(fetch("http://example.com").is_ok());
}
#[test]
fn test_timeout() {
let err = fetch("http://timeout").unwrap_err();
assert!(matches!(err, NetError::Timeout { .. }));
assert!(err.is_retryable());
}
#[test]
fn test_connection_refused() {
let err = fetch("http://refused").unwrap_err();
assert!(matches!(err, NetError::ConnectionRefused(_)));
assert!(err.is_retryable());
}
#[test]
fn test_dns_not_retryable() {
let err = fetch("").unwrap_err();
assert!(matches!(err, NetError::DnsResolutionFailed(_)));
assert!(!err.is_retryable());
}
#[test]
fn test_http_500_retryable() {
let err = fetch("http://500").unwrap_err();
assert!(err.is_retryable());
assert!(!err.is_client_error());
}
#[test]
fn test_http_404_not_retryable() {
let err = fetch("http://404").unwrap_err();
assert!(!err.is_retryable());
assert!(err.is_client_error());
}
#[test]
fn test_retry_success() {
let result = fetch_with_retry("http://example.com", 3);
assert!(result.is_ok());
}
#[test]
fn test_retry_exhausted() {
let result = fetch_with_retry("http://timeout", 2);
assert!(result.is_err());
}
#[test]
fn test_no_retry_on_client_error() {
let result = fetch_with_retry("http://404", 3);
assert!(result.is_err()); // should fail immediately, no retries
}
#[test]
fn test_display() {
let err = NetError::Timeout { seconds: 5.0 };
assert_eq!(err.to_string(), "timeout after 5.0s");
let err = NetError::HttpError {
status: 503,
body: "Unavailable".into(),
};
assert_eq!(err.to_string(), "HTTP 503: Unavailable");
}
}
Deep Comparison
Network Error Classification — Comparison
Core Insight
Network errors need classification (retryable? client error? transient?) for proper handling. Both languages use pattern matching, but Rust's methods on enums keep the logic co-located with the type.
OCaml Approach
is_retryable function matches on variantsstring_of_* functions for displayRust Approach
impl NetError { fn is_retryable(&self) -> bool }Err(e) if e.is_retryable()Display trait for formattingComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Error type | Variant type | Enum |
| Classification | Standalone function | Method on enum |
| Retry guard | when is_retryable e | if e.is_retryable() |
| Structured data | HttpError of int * string | HttpError { status, body } |
| Display | string_of_net_error | impl Display |
| Methods on error | Not idiomatic | Very idiomatic |
Exercises
retry<F>(f: F, max_attempts: u32) -> Result<Response, NetError> function that retries only when is_retryable() returns true.CircuitBreakerError variant with a reset_at: Instant field, and add it to the is_retryable logic.NetError to HTTP response status codes: 504 for timeout, 502 for connection refused, 400 for client errors.