Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8.4 · std::io::Error: Anatomy of I/O Errors

Domain 8 — I/O System Duration: ~15 minutes Library components: std::io::Error, std::io::ErrorKind, std::io::Result

Introduction

Each fallible operation in Domain 8 returns io::Result<T>, which is an alias for Result<T, io::Error>. That one error type must represent many different failures. Two examples are a missing file (an OS errno) and a checksum mismatch in your own parser (an application payload). The type must also be cheap to construct on hot paths.

To write code that handles I/O failures, you must know the internal representations of the type, its ErrorKind taxonomy, and its payload mechanism. Without this knowledge, code can only propagate strings.

This tutorial shows:

  • The three internal representations of io::Error and the constructors that make them: Error::new, Error::other, From<ErrorKind>, Error::from_raw_os_error.
  • The ErrorKind enum: which operations produce which kinds, and how to match on kind() to select a recovery strategy.
  • Why ErrorKind::Interrupted is special.
  • Custom error payloads: get_ref, into_inner, and Error::downcast.

Three Representations, Four Constructors

Internally, an io::Error stores one of three representations, and each constructor makes one of them. You can observe the difference with raw_os_error() and get_ref():

Figure: How each constructor shapes the error

use std::io;
use std::io::ErrorKind;

let simple: io::Error = ErrorKind::TimedOut.into();      // kind only: no allocation
let os     = io::Error::from_raw_os_error(2);            // errno or Win32 code
let custom = io::Error::new(ErrorKind::TimedOut, "gateway");  // kind + boxed payload

assert_eq!(simple.raw_os_error(), None);   // a kind-only error has no OS code
assert!(simple.get_ref().is_none());       // and it has no payload
assert_eq!(os.raw_os_error(), Some(2));    // an OS error keeps its code
assert!(custom.get_ref().is_some());       // the payload "gateway"

Practical notes:

  • From<ErrorKind> is allocation-free. Prefer it when you have no payload to add.
  • Error::other(payload) (stable since 1.74) is the short form of Error::new(ErrorKind::Other, payload). It is the usual pattern to put an application error into an io::Error when no specific kind applies. The standard library never uses Other for OS failures, so a match on Other finds only your errors.
  • OS error code 2 maps to NotFound on Unix (ENOENT) and on Windows (ERROR_FILE_NOT_FOUND). But each platform has its own Display text. Match on kind(), never on message strings.

ErrorKind: The Taxonomy

Operations produce the kinds. You do not invent them. Example 08_12 produces the first four kinds of this table with real operations, and Interrupted with a mock reader:

OperationKind
File::open on a missing pathNotFound
read_exact on a stream that is too shortUnexpectedEof
read_to_string on invalid UTF-8InvalidData
write_all into a full buffer of fixed sizeWriteZero
A syscall that a signal stopsInterrupted
An invalid seek (to before byte 0, tutorial 8.3)InvalidInput

Two kinds have similar names. InvalidInput means that your arguments were incorrect. InvalidData means that the contents of the stream were malformed.

The purpose of the taxonomy is to let you select a strategy for each category:

// Returns the name of the strategy for an error.
fn policy(err: &io::Error) -> &'static str {
    match err.kind() {
        // A missing file is frequently an expected case, not a failure.
        ErrorKind::NotFound => "create it and continue",
        // Temporary conditions: try again a limited number of times.
        ErrorKind::Interrupted | ErrorKind::TimedOut | ErrorKind::WouldBlock => "retry",
        // A retry does not correct a permission problem.
        ErrorKind::PermissionDenied => "report and abort",
        // Truncated or corrupt input: do not trust partial results.
        ErrorKind::UnexpectedEof | ErrorKind::InvalidData => "reject the input",
        // All other kinds, and each kind that a future release adds.
        _ => "propagate",
    }
}

// `ErrorKind::X.into()` makes a kind-only io::Error (see the previous section).
assert_eq!(policy(&ErrorKind::TimedOut.into()), "retry");
assert_eq!(policy(&ErrorKind::AddrInUse.into()), "propagate");   // the `_` arm

The wildcard arm is mandatory, because ErrorKind is #[non_exhaustive]. The standard library continues to add kinds. For example, 1.83 stabilized StorageFull, NotADirectory, IsADirectory, and more. Then 1.85 stabilized QuotaExceeded and CrossesDevices. Your match must continue to compile when a release adds a kind.

Interrupted: The Kind You Retry

ErrorKind::Interrupted means that the operation did nothing: call it again. On Unix, it corresponds to EINTR. A signal arrived during a syscall, and the system stopped the operation before it moved any bytes. Interrupted is the one kind that is always safe to retry immediately. The helper methods of the standard library do this retry. A manual loop does it as follows:

// flaky: a mock reader. It returns Interrupted two times, then it reads "payload".
// buf: [u8; 7], the destination buffer.
loop {
    let n = match flaky.read(&mut buf) {
        Ok(n) => n,                                                 // n bytes are in buf
        Err(e) if e.kind() == ErrorKind::Interrupted => continue,   // no bytes moved: call again
        Err(e) => return Err(e),                                    // each other kind is fatal here
    };
    // ... use the n bytes ...
    break;
}

