Error Handling
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.
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:
QoraOptions(
retryCount: 3, // Default: 3: set 0 to disable
retryDelay: const Duration(seconds: 1), // Base delay; doubles each attempt
)
Default backoff schedule:
| Attempt | Delay |
|---|---|
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
Custom Delay
QoraOptions(
retryDelayCalculator: (attempt) => Duration(
milliseconds: (500 * pow(2, attempt)).toInt(),
),
)
Disable Retries
// 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):
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:
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:
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:
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:
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:
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:
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:
fetchQuerythrowsQoraOfflineExceptionif no cached data exists.watchQueryemitsFetchStatus.paused: the state stays atLoading(previousData: ...)if data was previously cached.- The fetch is replayed automatically when connectivity is restored.
try {
final data = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
);
} on QoraOfflineException {
showSnackBar('No internet connection');
}
Related
- QoraOptions:
retryCount,retryDelay,retryDelayCalculator,networkMode - Network-Aware Queries: offline behaviour,
NetworkMode,FetchStatus - Mutations Guide:
onError, rollback