handler 1.0.1 copy "handler: ^1.0.1" to clipboard
handler: ^1.0.1 copied to clipboard

Error handling, rate limiting, delay and retry in one ergonomic api

Handler #

package_thumbnail

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

Motivation #

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.

Powerful Features at a Glance #

Customizable Error Handling #

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.

🔄 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,
    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,
      };
    },
  ),
);

⏰ 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)),
  );
};

🧩 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));
      },
    );
  }
}

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 if onSuccess 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 result D can be null.
  • Behavior:
    • If onSuccess is provided, it can transform the original result T into D. If onSuccess is not provided or returns null, the result will be null (or the value from onError if it provides one).
    • If onError is provided and returns a value of type D?, that value will be the result in case of an error. Otherwise, if onError doesn't return a value or isn't provided, the global error handler runs, and handle resolves to null.

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 result D is guaranteed to be non-null if the operation succeeds.
  • Behavior:
    • onSuccess is required and must return a non-null value of type D.
    • 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 type D to satisfy the non-nullable return type of handleStrict.

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 valid D or halt execution.

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 ✨ #

Alt

Contributions of any kind welcome!

Activities #

Alt

8
likes
150
points
32
downloads
screenshot

Publisher

verified publisherstarproxima.dev

Weekly Downloads

Error handling, rate limiting, delay and retry in one ergonomic api

Homepage
Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

dio, meta

More

Packages that depend on handler