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::Errorand the constructors that make them:Error::new,Error::other,From<ErrorKind>,Error::from_raw_os_error. - The
ErrorKindenum: which operations produce which kinds, and how to match onkind()to select a recovery strategy. - Why
ErrorKind::Interruptedis special. - Custom error payloads:
get_ref,into_inner, andError::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 ofError::new(ErrorKind::Other, payload). It is the usual pattern to put an application error into anio::Errorwhen no specific kind applies. The standard library never usesOtherfor OS failures, so a match onOtherfinds only your errors.- OS error code 2 maps to
NotFoundon Unix (ENOENT) and on Windows (ERROR_FILE_NOT_FOUND). But each platform has its ownDisplaytext. Match onkind(), 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:
| Operation | Kind |
|---|---|
File::open on a missing path | NotFound |
read_exact on a stream that is too short | UnexpectedEof |
read_to_string on invalid UTF-8 | InvalidData |
write_all into a full buffer of fixed size | WriteZero |
| A syscall that a signal stops | Interrupted |
| 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 withdowncast_ref::<T>()to read the fields and not consume the error.into_inner()consumes the error and returns the boxed payload. It returnsNonefor 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::Resultand use?freely. At the module boundary, wrap errors of other types withError::neworError::other. Select the most specificErrorKindthat you can justify. - At application boundaries: convert the
io::Errorinto your domain error (Domain 2 patterns). Keep theio::Erroras thesource(), so that you do not lose context. - In
main:fn main() -> io::Result<()>is valid becauseio::Errorimplements the necessary traits. Most examples in this domain use this signature.
Summary
| Concept | Key point |
|---|---|
io::Result<T> | Alias for Result<T, io::Error>. Use the qualified name. |
| Three representations | Kind 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_error | Wraps an errno. raw_os_error() returns Some(code). |
kind() matching | One strategy for each category. Never match on Display strings. |
#[non_exhaustive] | Always keep a _ arm, because releases continue to stabilize new kinds |
InvalidInput vs InvalidData | Incorrect arguments vs. malformed stream contents |
Interrupted | Retry immediately. The std helpers retry it for you. |
get_ref / into_inner / downcast | Borrow, extract, or recover the typed payload (downcast: 1.79) |
source() detail | The payload is part of the error, not a separate chain link |
Code Examples
| File | Description |
|---|---|
08_11_error_construction.rs | The four constructors, io::Result, raw_os_error, and assertions that show the three internal representations |
08_12_errorkind_matching.rs | Real operations that produce real kinds, a recovery policy with match, and the retry of Interrupted with a mock reader |
08_13_custom_payloads_downcast.rs | Structured payloads: get_ref with downcast_ref, into_inner, Error::downcast on a match and on a mismatch, and the 2-link source() chain |