The responsibility has two parts. A manual read loop must handle Interrupted itself. But read_to_end, read_exact, write_all, and io::copy each retry it internally. Example 08_12 proves the two parts with a mock reader that injects two interruptions. The manual loop makes three attempts. read_to_end hides the interruptions fully.

08_12_errorkind_matching.rs prints:

produced: NotFound, UnexpectedEof, InvalidData, WriteZero
policy: NotFound->create, TimedOut->retry, Eof->reject, other->propagate
Interrupted: manual loop took 3 attempts; read_to_end hid them

All assertions passed.

Custom Payloads: get_ref, into_inner, downcast

Error::new accepts any payload that implements Error + Send + Sync + 'static, not only strings. Thus an io::Error can carry a typed value. Generic callers see a kind and a Display message. Callers that know the payload type can recover the structured value.

// A structured payload. PartialEq lets you compare a recovered payload by value.
#[derive(Debug, PartialEq, Eq)]
struct ChecksumMismatch { expected: u32, actual: u32 }
// ... Display + Error impls ...
// Display prints "checksum mismatch: expected deadbeef, got 0badf00d" for the value below.

// A simulated block read: block 7 fails, each other block succeeds.
fn read_block(block: u32) -> io::Result<Vec<u8>> {
    if block == 7 {
        return Err(io::Error::new(
            ErrorKind::InvalidData,                                           // the kind
            ChecksumMismatch { expected: 0xDEAD_BEEF, actual: 0x0BAD_F00D },  // the payload
        ));
    }
    Ok(vec![0u8; 512])
}

Three methods recover the payload. They differ in ownership:

  • get_ref() borrows the payload as &dyn Error. Use it with downcast_ref::<T>() to read the fields and not consume the error.
  • into_inner() consumes the error and returns the boxed payload. It returns None for kind-only errors and for OS errors.
  • Error::downcast::<T>() (stable since 1.79) does the two steps in one call. If the type matches, it returns the payload by value. If the type does not match, it returns the original error with no change, so a failed attempt has no cost:
// err: io::Error, for example from read_block(7). downcast consumes it.
match err.downcast::<ChecksumMismatch>() {
    // mismatch: ChecksumMismatch, by value. This arm prints "expected deadbeef".
    Ok(mismatch) => println!("expected {:08x}", mismatch.expected),
    // A different payload type: downcast returns the same io::Error, with no change.
    Err(err) => return Err(err),
}

Code that follows the source() chain (tutorial 2.2) must know one detail. The payload is part of the io::Error. It is not a separate link behind it. The Display output of the io::Error is the message of the payload, and its source() returns the source of the payload. Thus, if you put the error from read_block in an application error, the chain has 2 links, not 3. To get the payload as a value, use downcast.

io::Result and Propagation Patterns

io::Result<T> is only a type alias. It adds no new semantics. It makes signatures shorter, and these signatures occur everywhere in I/O code:

fn parse_port(input: &str) -> io::Result<u16> {
    input.trim().parse()   // Result<u16, ParseIntError>
        // Convert the ParseIntError: select a kind and attach a String payload.
        .map_err(|e| io::Error::new(ErrorKind::InvalidInput, format!("bad port: {e}")))
}

// parse_port("8080") returns Ok(8080).
// parse_port("http") returns an Err with the kind InvalidInput and the message
// "bad port: invalid digit found in string".

Import std::io and write the qualified name (io::Result). Then the alias does not shadow the Result of the prelude in the remainder of the module.

How to select a propagation pattern:

  • In modules that do much I/O: return io::Result and use ? freely. At the module boundary, wrap errors of other types with Error::new or Error::other. Select the most specific ErrorKind that you can justify.
  • At application boundaries: convert the io::Error into your domain error (Domain 2 patterns). Keep the io::Error as the source(), so that you do not lose context.
  • In main: fn main() -> io::Result<()> is valid because io::Error implements the necessary traits. Most examples in this domain use this signature.

Summary

ConceptKey point
io::Result<T>Alias for Result<T, io::Error>. Use the qualified name.
Three representationsKind only, OS code, or kind + boxed payload
From<ErrorKind>Allocation-free. The Display text is the standard text of the kind.
Error::new(kind, payload)Attaches any payload that is Error + Send + Sync + 'static
Error::other(payload)Short form of new(ErrorKind::Other, ...) (1.74). Other never comes from the OS.
Error::from_raw_os_errorWraps an errno. raw_os_error() returns Some(code).
kind() matchingOne strategy for each category. Never match on Display strings.
#[non_exhaustive]Always keep a _ arm, because releases continue to stabilize new kinds
InvalidInput vs InvalidDataIncorrect arguments vs. malformed stream contents
InterruptedRetry immediately. The std helpers retry it for you.
get_ref / into_inner / downcastBorrow, extract, or recover the typed payload (downcast: 1.79)
source() detailThe payload is part of the error, not a separate chain link

Code Examples

FileDescription
08_11_error_construction.rsThe four constructors, io::Result, raw_os_error, and assertions that show the three internal representations
08_12_errorkind_matching.rsReal operations that produce real kinds, a recovery policy with match, and the retry of Interrupted with a mock reader
08_13_custom_payloads_downcast.rsStructured payloads: get_ref with downcast_ref, into_inner, Error::downcast on a match and on a mismatch, and the 2-link source() chain