Cuando marcas una función como async, el comportamiento de los errores cambia radicalmente respecto al código síncrono. En lugar de interrumpir inmediatamente la ejecución del hilo, un throw dentro de una función async simplemente marca el Future resultante como “fallido” (error). Esto ocurre porque el entorno de Dart debe garantizar que la función siempre devuelva un objeto Future, ya sea que contenga el valor esperado o la excepción que ocurrió durante su ejecución.
Esta arquitectura permite que el error viaje junto con el objeto Future hasta que alguien decida “esperarlo”. Debes usar estas técnicas siempre que interactúes con operaciones de entrada/salida (I/O), llamadas a APIs o cualquier proceso que no sea instantáneo. Si manejas mal este flujo, especialmente al ignorar Futures sin un manejador, podrías terminar con excepciones no capturadas que ensucian los logs de producción o, en el peor de los casos, dejan tu aplicación en un estado inconsistente al no procesar fallos críticos en segundo plano.
Para capturar estos errores, tienes dos caminos principales: el estilo imperativo con try/catch alrededor de un await, que es el más legible y recomendado, o el estilo funcional usando .catchError() o .onError<T>() directamente sobre el objeto Future.
import 'dart:async';
// Simulamos un servicio que puede fallar tras una demora
Future<String> fetchUserData(bool debeFallar) async {
await Future.delayed(const Duration(milliseconds: 500));
if (debeFallar) {
throw Exception("Error: No se pudo conectar con el servidor");
}
return '{"id": 42, "nombre": "Alex"}';
}
// Un Stream que emite valores y luego lanza un error
Stream<int> sensorStream() async* {
yield 10;
yield 20;
await Future.delayed(const Duration(milliseconds: 300));
throw Exception("Error: Sensor desconectado");
}
void main() async {
// runZonedGuarded actúa como una red de seguridad global para
// capturar excepciones que "escapan" por falta de await o catchError.
runZonedGuarded(() async {
print('--- Escenario 1: try/catch con await ---');
try {
final user = await fetchUserData(true);
print('Usuario: $user');
} catch (e) {
print('Capturado localmente: $e');
} finally {
// El bloque finally se ejecuta siempre, incluso si hay error o error controlado
print('Limpieza de recursos completada.');
}
print('\n--- Escenario 2: Stream con await for ---');
try {
await for (final valor in sensorStream()) {
print('Lectura de sensor: $valor');
}
} catch (e) {
print('Error en el flujo del stream: $e');
}
print('\n--- Escenario 3: Fire-and-forget (Peligro) ---');
// Lanzamos una tarea sin 'await'. El error no se capturará en este scope.
fetchUserData(true);
print('Tarea lanzada en segundo plano (sin esperar)...');
// Esperamos un poco para permitir que el error del Escenario 3 se propague
await Future.delayed(const Duration(milliseconds: 800));
print('\nPrograma finalizado.');
}, (error, stackTrace) {
// Esto solo se ejecuta si algo escapó de los try/catch locales
print('\n[ZONA] Error no manejado capturado globalmente: $error');
});
}
Análisis del flujo
En el Escenario 1, el try/catch envuelve la instrucción await fetchUserData(true). Como la función fetchUserData lanza una excepción, el Future se completa con un error y el control pasa directamente al bloque catch. El uso de finally es crucial aquí: garantiza que, independientemente de si la red respondió bien o mal, la “limpieza de recursos” se ejecute, una práctica estándar en servicios de backend para cerrar conexiones o liberar memoria.
En el Escenario 2, estamos consumiendo un Stream mediante un bucle await for. Cuando el sensorStream lanza su excepción, el bucle se interrumpe y el error es capturado por el catch que rodea al loop. Es importante entender que un error en un Stream no es igual a una excepción síncrona; el error se emite como un evento especial dentro del flujo asíncrono.
El Escenario 3 es donde suelen aparecer los bugs más difíciles de rastrear. Al ejecutar fetchUserData(true) sin el prefijo await, la función se dispara y el hilo principal sigue su camino inmediatamente. Como no estamos esperando el resultado, no hay un try/catch que pueda interceptar el error cuando este ocurra 500ms después. En este caso, el error “escapa” del flujo de ejecución normal, y la única forma de capturarlo es mediante runZonedGuarded, que actúa como un interceptor global para errores no manejados en la zona de ejecución actual.
El error frecuente
El error más común en aplicaciones de producción es el “fire-and-forget” accidental. Ocurre cuando lanzas una tarea asíncrona sin esperar su finalización ni asignar su resultado a una variable para manejar su error.
// ❌ INCORRECTO: Error silencioso o unhandled exception
void logEvent(String msg) async {
await Future.delayed(const Duration(seconds: 1));
throw Exception("Fallo de log");
}
void main() {
// Si no pones 'await', el error se perderá en el limbo
// hasta que la zona de ejecución lo capture como un error no manejado.
logEvent("Evento de usuario");
}
Si no usas await, o si no encadenas un .catchError() para manejar el fallo, el error se convierte en una unhandled exception. En entornos de servidor, esto puede causar que el proceso se comporte de forma errática o que los logs de error se llenen de ruidos que no están asociados a ningún flujo de control lógico.
N° 63