ExamplesBy LevelBy TopicLearning Paths
1025 Intermediate

1025-network-errors — Network Error Classification

Functional Programming

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

  • • Design a NetError enum that captures the full taxonomy of network failures
  • • Implement is_retryable and is_client_error classification methods
  • • Use the error enum to drive retry logic
  • • Implement Display for human-readable error messages
  • • Understand how production HTTP clients structure their error types
  • Code 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

  • Method dispatch: Rust's is_retryable is a method on NetError; OCaml uses a top-level function pattern-matching on the variant.
  • Record fields in variants: Rust supports named fields in enum variants (HttpError { status, body }); OCaml uses inline record syntax { status: int; body: string }.
  • Display trait: Rust requires implementing fmt::Display explicitly; OCaml typically uses Format.fprintf or derives show via ppx.
  • Production libraries: Rust's 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");
        }
    }
    ✓ Tests Rust test suite
    #[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

  • • Variant type with all error kinds
  • • Standalone is_retryable function matches on variants
  • • Retry logic uses recursive function with decrementing counter
  • string_of_* functions for display
  • Rust Approach

  • • Enum with methods: impl NetError { fn is_retryable(&self) -> bool }
  • • Pattern matching with guards: Err(e) if e.is_retryable()
  • • Retry loop with attempt counter
  • Display trait for formatting
  • Comparison Table

    AspectOCamlRust
    Error typeVariant typeEnum
    ClassificationStandalone functionMethod on enum
    Retry guardwhen is_retryable eif e.is_retryable()
    Structured dataHttpError of int * stringHttpError { status, body }
    Displaystring_of_net_errorimpl Display
    Methods on errorNot idiomaticVery idiomatic

    Exercises

  • Implement a retry<F>(f: F, max_attempts: u32) -> Result<Response, NetError> function that retries only when is_retryable() returns true.
  • Add a CircuitBreakerError variant with a reset_at: Instant field, and add it to the is_retryable logic.
  • Write a function that maps NetError to HTTP response status codes: 504 for timeout, 502 for connection refused, 400 for client errors.
  • Open Source Repos