Types that refuse bad input
This chapter covers
- Traits: what they are, how to implement one, and what
#[derive]writes for you - A newtype: a struct around one number that can only hold valid values
- Error types that say what went wrong, with
Displayandstd::error::Error - Converting between types with
FromandTryFrom - Enums whose variants carry data, used for commands, for states, and for errors
- A parser that turns a line of text into a typed command
This chapter begins Part 3, on modeling data. So far your programs have used the types Rust provides: numbers, strings, vectors, and maps. In this chapter you design your own types, so that invalid values cannot be represented at all.
The examples come from a small command-line tool that manages jobs on a remote computer. The jobs are called
workloads. Each workload asks for a number of GPUs, which must be between 1 and 64. A GPU is a graphics processor,
and servers also use GPUs for general computation. A workload moves through states such as pending,
running, and terminated. The tool reads commands typed by a user, such as create triton 4, which asks for a
workload named triton with 4 GPUs. That is all you need to know about the tool.
Here is the problem this chapter solves. A GPU count arrives as text typed by a user. The program must reject
0, 99, and many, and it must do so before the count reaches the code that uses it. If each function
checked the count itself, one forgotten check would let a bad value through. Instead, the program checks the
count once, where the text enters, and stores the result in a type that can only hold valid counts. Every
function that receives that type can rely on it without checking again.
The chapter builds this in order:
- Section 7.1 explains traits, which the rest of the chapter implements.
- Section 7.2 builds the GPU count type.
- Section 7.3 gives it an error type.
- Section 7.4 adds the standard conversions.
- Section 7.5 builds an enum for commands and a parser for them.
- Section 7.6 models the workload’s states and the moves between them.
- Section 7.7 models the errors a server can return.
7.1 Traits
A trait is a set of methods that a type promises to provide. The standard library defines many traits.
For example, the Display trait has one method, fmt, which writes a value as text for a person to read.
println!("{}", x) works for any x whose type implements Display.
You implement a trait for your type with an impl block that names the trait:
impl fmt::Display for GpuCountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "...")
}
}
Some traits are so regular that the compiler can write them for you. #[derive(Debug, Clone, PartialEq)] placed above a struct asks the compiler to generate three traits.
Debug prints with {:?}, Clone copies with .clone(), and PartialEq compares with ==. The derived versions work field by field.
Table 7.1 lists the traits this chapter uses.
| Trait | What it gives you | How you get it |
|---|---|---|
Debug | printing with {:?}, for programmers | #[derive(Debug)] |
Display | printing with {}, for users | write impl Display yourself |
Clone, Copy | .clone(), and for Copy, copying by assignment | derive |
PartialEq, Eq | == and != | derive |
PartialOrd, Ord | <, >, and sorting | derive |
Hash | use as a HashMap key | derive |
std::error::Error | use as an error with ? and Box<dyn Error> | write an empty impl once Debug and Display exist |
From, TryFrom | conversions between types | write impl yourself |
7.2 A GPU count that is always valid
A newtype is a struct with one field, used to give a plain value a type of its own. Here the plain value
is a u32, and the new type is GpuCount.
The trick is privacy. Fields in Rust are private by default. Code outside the module where the struct is defined cannot read or
write them, and cannot build the struct with GpuCount(0). The only way to get a
GpuCount is through the functions the module offers. If those functions check the value, every
GpuCount in the program is valid. Figure 7.1 shows the design.
The module starts with a comment that lists its ideas:
Listing 7.1 The module comment (lines 1 to 12). src/problems/adt_idioms.rs
//! Small algebraic-data-type idioms.
//!
//! These patterns are short and cannot represent invalid states.
//!
//! - A newtype gives a primitive a name and a single validating constructor, so
//! an out-of-range value cannot exist.
//! - An enum with data models a closed set of alternatives, and `match` forces
//! every alternative to be handled.
//! - Typed errors carry the reason instead of a string, so the caller can react.
//! - `TryFrom` puts the validation on the type boundary, where it belongs.
//! - An iterator chain states the intent in one pass and removes index
//! bookkeeping.
Here is the type:
Listing 7.2 GpuCount (lines 14 to 36).
use std::fmt;
/// A GPU count that is validated at construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GpuCount(u32);
impl GpuCount {
pub const MIN: u32 = 1;
pub const MAX: u32 = 64;
/// Validate a raw count.
pub fn new(value: u32) -> Result<Self, GpuCountError> {
if (Self::MIN..=Self::MAX).contains(&value) {
Ok(Self(value))
} else {
Err(GpuCountError(value))
}
}
pub fn get(self) -> u32 {
self.0
}
}
pub struct GpuCount(u32);is a tuple struct: a struct whose field has no name, only a position. The struct is public, but its field is not.pub const MIN: u32 = 1;andMAXare associated constants, constants that belong to the type. You refer to them asGpuCount::MIN, orSelf::MINinside theimpl.newis the gate.(Self::MIN..=Self::MAX)is the range 1 to 64, including both ends, and.contains(&value)checks the value. If it is in range,newreturnsOk(Self(value)). If not, it returnsErr(GpuCountError(value)), an error that carries the rejected value. Section 7.3 shows that type.getreturns the number inside. It takesselfby value, which is fine becauseGpuCountisCopy.
The long #[derive(...)] line gives GpuCount the same abilities as the u32 inside it. You can print it,
copy it, compare it, sort it, and use it as a map key.
7.3 An error that says what went wrong
When new rejects a value, it returns a GpuCountError. Here is that type:
Listing 7.3 The error type (lines 38 to 54).
/// Why a raw GPU count was rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GpuCountError(pub u32);
impl fmt::Display for GpuCountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"gpu count {} is outside {}..={}",
self.0,
GpuCount::MIN,
GpuCount::MAX
)
}
}
impl std::error::Error for GpuCountError {}
GpuCountError(pub u32) keeps the rejected value. Its field is pub, so a caller can read which value was
rejected.
The Display implementation writes the message a user sees. write! works like format!, but writes into
the formatter. The message includes the value and the allowed range: gpu count 0 is outside 1..=64.
impl std::error::Error for GpuCountError {} is empty. The Error trait’s methods all have defaults, and it
requires Debug and Display, which the type already has. Implementing it lets this error work with other
error-handling code, such as Box<dyn std::error::Error>, which you will see in section 7.5.
7.4 Converting with From and TryFrom
The standard library has two traits for converting a value of one type into another:
From<A> for Bis for conversions that always succeed. Implementing it gives youB::from(a), and alsoa.into()wherever aBis expected.TryFrom<A> for Bis for conversions that can fail. Itstry_fromreturns aResult. Implementing it gives youB::try_from(a)anda.try_into().
Listing 7.4 The two conversions (lines 56 to 68).
impl TryFrom<u32> for GpuCount {
type Error = GpuCountError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<GpuCount> for u32 {
fn from(count: GpuCount) -> u32 {
count.0
}
}
Going from u32 to GpuCount can fail, so it is TryFrom. Its body calls new, so the check still lives
in one place. type Error = GpuCountError; names the error type this conversion returns.
Going from GpuCount back to u32 cannot fail, so it is From. The body returns the field, count.0.
TIP: Rust also has the as keyword, as in x as u32. as never reports failure: converting 300 to a
u8 with as quietly gives 44. Prefer From when a conversion cannot fail and TryFrom when it can, and
keep as for cases where you want truncation.
7.5 Commands: an enum that carries data
7.5.1 Why an enum
The tool accepts four commands. list takes no arguments. get and delete each take an ID. create
takes a name and a GPU count. A first design might put all the possible fields in one struct:
struct Command {
verb: String,
id: Option<String>, // only for get and delete
name: Option<String>, // only for create
gpus: Option<u32>, // only for create
}
This struct can hold a get without an ID, or a list with a GPU count. Every function that receives it
has to check which fields make sense for the verb.
An enum whose variants each carry their own data cannot hold those combinations:
Listing 7.5 The command enum (lines 70 to 77).
/// A closed set of commands, each carrying exactly the data it needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
List,
Get { id: String },
Create { name: String, gpus: GpuCount },
Delete { id: String },
}
A Create always has a name and a valid GpuCount, because Rust will not build one without them. A List
has no fields, because it needs none.
In memory, every Command takes the same space, enough for its largest variant, as figure 7.2 shows.
Command is 32 bytes. Rust also records which variant a value is. Here it stores that in bit patterns the String never uses, so no extra byte is needed.7.5.2 The parser’s errors and helper
Before the parser, here is what it returns when a line is wrong. Each way a line can be malformed gets its own error variant, so the caller learns exactly what failed.
Listing 7.6 CommandError and argument (lines 119 to 148).
/// A typed parse failure, so callers can distinguish the cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
Empty,
UnknownVerb(String),
MissingArgument(&'static str),
InvalidGpuCount(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(formatter, "empty command"),
Self::UnknownVerb(verb) => write!(formatter, "unknown verb {verb:?}"),
Self::MissingArgument(name) => write!(formatter, "missing argument: {name}"),
Self::InvalidGpuCount(raw) => write!(formatter, "invalid gpu count: {raw:?}"),
}
}
}
impl std::error::Error for CommandError {}
fn argument<'a>(
parts: &mut impl Iterator<Item = &'a str>,
name: &'static str,
) -> Result<&'a str, CommandError> {
parts
.next()
.ok_or(CommandError::MissingArgument(name))
}
CommandError lists every way parsing can fail, and its Display writes a message for each. Because the
errors are enum variants, a caller can match on them and react differently to each. A CLI could print usage
help for MissingArgument and the allowed range for InvalidGpuCount. With a plain String error, the
caller would have to read the message text to tell the cases apart.
argument takes the next word from the iterator, or fails with the argument’s name. Look at its first
parameter, &mut impl Iterator<Item = &'a str>. It borrows the caller’s iterator mutably, so that each call
consumes one more word. The lifetime 'a, which you met in chapter 4, says the returned &str points into
the original line. So no text is copied until parse decides to keep it with to_string().
7.5.3 Parsing a line of text
Parsing turns one line of text into a Command, or into an error that says what was wrong with the line.
Listing 7.7 Command::parse and verb (lines 79 to 117).
impl Command {
/// Parse one command line.
pub fn parse(line: &str) -> Result<Self, CommandError> {
let mut parts = line.split_whitespace();
let verb = parts
.next()
.ok_or(CommandError::Empty)?;
match verb {
"list" => Ok(Self::List),
"get" => Ok(Self::Get {
id: argument(&mut parts, "id")?.to_string(),
}),
"delete" => Ok(Self::Delete {
id: argument(&mut parts, "id")?.to_string(),
}),
"create" => {
let name = argument(&mut parts, "name")?.to_string();
let raw = argument(&mut parts, "gpu count")?;
let value: u32 = raw
.parse()
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
let gpus = GpuCount::new(value)
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
Ok(Self::Create { name, gpus })
}
other => Err(CommandError::UnknownVerb(other.to_string())),
}
}
pub fn verb(&self) -> &'static str {
match self {
Self::List => "list",
Self::Get { .. } => "get",
Self::Create { .. } => "create",
Self::Delete { .. } => "delete",
}
}
}
parse returns Result<Self, CommandError>: a command, or the reason the line could not be parsed. Trace
Command::parse("create triton 4") step by step:
| Step | Code | Result |
|---|---|---|
| 1 | line.split_whitespace() | an iterator over "create", "triton", "4" |
| 2 | parts.next().ok_or(CommandError::Empty)? | verb is "create" |
| 3 | match verb | runs the "create" arm |
| 4 | argument(&mut parts, "name")? | "triton", turned into a String |
| 5 | argument(&mut parts, "gpu count")? | raw is "4" |
| 6 | raw.parse() | the number 4 |
| 7 | GpuCount::new(value) | GpuCount(4) |
| 8 | Ok(Self::Create { name, gpus }) | the finished command |
Now try some bad inputs. An empty line fails at step 2, because there is no first word. ok_or turns the
None into Err(CommandError::Empty), and ? returns it. The line create triton many fails at step 6,
because "many" is not a number. The line create triton 0 passes step 6 and fails at step 7.
Animation 7.1 runs the good line and the three bad ones through the stages of the table.
? either passes a value to the next stage or returns a typed error. A count of 0 gets past parsing and stops at the type's gate.Steps 6 and 7 can fail for different reasons, but both failures are reported the same way.
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string())) replaces whatever error came back with
InvalidGpuCount, carrying the text the user typed. The user then sees their own input in the message.
verb goes the other way, from a command to its name. The patterns Self::Get { .. } match a variant and
ignore its fields.
7.5.4 Adding up without an index
The file has one more small function:
Listing 7.8 summarize (lines 150 to 157).
/// Count and sum in one pass, without managing an index.
pub fn summarize(values: &[i32]) -> (usize, i64) {
values
.iter()
.fold((0usize, 0i64), |(count, sum), value| {
(count + 1, sum + i64::from(*value))
})
}
fold walks the slice once and carries a value along. Here the value is a pair, (count, sum), starting at
(0, 0). For each element, the closure returns the new pair. i64::from(*value) widens each i32 to i64.
That conversion is a From, so it can never lose information.
7.5.5 The complete file
Listing 7.9 The complete file, with its tests. src/problems/adt_idioms.rs
//! Small algebraic-data-type idioms.
//!
//! These patterns are short and cannot represent invalid states.
//!
//! - A newtype gives a primitive a name and a single validating constructor, so
//! an out-of-range value cannot exist.
//! - An enum with data models a closed set of alternatives, and `match` forces
//! every alternative to be handled.
//! - Typed errors carry the reason instead of a string, so the caller can react.
//! - `TryFrom` puts the validation on the type boundary, where it belongs.
//! - An iterator chain states the intent in one pass and removes index
//! bookkeeping.
use std::fmt;
/// A GPU count that is validated at construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GpuCount(u32);
impl GpuCount {
pub const MIN: u32 = 1;
pub const MAX: u32 = 64;
/// Validate a raw count.
pub fn new(value: u32) -> Result<Self, GpuCountError> {
if (Self::MIN..=Self::MAX).contains(&value) {
Ok(Self(value))
} else {
Err(GpuCountError(value))
}
}
pub fn get(self) -> u32 {
self.0
}
}
/// Why a raw GPU count was rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GpuCountError(pub u32);
impl fmt::Display for GpuCountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"gpu count {} is outside {}..={}",
self.0,
GpuCount::MIN,
GpuCount::MAX
)
}
}
impl std::error::Error for GpuCountError {}
impl TryFrom<u32> for GpuCount {
type Error = GpuCountError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<GpuCount> for u32 {
fn from(count: GpuCount) -> u32 {
count.0
}
}
/// A closed set of commands, each carrying exactly the data it needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
List,
Get { id: String },
Create { name: String, gpus: GpuCount },
Delete { id: String },
}
impl Command {
/// Parse one command line.
pub fn parse(line: &str) -> Result<Self, CommandError> {
let mut parts = line.split_whitespace();
let verb = parts
.next()
.ok_or(CommandError::Empty)?;
match verb {
"list" => Ok(Self::List),
"get" => Ok(Self::Get {
id: argument(&mut parts, "id")?.to_string(),
}),
"delete" => Ok(Self::Delete {
id: argument(&mut parts, "id")?.to_string(),
}),
"create" => {
let name = argument(&mut parts, "name")?.to_string();
let raw = argument(&mut parts, "gpu count")?;
let value: u32 = raw
.parse()
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
let gpus = GpuCount::new(value)
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
Ok(Self::Create { name, gpus })
}
other => Err(CommandError::UnknownVerb(other.to_string())),
}
}
pub fn verb(&self) -> &'static str {
match self {
Self::List => "list",
Self::Get { .. } => "get",
Self::Create { .. } => "create",
Self::Delete { .. } => "delete",
}
}
}
/// A typed parse failure, so callers can distinguish the cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
Empty,
UnknownVerb(String),
MissingArgument(&'static str),
InvalidGpuCount(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(formatter, "empty command"),
Self::UnknownVerb(verb) => write!(formatter, "unknown verb {verb:?}"),
Self::MissingArgument(name) => write!(formatter, "missing argument: {name}"),
Self::InvalidGpuCount(raw) => write!(formatter, "invalid gpu count: {raw:?}"),
}
}
}
impl std::error::Error for CommandError {}
fn argument<'a>(
parts: &mut impl Iterator<Item = &'a str>,
name: &'static str,
) -> Result<&'a str, CommandError> {
parts
.next()
.ok_or(CommandError::MissingArgument(name))
}
/// Count and sum in one pass, without managing an index.
pub fn summarize(values: &[i32]) -> (usize, i64) {
values
.iter()
.fold((0usize, 0i64), |(count, sum), value| {
(count + 1, sum + i64::from(*value))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_each_command_variant() {
assert_eq!(Command::parse("list"), Ok(Command::List));
assert_eq!(
Command::parse("get wl-7"),
Ok(Command::Get { id: "wl-7".into() })
);
assert_eq!(
Command::parse("create triton 4"),
Ok(Command::Create {
name: "triton".into(),
gpus: GpuCount::new(4).expect("valid")
})
);
assert_eq!(
Command::parse("delete wl-7"),
Ok(Command::Delete { id: "wl-7".into() })
);
}
#[test]
fn rejects_bad_input_with_typed_errors() {
assert_eq!(Command::parse(""), Err(CommandError::Empty));
assert_eq!(
Command::parse("frobnicate"),
Err(CommandError::UnknownVerb("frobnicate".into()))
);
assert_eq!(
Command::parse("get"),
Err(CommandError::MissingArgument("id"))
);
assert_eq!(
Command::parse("create triton 0"),
Err(CommandError::InvalidGpuCount("0".into()))
);
assert_eq!(
Command::parse("create triton many"),
Err(CommandError::InvalidGpuCount("many".into()))
);
}
#[test]
fn newtype_rejects_out_of_range_values() {
assert!(GpuCount::new(1).is_ok());
assert!(GpuCount::new(64).is_ok());
assert!(GpuCount::new(0).is_err());
assert!(GpuCount::new(65).is_err());
assert_eq!(u32::from(GpuCount::try_from(8).expect("valid")), 8);
assert!(GpuCount::try_from(0).is_err());
}
#[test]
fn summarize_reports_count_and_sum() {
assert_eq!(summarize(&[]), (0, 0));
assert_eq!(summarize(&[1, 2, 3]), (3, 6));
assert_eq!(summarize(&[-5, 5]), (2, 0));
}
#[test]
fn errors_render_a_reason() {
assert_eq!(
Command::parse("create triton 0")
.unwrap_err()
.to_string(),
"invalid gpu count: \"0\""
);
assert!(
GpuCount::new(99)
.unwrap_err()
.to_string()
.contains("outside")
);
}
}
Read the tests as a description of the behavior. parses_each_command_variant shows one valid line per
variant. rejects_bad_input_with_typed_errors shows each failure, and compares the errors with assert_eq!.
That comparison works because CommandError derives PartialEq. newtype_rejects_out_of_range_values
tests both edges of the range, 1 and 64, and one step past each.
The tests run in parallel, so their order in the output can change from run to run:
$ cargo test --lib adt_idioms
running 5 tests
test problems::adt_idioms::tests::newtype_rejects_out_of_range_values ... ok
test problems::adt_idioms::tests::summarize_reports_count_and_sum ... ok
test problems::adt_idioms::tests::rejects_bad_input_with_typed_errors ... ok
test problems::adt_idioms::tests::parses_each_command_variant ... ok
test problems::adt_idioms::tests::errors_render_a_reason ... ok
test result: ok. 5 passed; 0 failed
The command enum describes what a user asks for. The next section uses an enum to describe something that changes over time.
7.6 The states of a workload
A workload is accepted (Pending), gets machines assigned (Provisioning), runs (Running), is shut down
(Terminating), and ends (Terminated). Any of the active states can fail, and a failed workload can be
tried again. A description like this, a set of states and the allowed moves between them, is called a
state machine. Figure 7.3 draws it.
Listing 7.10 The states (lines 1 to 18). src/problems/state_machine.rs
//! Algebraic data types / enum state machines.
//!
//! Cloud SDKs model workloads as finite state machines. Rust `enum` is
//! perfect for this: invalid states are unrepresentable and transitions are
//! explicit `match` arms, not nested `if` chains.
use std::time::Duration;
/// Lifecycle of a GPU cloud workload exposed through an SDK/API.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadState {
Pending,
Provisioning,
Running,
Terminating,
Terminated,
Failed,
}
Each state is a variant with no data. Such an enum is stored as a single byte, and it derives Copy, so you
pass states around by value.
Listing 7.11 The allowed moves (lines 20 to 56).
impl WorkloadState {
/// Returns an error string for impossible transitions, or `Ok(())`.
pub fn can_transition_to(self, next: Self) -> Result<(), String> {
match (self, next) {
// Forward lifecycle.
(Self::Pending, Self::Provisioning)
| (Self::Pending, Self::Failed)
| (Self::Provisioning, Self::Running)
| (Self::Provisioning, Self::Failed)
| (Self::Running, Self::Terminating)
| (Self::Running, Self::Failed)
| (Self::Terminating, Self::Terminated)
| (Self::Terminating, Self::Failed) => Ok(()),
// Retry loops allowed from failed states.
(Self::Failed, Self::Pending) | (Self::Failed, Self::Provisioning) => Ok(()),
// Everything else is invalid.
_ => Err(format!("cannot transition from {self:?} to {next:?}")),
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Terminated | Self::Failed)
}
pub fn summary(self) -> &'static str {
match self {
Self::Pending => "workload accepted, waiting for scheduler",
Self::Provisioning => "GPU resources being allocated",
Self::Running => "workload is running",
Self::Terminating => "cleanup in progress",
Self::Terminated => "workload stopped and billing ended",
Self::Failed => "workload failed; inspect logs",
}
}
}
can_transition_to checks one move, from self to next. It matches on the pair (self, next), so each
pattern is one arrow in figure 7.3. Patterns joined with | share one arm: this is an or-pattern. The
first arm lists the forward moves and the failure moves. The second lists the retries. The last arm, _,
matches every other pair and returns an error.
That last arm decides what happens to moves nobody listed. With it, any move not written down is rejected. If someone adds a new state later, every move into or out of it is rejected until they list it. That is the safe default for a state machine.
is_terminal uses the matches! macro, which returns true when a value matches a pattern. Failed counts
as terminal even though it can retry, because it holds no machines. summary returns a fixed message for
each state. Its return type &'static str means the text lives for the whole program, because it is written
into the program itself.
7.7 Errors from a server
The same file models the errors a server can send back. Here, different errors carry different information.
Listing 7.12 ApiError and its methods (lines 58 to 91).
/// API error modeled as an enum with associated data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
InvalidRequest(String),
Unauthorized,
RateLimited { retry_after: Duration },
NotFound { resource_id: String },
Server { status: u16, message: String },
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::Server {
status: 500..=599,
..
}
)
}
pub fn user_message(&self) -> String {
match self {
Self::InvalidRequest(msg) => format!("invalid request: {msg}"),
Self::Unauthorized => "authentication required or token expired".to_string(),
Self::RateLimited { retry_after } => {
format!("rate limited, retry in {}ms", retry_after.as_millis())
}
Self::NotFound { resource_id } => format!("resource not found: {resource_id}"),
Self::Server { status, message } => format!("server error {status}: {message}"),
}
}
}
A rate-limit error says how long to wait before trying again. A not-found error says which resource. A server error carries the HTTP status code and a message.
is_retryable answers a question every client asks after a failure: could trying again help? A rate limit
will pass, and a server error in the 500s may be temporary, so both are retryable. A missing resource will
still be missing. The whole rule is one pattern:
Self::RateLimited { .. } | Self::Server { status: 500..=599, .. }
status: 500..=599 inside the struct pattern matches any status from 500 to 599. .. ignores the other
fields. Chapter 19 builds a retry loop around this method.
user_message turns each error into text for a person. retry_after.as_millis() converts the waiting time
to milliseconds for the message.
Listing 7.13 A small parser (lines 93 to 101).
/// Typical SDK helper: never expose nested `if let` soup to callers.
pub fn parse_gpu_count(raw: &str) -> Result<u32, ApiError> {
match raw.trim() {
"" => Err(ApiError::InvalidRequest("gpuCount is empty".into())),
parsed => parsed.parse::<u32>().map_err(|_| {
ApiError::InvalidRequest(format!("gpuCount must be a number, got {raw:?}"))
}),
}
}
parse_gpu_count is a shorter version of the gate from section 7.2. It trims the text, handles the empty
case with its own message, and converts the standard library’s parse error into ApiError::InvalidRequest.
{raw:?} prints the text with quotes, so an empty or odd input is visible in the message.
Listing 7.14 The complete file, with its tests. src/problems/state_machine.rs
//! Algebraic data types / enum state machines.
//!
//! Cloud SDKs model workloads as finite state machines. Rust `enum` is
//! perfect for this: invalid states are unrepresentable and transitions are
//! explicit `match` arms, not nested `if` chains.
use std::time::Duration;
/// Lifecycle of a GPU cloud workload exposed through an SDK/API.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadState {
Pending,
Provisioning,
Running,
Terminating,
Terminated,
Failed,
}
impl WorkloadState {
/// Returns an error string for impossible transitions, or `Ok(())`.
pub fn can_transition_to(self, next: Self) -> Result<(), String> {
match (self, next) {
// Forward lifecycle.
(Self::Pending, Self::Provisioning)
| (Self::Pending, Self::Failed)
| (Self::Provisioning, Self::Running)
| (Self::Provisioning, Self::Failed)
| (Self::Running, Self::Terminating)
| (Self::Running, Self::Failed)
| (Self::Terminating, Self::Terminated)
| (Self::Terminating, Self::Failed) => Ok(()),
// Retry loops allowed from failed states.
(Self::Failed, Self::Pending) | (Self::Failed, Self::Provisioning) => Ok(()),
// Everything else is invalid.
_ => Err(format!("cannot transition from {self:?} to {next:?}")),
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Terminated | Self::Failed)
}
pub fn summary(self) -> &'static str {
match self {
Self::Pending => "workload accepted, waiting for scheduler",
Self::Provisioning => "GPU resources being allocated",
Self::Running => "workload is running",
Self::Terminating => "cleanup in progress",
Self::Terminated => "workload stopped and billing ended",
Self::Failed => "workload failed; inspect logs",
}
}
}
/// API error modeled as an enum with associated data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
InvalidRequest(String),
Unauthorized,
RateLimited { retry_after: Duration },
NotFound { resource_id: String },
Server { status: u16, message: String },
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::Server {
status: 500..=599,
..
}
)
}
pub fn user_message(&self) -> String {
match self {
Self::InvalidRequest(msg) => format!("invalid request: {msg}"),
Self::Unauthorized => "authentication required or token expired".to_string(),
Self::RateLimited { retry_after } => {
format!("rate limited, retry in {}ms", retry_after.as_millis())
}
Self::NotFound { resource_id } => format!("resource not found: {resource_id}"),
Self::Server { status, message } => format!("server error {status}: {message}"),
}
}
}
/// Typical SDK helper: never expose nested `if let` soup to callers.
pub fn parse_gpu_count(raw: &str) -> Result<u32, ApiError> {
match raw.trim() {
"" => Err(ApiError::InvalidRequest("gpuCount is empty".into())),
parsed => parsed.parse::<u32>().map_err(|_| {
ApiError::InvalidRequest(format!("gpuCount must be a number, got {raw:?}"))
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_machine_rejects_invalid_transitions() {
assert!(
WorkloadState::Pending
.can_transition_to(WorkloadState::Provisioning)
.is_ok()
);
assert!(
WorkloadState::Running
.can_transition_to(WorkloadState::Running)
.is_err()
);
assert!(
WorkloadState::Terminated
.can_transition_to(WorkloadState::Running)
.is_err()
);
assert!(!WorkloadState::Terminating.is_terminal());
assert!(WorkloadState::Terminated.is_terminal());
}
#[test]
fn api_error_retry_policy() {
let rate_limited = ApiError::RateLimited {
retry_after: Duration::from_millis(100),
};
let not_found = ApiError::NotFound {
resource_id: "gpu-123".into(),
};
let server = ApiError::Server {
status: 503,
message: "maintenance".into(),
};
assert!(rate_limited.is_retryable());
assert!(!not_found.is_retryable());
assert!(server.is_retryable());
}
#[test]
fn parsing_returns_typed_errors() {
assert_eq!(parse_gpu_count("4"), Ok(4));
assert!(parse_gpu_count("").is_err());
assert!(parse_gpu_count("abc").is_err());
}
}
The tests check an allowed move and two forbidden ones, the retry rule for three errors, and the parser.
Summary
- A trait is a set of methods a type promises. You implement one with
impl Trait for Type, and#[derive]generates the standard ones. - A newtype with a private field and a checking constructor can only hold valid values, so the code that receives it needs no checks.
- An error type with
Displayandstd::error::Errortells the caller what went wrong, and works with?. Fromconverts when it cannot fail.TryFromreturns aResultwhen it can.- An enum whose variants carry their own data replaces a struct full of
Optionfields, and is as large as its largest variant. - A parser turns text into a typed value, and
map_errturns low-level failures into your own errors. - Matching on a pair of states describes a state machine, and a final
_arm rejects every move not listed. - Error enums can carry data and methods such as
is_retryable.
Every value in this chapter had exactly one owner. Chapter 8 covers the pointer types for data that needs several owners, or that must be shared between threads.
Exercises
- Write a
Portnewtype for network ports that rejects 0, with aTryFrom<u16>implementation and its own error type. - Implement the
FromStrtrait forCommand, so that"list".parse::<Command>()works. Its method can callCommand::parse. - Add a
Suspendedstate toWorkloadStatethat can be entered fromRunningand left back toRunning, and add tests for both moves. - Give
ApiErroraDisplayimplementation that usesuser_message, then implementstd::error::Errorfor it.