The ultimate solution for robust, clean, and UX-friendly API operations in Flutter apps
Handler radically simplifies how you work with HTTP requests and async operations by providing a unified API that elegantly solves common challenges:
- ✅ Error management - customizable with type safety
- ✅ Retry - seamless recovery from failures
- ✅ Rate-limiting - optimized frequency control
- ✅ Operation control - cancel or trigger operations on demand
Handling asynchronous operations, especially API calls, in Flutter often involves boilerplate and juggling multiple concerns. Without a unified solution, developers typically face:
- Manual Error Parsing: Each API call requires manual parsing of success and error responses. This is repetitive and error-prone, especially when error structures vary across endpoints.
- Scattered Error Logic: Error handling (like logging or default UI notifications) and local error handling (specific UI updates or fallbacks) become intertwined and spread across the codebase.
- Integrating Multiple Solutions: Implementing features like auto-retry for network glitches, debounce for search inputs, or throttle for rapid actions usually means pulling in several third-party packages. These solutions might not integrate seamlessly and add complexity.
Handler offers a streamlined, powerful alternative, consolidating these concerns into an elegant, declarative API:
// The Handler way:
final result = await handler.handle(
() => api.searchData(query), // Your Dio request or any async operation
minExecutionTime: Duration(milliseconds: 300), // Prevents UI flicker
// Built-in, customizable auto-retry
retry: Retry(
maxAttempts: 3,
maxTotalTime: Duration(seconds: 20),
delayStrategy: DelayStrategy.exponential,
retryIf: RetryIf.badConnection,
),
// Built-in, integrated rate control
rateLimiter: Debounce(
duration: Duration(milliseconds: 300),
onDelayTick: (timings) {
// Optionally show a delay progress
}
),
onSuccess: (data) {
lastSearchData = data;
// Process successful data, optionally transform it
},
onError: (e) {
// Handle specific errors locally if needed
switch (e) {
case ErrorResponse(statusCode: 404):
// Handle 404 error
break;
// BackendError - is a custom error type that you can define in your app
case ErrorResponse(error: BackendError(type: BackendErrorType.wrongQuery)):
// Handle wrong query error
break;
case InternalError(error: TimeoutException()):
// Handle timeout error
break;
// If the operation was cancelled by rate limiter
case CancelError():
return lastSearchData;
default:
// Let the default handler do its job for other errors
handler.onError(e);
}
},
);
// Centralized error parsing and default behaviors are defined once in your Handler instance.
// Call sites remain clean and focused on the operation itself.Stop scattering try-catch blocks, logging, and error notifications everywhere. Define error handling once in your Handler and reuse it across your app:
// In your AppHandler (extends Handler)
class AppHandler extends Handler<MyCustomApiError> {
AppHandler() : super(
parseBaseResponseError: (data) => BackendError.parse(data),
);
// Optional: Override onError for more control (e.g., with context or specific params)
void onError(HandledError<MyCustomApiError> error, {bool showToast = true}) {
// 1. Log every error
Logger.error("API Operation Failed", error: error.originalError, stackTrace: error.stackTrace);
// 2. Send to analytics/crash reporting (e.g., Sentry, Firebase)
switch (error) {
case InternalError():
case ErrorResponse(statusCode: >= 500):
Analytics.reportError(error);
break;
default:
// Do not report other errors like 4xx or cancellations
break;
}
// 3. Show user-friendly notifications (can be customized)
String errorMessage = "An unexpected error occurred.";
switch (error) {
case ErrorResponse(error: final apiError, statusCode: final code):
errorMessage = "API Error ($code): ${apiError.developerMessage}"; // Assuming MyCustomApiError has a developerMessage
break;
case InternalError(error: final internalErr):
errorMessage = "Internal error: ${internalErr.toString()}";
break;
case CancelError():
errorMessage = "Operation was cancelled.";
break;
}
if (showToast) {
// Your custom toast logic for this specific handler instance
showErrorToast(errorMessage);
}
}
}
// Now, at the call site, you only care about specific UI updates or fallbacks:
await handler.handle(
() => userRepository.updateProfile(newData),
onError: (error) {
// Maybe this specific error needs a dialog instead of a toast
switch (error) {
case ErrorResponse(statusCode: 422, error: final apiError): // Unprocessable Entity
// Assuming MyCustomApiError has a way to get validation messages
showValidationDialog(apiError.validationMessages);
break;
default:
// Let the global handler do its job for other errors
// (or call `handler.onError(error)` if you overrode it and want default behavior)
break;
}
// No need to return anything if the goal is just specific error UI
}
);With Handler, you get:
- Consistency: All errors are handled uniformly.
- Cleanliness: Business logic isn't cluttered with error handling.
- Maintainability: Update error logic in one place.
Automatically retry failed operations with intelligent backoff. Customize retry conditions using HandledError:
await handler.handle(
() => dataService.fetchCriticalData(),
retry: Retry(
maxAttempts: 5,
minDelay: Duration(milliseconds: 500),
maxDelay: Duration(seconds: 10),
delayStrategy: DelayStrategy.exponential, // Exponential backoff with jitter
maxTotalTime: Duration(seconds: 30),
retryIf: (e, s, stats) {
// Use wrapError to work with HandledError types
final error = handler.wrapError(e, s);
// Only retry specific API errors or network issues
return switch (error) {
ErrorResponse(statusCode: >= 500) => true, // Server errors
InternalError(error: SocketException()) => true, // Potentially network related
_ => false,
};
},
),
);Use Debounce for user input or Throttle for rapid operations:
// Search-as-you-type
searchField.onChanged = (query) {
handler.handle(
() => repository.searchItems(query),
key: 'search-operation', // Allows cancellation
rateLimiter: Debounce(duration: Duration(milliseconds: 300)),
);
};Chain dependent API calls cleanly:
final orderDetails = await handler.handleStrict(
() => orderRepository.createOrder(cartItems),
onSuccess: (orderConfirmation) async {
showToast('Order created: ${orderConfirmation.orderId}');
// Second request depends on the first one
final paymentResult = await handler.handleStrict(
() => paymentRepository.processPayment(orderConfirmation.orderId, paymentDetails),
onSuccess: (paymentStatus) => paymentStatus,
);
return OrderDetails(order: orderConfirmation, payment: paymentResult);
},
);Explicitly manage ongoing operations:
// Cancel pending search if user types quickly or navigates away
handler.cancel(key: 'search-operation');
// For debounce / throttle, fire immediately if needed (e.g. user action)
await handler.fire(key: 'user-action-debounce');
// Cancel all ongoing Handler operations (e.g., on screen dispose)
handler.cancelAll();Switch over HandledError for precise error management (Dart 3 pattern matching):
// Inside your custom onError or at the call site
switch (error) {
case ErrorResponse<MyCustomApiError>(error: final apiErr, statusCode: final code):
// Access structured error data directly
print('API Error Code: $code');
print('Custom Payload: ${apiErr.developerMessage}'); // Your MyCustomApiError payload
// error.url, error.method, error.requestData are available.
break;
case InternalError(error: final internalErr):
print('Internal error: $internalErr');
break;
case CancelError(rateLimiter: final limiter, timings: final timings):
print('Operation was cancelled. Limiter: ${limiter?.runtimeType}, Timings: $timings');
break;
}Your custom BaseResponseError (e.g., MyCustomApiError) only holds the parsed data (payload) from the error response. ErrorResponse provides access to HTTP details like statusCode.
Use HandlerFacade mixin in your Blocs, Cubits, or Notifiers (Riverpod) to keep them clean and focused on state management, delegating API operations to repositories/services accessed via the handler:
// In your Cubit/Bloc/Notifier
class MyFeatureCubit extends Cubit<MyState> with HandlerFacade<ApiError> {
@override
final Handler<ApiError> handler; // Injected or created
final MyRepository _repository;
MyFeatureCubit(this.handler, this._repository) : super(InitialState());
Future<void> fetchData() async {
emit(LoadingState());
// Use the handle method directly from the facade!
// No need to return from onSuccess if it only emits state
await handle<DataType, void>(
() => _repository.fetchData(),
onSuccess: (result) {
emit(LoadedState(result));
},
onError: (error) {
String friendlyMessage = "Failed to fetch data"; // Default
// You can customize the message based on error type here if needed
emit(ErrorState(friendlyMessage));
},
);
}
}Handler offers two primary methods for executing your operations: handle and handleStrict. Understanding their differences will help you write cleaner and more predictable code.
1. handle<T, D>(...) -> FutureOr<D?>
- Use Case: Ideal when the operation might not return a meaningful value upon success, or when
onErrorcan provide a fallback value. It's also suitable ifonSuccessis mainly for side-effects (like updating UI) and doesn't need to return a specific value. - Return Type:
FutureOr<D?>(nullable). This means the resultDcan benull. - Behavior:
- If
onSuccessis provided, it can transform the original resultTintoD. IfonSuccessis not provided or returnsnull, the result will benull(or the value fromonErrorif it provides one). - If
onErroris provided and returns a value of typeD?, that value will be the result in case of an error. Otherwise, ifonErrordoesn't return a value or isn't provided, the global error handler runs, andhandleresolves tonull.
- If
When to use handle:
- Executing "fire-and-forget" operations (e.g., a POST request that doesn't return data).
- When
onSuccessis used for side-effects and doesn't need to return a value. - When you have a specific fallback value to return from
onErrorin certain error scenarios.
2. handleStrict<T, D>(...) -> Future<D>
- Use Case: Essential when a successful operation must produce a non-null value of type
D. This method enforces stricter type safety. - Return Type:
Future<D>(non-nullable). The resultDis guaranteed to be non-null if the operation succeeds. - Behavior:
onSuccessis required and must return a non-null value of typeD.- If
onErroris not provided, any error encountered (after global error processing) will be re-thrown, halting further execution in the current chain. This ensures that you explicitly handle errors or let them propagate. - If
onErroris provided, it must return a non-null value of typeDto satisfy the non-nullable return type ofhandleStrict.
When to use handleStrict:
- Fetching data that is crucial for subsequent logic and must be non-null.
- Chaining operations where each step depends on a non-null result from the previous one.
- When you want to ensure that any unhandled error (by a local
onError) aggressively stops the execution flow by re-throwing.
In summary:
- Choose
handlefor flexibility, nullable results, and side-effect-driven success handlers. - Choose
handleStrictfor robust, non-nullable results and when errors should either be explicitly handled to return a validDor halt execution.
It's generally better to call repository methods inside handler.handle, not to use Handler directly within repositories.
// data_repository.dart
class DataRepository {
Future<MyData> fetchData() async {
// Actual HTTP call using Dio, http, etc.
final response = await dio.get('/data');
return MyData.fromJson(response.data); // Throws if parsing fails or bad response
}
}
// feature_controller.dart or BLoC/Cubit
class FeatureController {
final AppHandler handler; // Your customized Handler instance
final DataRepository repository;
FeatureController(this.handler, this.repository);
Future<ViewModel?> loadAndShowData() async {
final viewModel = await handler.handle<MyData, ViewModel?>(
() => repository.fetchData(), // Repository method call
onSuccess: (myData) => ViewModel.fromData(myData), // Map to ViewModel
// Global error handling from AppHandler will apply
// Add specific onError here if needed for this call site,
// for example, to return a specific ViewModel on a particular error:
onError: (error) {
if (error case ErrorResponse(statusCode: 404)) {
return ViewModel.notFound();
}
// Let global handler manage UI for other errors
handler.onError(error);
},
);
return viewModel;
}
}Contributions of any kind welcome!
