Contexto asíncrono avanzado con Zonas en Dart
Las Zones son un mecanismo para asociar un contexto de ejecución específico a un flujo de control, permitiendo que este contexto persista a través de las fronteras asíncronas. A diferencia de un hilo de ejecución tradicional en otros lenguajes, en Dart las zonas no son hilos, sino una forma de “etiquetar” la ejecución. Esto significa que cuando una operación asíncrona (como un Future o un Timer) se programa en el event loop, la zona activa en el momento del registro se “empaqueta” con la tarea, y cuando la tarea se ejecuta, el motor de Dart restaura esa zona específica.
Esta arquitectura es necesaria porque en un entorno de un solo hilo con programación asíncrona, el stack trace tradicional se pierde cuando una función retorna y el callback se ejecuta más tarde. Las Zones permiten resolver esto proporcionando un mecanismo para interceptar errores no capturados, propagar valores de contexto (como un requestId en un servidor) y personalizar comportamientos de la VM (como el manejo de timers o prints) sin necesidad de pasar parámetros manualmente por cada función asíncrona.
En la práctica, usarías Zones para implementar telemetría, logging estructural que incluya metadatos de la petición, o para capturar errores globales en un servidor Dart que de otro modo harían colapsar el proceso. Si intentas gestionar el contexto de una petición HTTP pasando un objeto context a cada función de tu aplicación, terminarás con un código verboso y difícil de mantener; las zonas resuelven esto de forma transparente. Sin embargo, si intentas usar zonas para pasar datos de negocio críticos de forma desordenada, corres el riesgo de perder la trazabilidad si el flujo de ejecución salta a una zona diferente o si el programador olvida la dependencia implícita del contexto.
import 'dart:async';
/// Personalizamos el comportamiento de la zona para interceptar errores.
class TelemetryZoneSpecification extends ZoneSpecification {
@override
void handleUncaughtError(
Zone parent,
ZoneError error,
StackTrace stackTrace,
Object? zoneValue,
) {
// En un sistema real, aquí enviaríamos el error a Sentry o CloudWatch.
print('--- [TELEMETRÍA] Interceptado error crítico ---');
print('Error: $error');
print('Contexto de la zona: $zoneValue');
print('Stacktrace: $stackTrace');
print('-----------------------------------------------');
// Es vital llamar al super para permitir que el error siga su curso
// si la intención es que el proceso termine o sea manejado por el root.
super.handleUncaughtError(parent, error, stackTrace, zoneValue);
}
}
/// Simula una operación asíncrona que obtiene el ID de la petición del contexto.
Future<void> performDatabaseQuery() async {
// Recuperamos el valor almacenado en la zona actual.
final requestId = Zone.current[#request_id];
print('Ejecutando query para la petición: $requestId');
await Future.delayed(const Duration(milliseconds: 500));
if (requestId == 'req-error-99') {
// Este error ocurriría en un callback de un timer/future,
// fuera del alcance de un try-catch estándar.
throw Exception('Error de base de datos catastrófico');
}
print('Query completada para $requestId');
}
void main() async {
final spec = TelemetryZoneSpecification();
// Creamos una zona que contiene un valor de contexto y una especificación personalizada.
// Usamos runZonedGuarded para capturar errores que escapen de los try-catch locales.
await runZonedGuarded<void>(
() async {
// Caso 1: Ejecución normal con contexto
await Zone.value(
{'request_id': 'req-ok-123'},
spec,
).run(() async {
print('Iniciando flujo normal...');
await performDatabaseQuery();
});
print('\n--- Iniciando flujo con error asíncrono ---\n');
// Caso 2: Ejecución que disparará el handler de la ZoneSpecification
await Zone.value(
{'request_id': 'req-error-99'},
spec,
).run(() async {
// Lanzamos un error dentro de una tarea asíncrona que no está envuelta en try-catch.
await performDatabaseQuery();
});
},
(error, stackTrace) {
// Este es el handler global de la zona protegida.
print('\n[MAIN] Error atrapado por runZonedGuarded: $error');
},
zoneValues: {'app_version': '1.0.0'},
);
print('\nProceso principal finalizado.');
}
Desglose del ejemplo
En el código anterior, hemos implementado un sistema de telemetría robusto. La clase TelemetryZoneSpecification sobrescribe handleUncaughtError, un método que la VM de Dart llama cuando un error llega al final de la cadena de propagación de la zona sin ser capturado. Al hacerlo, podemos extraer información del zoneValue, que en nuestro caso es un mapa con metadatos.
En el main, la función runZonedGuarded actúa como un contenedor de seguridad. Es fundamental entender que runZonedGuarded no solo captura errores, sino que crea un nuevo ámbito de ejecución. Dentro de este, usamos Zone.value para inyectar un mapa con el request_id.
Cuando performDatabaseQuery se ejecuta, no recibe el requestId como parámetro. En su lugar, lo recupera mediante Zone.current[#request_id]. Esto es posible porque, aunque performDatabaseQuery es una función asíncrona que se suspende con await, la VM de Dart garantiza que al retomar la ejecución, el contexto de la zona sigue siendo el mismo.
En el segundo escenario (el flujo con error), lanzamos una excepción dentro de performDatabaseQuery. Dado que esta excepción ocurre después de un await y no está envuelta en un bloque try-catch dentro de la función, el error “viaja” hacia arriba por la jerarquía de zonas. Primero es interceptado por nuestra TelemetryZoneSpecification (donde registramos el error con su contexto) y, finalmente, es capturado por el callback de runZonedGuarded en el main.
El error frecuente
Un error clásico al trabajar con código asíncrono es asumir que un try-catch envolviendo una llamada asíncrona capturará todos los errores.
void errorFalso() async {
try {
// Esto NO captura errores de eventos programados en el event loop
Timer(const Duration(milliseconds: 10), () {
throw Exception('Error fantasma');
});
} catch (e) {
print('Nunca verás esto: $e');
}
}
En este caso, el Timer programa una tarea en el event loop y la función errorFalso termina inmediatamente. Cuando el Timer se dispara 10ms después, el try-catch ya no está en el stack trace de la ejecución actual. El error se convierte en un “uncaught error”. La única forma de capturar este error de manera global y segura es mediante runZonedGuarded o interceptando el error en una ZoneSpecification.
N° 67