Recipes

Error Handling

Understand how Qora surfaces errors, configures retry behaviour, and enables graceful degradation with previousData.

Qora distinguishes between transient errors (retry automatically) and terminal errors (surface to the UI). All fetch failures go through the same pipeline: retry with exponential backoff → transition to Failure → notify subscribers.


The Failure State

When all retries are exhausted, QoraState transitions to Failure:

sealed class QoraState<T> { ... }

final class Failure<T> extends QoraState<T> {
  final Object error;
  final StackTrace? stackTrace;
  final T? previousData; // non-null when data was cached before the failure
}

previousData carries the last successfully fetched value. This enables graceful degradation: show stale data alongside an error indicator rather than replacing the entire view.

user_screen.dart
QoraBuilder<User>(
  queryKey: ['users', userId],
  fetcher: () => api.getUser(userId),
  builder: (context, state, fetchStatus) => switch (state) {
    Initial() => const SizedBox.shrink(),
    Loading(:final previousData) => previousData != null
        ? Stack(children: [UserCard(previousData), const LoadingOverlay()])
        : const CircularProgressIndicator(),
    Success(:final data) => UserCard(data),
    Failure(:final error, :final previousData) => previousData != null
        ? Column(children: [
            UserCard(previousData),
            ErrorBanner('Failed to refresh: $error'),
          ])
        : ErrorScreen('$error'),
  },
)

Retry Configuration

Qora retries failed fetches automatically with exponential backoff. Control this per query via QoraOptions:

main.dart
QoraOptions(
  retryCount: 3,                              // Default: 3: set 0 to disable
  retryDelay: const Duration(seconds: 1),     // Base delay; doubles each attempt
)

Default backoff schedule:

AttemptDelay
11 s
22 s
34 s

Custom Delay

main.dart
QoraOptions(
  retryDelayCalculator: (attempt) => Duration(
    milliseconds: (500 * pow(2, attempt)).toInt(),
  ),
)

Disable Retries

main.dart
// Fail immediately: e.g. for mutations or real-time queries
QoraOptions(retryCount: 0)

Selectively Skipping Retries

To skip retries for specific error types (e.g. 401 Unauthorized should never be retried):

main.dart
QoraOptions(
  retryCount: 3,
  retryDelayCalculator: (attempt) {
    // This won't be called if retryCount is effectively 0,
    // but you can gate retry logic in the fetcher itself:
    return Duration(seconds: 1 << attempt);
  },
)

The recommended pattern is to throw a specific exception and catch it in the fetcher:

user_api.dart
Future<User> getUser(int id) async {
  final response = await http.get(Uri.parse('/users/$id'));
  if (response.statusCode == 401) throw const UnauthorizedException();
  if (response.statusCode == 404) throw const NotFoundException();
  if (response.statusCode != 200) throw HttpException(response.statusCode);
  return User.fromJson(jsonDecode(response.body));
}

Then in QoraClientConfig.errorMapper, normalize errors before they reach the retry engine:

main.dart
QoraClientConfig(
  errorMapper: (error, stack) {
    if (error is UnauthorizedException) return error; // Don't wrap: let it surface
    return QoraException('$error', originalError: error, stackTrace: stack);
  },
)

Global Error Handling

Use QoraClientConfig.errorMapper to normalise all errors into a single type before they surface in Failure.error:

main.dart
final client = QoraClient(
  config: QoraClientConfig(
    errorMapper: (error, stackTrace) {
      if (error is DioException) {
        return switch (error.type) {
          DioExceptionType.connectionTimeout => const NetworkTimeoutError(),
          DioExceptionType.receiveTimeout => const NetworkTimeoutError(),
          _ => ApiError(error.response?.statusCode),
        };
      }
      return error;
    },
  ),
);

Background Fetch Errors

When using watchQuery, background fetches run in unawaited futures. If the fetch fails, the error is stored in the Failure state, but the re-thrown exception would crash the current async Zone. Use QoraClientConfig.onBackgroundFetchError to safely intercept these errors for logging or crash reporting:

lib/core/qora.dart
final client = QoraClient(
  config: QoraClientConfig(
    onBackgroundFetchError: (error, stackTrace, key) {
      // Error already stored in Failure state; use this for reporting
      crashReporter.record(error, stackTrace);
    },
  ),
);

The callback receives the error, stack trace, and the normalised query key. The error has already been stored in the query's Failure state, this callback is for observability only.


Per-Tracker Error Observation

Implement QoraTracker.onQueryFetchFailed to observe all query errors globally: useful for crash reporting or analytics:

analytics_tracker.dart
class AnalyticsTracker extends NoOpTracker {
  @override
  void onQueryFetchFailed(String key, Object error, StackTrace stackTrace) {
    Sentry.captureException(error, stackTrace: stackTrace);
    analytics.track('query_error', {'key': key, 'error': '$error'});
  }
}

Mutation Errors

Mutation errors are surfaced on MutationFailure:

main.dart
QoraMutationBuilder<User, UpdatePayload, User?>(
  mutator: (payload) => api.updateUser(payload),
  options: MutationOptions(
    onMutate: (payload) async =>
        QoraScope.of(context).getQueryData<User>(['users', userId]),
    onError: (error, payload, snapshot) async {
      if (snapshot != null) {
        QoraScope.of(context).restoreQueryData(['users', userId], snapshot);
      }
    },
    onSettled: (_, _, _, _) async =>
        QoraScope.of(context).invalidate(['users', userId]),
  ),
  builder: (context, state, mutate) => switch (state) {
    MutationFailure(:final error) => ErrorBanner('$error'),
    _ => UpdateButton(onTap: () => mutate(payload)),
  },
)

Offline Errors

When networkMode is NetworkMode.online (default) and the device is offline:

  • fetchQuery throws QoraOfflineException if no cached data exists.
  • watchQuery emits FetchStatus.paused: the state stays at Loading(previousData: ...) if data was previously cached.
  • The fetch is replayed automatically when connectivity is restored.
main.dart
try {
  final data = await client.fetchQuery<User>(
    key: ['users', userId],
    fetcher: () => api.getUser(userId),
  );
} on QoraOfflineException {
  showSnackBar('No internet connection');
}

Copyright © 2026