Errors, exceptions and async error handling
Tell Error from Exception, catch with on and finally, and handle failure in Futures and Streams without losing the stack trace.
Throwing and catching
// Exception for expected, recoverable conditions; Error for programmer mistakes
class InsufficientStock implements Exception {
InsufficientStock(this.sku, this.requested, this.available);
final String sku;
final int requested;
final int available;
@override
String toString() => 'InsufficientStock($sku: want $requested, have $available)';
}
class ConfigError extends Error {
// Error subclasses must supply a stack trace when the API needs one
ConfigError(this.message, [this.cause]);
final String message;
final Object? cause;
@override
String toString() => 'ConfigError: $message';
}
class Service {
void reserve(String sku, int qty) {
final available = 2;
if (qty <= 0) {
throw ArgumentError.value(qty, 'qty', 'must be positive'); // a bug in the caller
}
if (qty > available) {
throw InsufficientStock(sku, qty, available); // an expected outcome
}
}
}
void main() {
final svc = Service();
try {
svc.reserve('ABC-1', 5);
} on InsufficientStock catch (e, stack) {
print('$e');
print(stack); // keep the trace: it is the debugging cost
} on ArgumentError catch (e) {
print('bad caller: ${e.message}');
} catch (e) {
print('unexpected: $e');
} finally {
print('always runs');
}
}Errorand its subclasses signal a bug: a bad argument, a broken invariant, a failed cast. They should crash, not be caught and swallowed.Exceptionis for a condition the caller may reasonably handle: a missing file, an unavailable resource, a validation failure.- Order
onclauses from most specific to least specific; the first match wins. - Always bind the second catch parameter when you log or rethrow. Losing the stack trace turns a five-minute bug into an afternoon.
rethrowpreserves the original trace;throw e;resets it to the current line.
Errors in Futures and Streams
Future<int> fetchStock(String sku) async {
if (sku.isEmpty) throw ArgumentError('sku');
await Future<void>.delayed(const Duration(milliseconds: 10));
return 3;
}
Future<void> main() async {
// try/catch works across await inside an async function
try {
final stock = await fetchStock('ABC-1');
print(stock);
} on ArgumentError catch (e, s) {
print('rejected: $e');
print(s);
}
// an unawaited Future that fails becomes an unhandled error and can kill the isolate
// fetchStock(''); // do not do this: the error has no handler
unawaited(fetchStock('').catchError((Object e) => print('handled: $e')));
// whenComplete runs on both outcomes; catchError only handles the failure
await fetchStock('ABC-1')
.then((v) => print('then $v'))
.catchError((Object e) => print('error $e'))
.whenComplete(() => print('done'));
// a timeout does NOT cancel the underlying work
try {
await fetchStock('X').timeout(const Duration(milliseconds: 1));
} on TimeoutException catch (e) {
print('timed out after ${e.duration}');
}
// a Stream can fail part way through; the error arrives in the same await-for
final stream = Stream<int>.fromIterable([1, 2, 3]).map((n) {
if (n == 2) throw StateError('bad value');
return n;
});
try {
await for (final value in stream) {
print('got $value');
}
} on StateError catch (e) {
print('stream failed: $e');
}
}
void unawaited(Future<void> f) {}
void unawaited2(Future<dynamic> f) {}| Situation | Mechanism | Note |
|---|---|---|
| Synchronous failure | throw inside a try | Caught by the enclosing try |
| Async failure | The returned Future completes with an error | Await it, or attach a handler |
| Unawaited failure | Reported to the current zone | Crashes the isolate unless a zone handles it |
| Stream failure | The stream emits an error event | onError callback or a catch inside await for |
| Cleanup | finally or whenComplete | Runs on both paths |
| Timeout | Future.timeout | Does not stop the work, only stops waiting |
An async function always returns a Future, even when it throws synchronously. The error is delivered through the returned future, which is why the caller must await it or attach a handler rather than wrapping the call in a try.
Zones, guards and structured boundaries
import 'dart:async';
void main() {
// a guarded zone is the top-level net for errors nobody handled
runZonedGuarded(
() async {
// a failing fire-and-forget future is caught here, not by the isolate
Future<void>.delayed(const Duration(milliseconds: 1))
.then((_) => throw StateError('nobody awaited me'));
await Future<void>.delayed(const Duration(milliseconds: 20));
print('work finished');
},
(error, stack) {
print('zone caught: $error');
print(stack);
// log and report here: this is the last chance before the process dies
},
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => parent.print(zone, '[log] $line'),
),
);
}
// a retry helper built on Futures, without a loop of awaits
Future<T> retry<T>(
Future<T> Function() action, {
int attempts = 3,
Duration delay = const Duration(milliseconds: 200),
}) async {
var lastError = StateError('no attempt made');
for (var i = 0; i < attempts; i++) {
try {
return await action();
} catch (e) {
lastError = e is StateError ? e : StateError(e.toString());
if (i < attempts - 1) await Future<void>.delayed(delay * (i + 1)); // backoff
}
}
throw lastError;
}⚠️
Never leave a
Future without a handler. An unhandled asynchronous error is reported to the zone, and without a guarded zone the isolate terminates. Either await it, or attach catchError, or explicitly ignore it with an assigned unawaited(...) so the intent is visible.FAQ
When should I throw an Error rather than an Exception?
When the caller violated a precondition that cannot be recovered from: a null where non-null is required, an invalid argument, a failed cast. If the caller could reasonably do something about it, define an
Exception.Does try/finally work with await?
Yes.
finally runs when the awaited future completes, including when it completes with an error. whenComplete on a Future is the same idea in a chain.Related
Classes, futures and streams Files, JSON and HTTP in daily Dart
Last refreshed 2026-09-18.