Panics

#312 Aug 2026

312. Duration::saturating_sub — Your Timeout Budget Ran Out, and the Subtraction Panicked

Duration - Duration panics on underflow — and budget - elapsed underflows the moment a slow operation blows the budget.

A classic timeout-budget loop: give the whole request 200 ms, subtract what each step used, hand the rest to the next step. The subtraction is the trap:

1
2
3
4
5
6
7
use std::time::Duration;

let budget = Duration::from_millis(200);
let elapsed = Duration::from_millis(250);

// panics: overflow when subtracting durations
let remaining = budget - elapsed;

Duration is unsigned — there is no negative duration — so the moment elapsed exceeds budget, Sub has nothing valid to return and panics. It works fine in tests where every step is fast, then crashes in production the first time a step stalls.

The fallible and saturating versions

checked_sub returns an Option, which makes “budget exhausted” an explicit case instead of a crash:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::time::Duration;

let budget = Duration::from_millis(200);
let slow = Duration::from_millis(250);
let fast = Duration::from_millis(50);

assert_eq!(budget.checked_sub(slow), None);
assert_eq!(
    budget.checked_sub(fast),
    Some(Duration::from_millis(150))
);

If “no time left” should just mean zero — say, the value goes straight into recv_timeout from bite 283saturating_sub clamps instead:

1
2
let remaining = budget.saturating_sub(slow);
assert_eq!(remaining, Duration::ZERO);

The same family exists for the other direction: checked_add / saturating_add for accumulating durations, and checked_mul / checked_div for scaling them.

This is the integer twin of this morning’s bite 311: there, float math produced a negative that panicked at the Duration conversion; here, Duration math itself hits the floor. Either way, the fix is the same — reach for the method that returns a value you can handle instead of the operator that panics.

#110 Apr 2026

110. slice::split_at_checked — Split Without the Panic

slice.split_at(i) panics the second i > len. The usual fix is a length check wrapped around the call so you don’t blow up on a bad index. split_at_checked does the same job in one call and hands you an Option.

The classic trap — a single bad index away from a panic:

1
2
let xs = [1, 2, 3, 4];
let (head, tail) = xs.split_at(10); // panics: byte index 10 is out of bounds

The defensive version everyone writes:

1
2
3
4
5
6
7
8
9
let xs = [1, 2, 3, 4];
let i = 10;

if i <= xs.len() {
    let (head, tail) = xs.split_at(i);
    // ...use head and tail
} else {
    // handle out-of-bounds
}

Two reads of i, one easy off-by-one (< vs <=), and a panic waiting if you ever drop the guard.

Rust 1.80 stabilised split_at_checked (and split_at_mut_checked), which folds the bounds check into the return type:

1
2
3
4
5
let xs = [1, 2, 3, 4];

assert_eq!(xs.split_at_checked(2), Some((&xs[..2], &xs[2..])));
assert_eq!(xs.split_at_checked(4), Some((&xs[..], &[][..]))); // boundary is fine
assert_eq!(xs.split_at_checked(5), None);                     // would have panicked

Now the bounds check is the API. You get an Option<(&[T], &[T])> and the compiler nudges you to handle the None case:

1
2
3
4
5
6
7
fn take_prefix(buf: &[u8], n: usize) -> Option<&[u8]> {
    let (head, _rest) = buf.split_at_checked(n)?;
    Some(head)
}

assert_eq!(take_prefix(b"hello", 3), Some(&b"hel"[..]));
assert_eq!(take_prefix(b"hi", 3), None);

? does the bailout, no manual length check, no panic path. This works on &str too, where the index has to land on a UTF-8 boundary — and it returns None if it doesn’t, instead of panicking.