Handler
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 - centralized and customizable with type safety
- ✅ Auto-retry - seamless recovery from failures
- ✅ Rate-limiting - optimized frequency control
- ✅ Operation control - cancel or trigger operations on demand
- ✅ Great UX - smooth loading states and transitions
Why You'll Love Handler
Every Flutter developer knows the pain of these common API problems:
// Problems with traditional approach:
try {
showLoading();
final response = await dio.get('/endpoint'); // What if network fails?
hideLoading(); // What if it's too fast and UI flickers?
if (response.statusCode == 200) {
final data = MyData.fromJson(response.data);
updateUI(data);
} else {
// Different errors need different handling
handleApiError(response); // Where to put global vs local handling?
}
} catch (e) {
hideLoading(); // Repetitive error state management
handleNetworkError(e); // What if temporary and should retry?
}
// With USER INPUT (even worse!)
// - Need debouncing for search-as-you-type
// - Need throttling for "like" buttons
// - Need cancellation when user navigates away
Handler transforms this mess into elegant, declarative code:
// The Handler way:
final result = await handler.handle(
() => api.getData(), // Dio request in repository
minExecutionTime: Duration(milliseconds: 300), // No UI flicker
retry: Retry(maxAttempts: 3), // Auto-retry on network issues
rateLimiter: Debounce(duration: Duration(milliseconds: 300)), // Rate control
onSuccess: (data) {
// Optionally transform or use part of the response
final data = data.someData;
// No need to return anything from onSuccess if the primary goal is side-effects (e.g., UI update)
},
onError: (error) => handleSpecificError(error), // Local error handling if needed
);
// That's it! Global error handling (logging, analytics, default UI notifications)
// is managed centrally by Handler, keeping your call sites clean.
Powerful Features at a Glance
💪 Centralized Error Handling: The Core Superpower
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) => MyCustomApiError.fromData(data),
// Provide a default global error handler
onError: (error) {
// 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;
}
showErrorToast(errorMessage);
}
);
// Optional: Override onError for more control (e.g., with context or specific params)
void onError(HandledError<MyCustomApiError> error, {bool showToast = true}) {
if (showToast) {
// Your custom toast logic for this specific handler instance
}
// You can still call the default global behavior if needed from the constructor
super.onError(error);
}
}
// 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.
🔄 Smart Retry with Strategies
Automatically retry failed operations with intelligent backoff. Customize retry conditions using HandledError
:
await handler.handle(
() => dataService.fetchCriticalData(),
retry: Retry(
maxAttempts: 5,
delayStrategy: DelayStrategy.exponential(), // Exponential backoff with jitter
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 || final code == 408) => true, // Server errors or timeout
InternalError(error: SocketException()) => true, // Potentially network related
_ => false,
};
},
),
);
⏰ Rate Limiting Made Easy
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)),
);
};
handle
vs handleStrict
: Choosing the Right Tool
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
onError
can provide a fallback value. It's also suitable ifonSuccess
is mainly for side-effects (like updating UI) and doesn't need to return a specific value. - Return Type:
FutureOr<D?>
(nullable). This means the resultD
can benull
. - Behavior:
- If
onSuccess
is provided, it can transform the original resultT
intoD
. IfonSuccess
is not provided or returnsnull
, the result will benull
(or the value fromonError
if it provides one). - If
onError
is provided and returns a value of typeD?
, that value will be the result in case of an error. Otherwise, ifonError
doesn't return a value or isn't provided, the global error handler runs, andhandle
resolves tonull
.
- If
When to use handle
:
- Executing "fire-and-forget" operations (e.g., a POST request that doesn't return data).
- When
onSuccess
is used for side-effects and doesn't need to return a value. - When you have a specific fallback value to return from
onError
in 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 resultD
is guaranteed to be non-null if the operation succeeds. - Behavior:
onSuccess
is required and must return a non-null value of typeD
.- If
onError
is 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
onError
is provided, it must return a non-null value of typeD
to 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
handle
for flexibility, nullable results, and side-effect-driven success handlers. - Choose
handleStrict
for robust, non-nullable results and when errors should either be explicitly handled to return a validD
or halt execution.
🧩 Elegant Nested Requests
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);
},
);
⚡ Operation Control
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();
HandledError
Deep Dive
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
.
🤝 HandlerFacade
for Clean Architecture
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));
},
);
}
}
Installation
dependencies:
handler: ^1.0.0
Quick Example: Repository Pattern
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;
}
}
Contributors ✨
Contributions of any kind welcome!