Documentation

Error handling

Network states, BaseModel unwrap helpers, and exponential-backoff retry.

A reliable mobile app handles four states in every screen: loading, error, empty, and data. The HTTP client throws on non-2xx and on network failure — the rest is a UI concern. This page covers the small helpers that keep the four states consistent and adds an exponential-backoff retry for the operations where it makes sense.

The four states

Every list and detail screen passes through these:

StateWhenWhat the user sees
LoadingFirst fetch in flightSpinner or shimmer placeholder
ErrorNetwork failure or non-2xxError message + retry button
Empty200 OK, zero rowsFriendly empty state with a primary action
Data200 OK, at least one rowThe actual list/detail

A reusable widget keeps the four states consistent:

// lib/widgets/async_view.dart
import 'package:flutter/material.dart';

class AsyncView<T> extends StatelessWidget {
  final AsyncSnapshot<List<T>> snapshot;
  final Widget Function(List<T>) builder;
  final Widget? emptyView;
  final VoidCallback? onRetry;

  const AsyncView({
    super.key,
    required this.snapshot,
    required this.builder,
    this.emptyView,
    this.onRetry,
  });

  @override
  Widget build(BuildContext context) {
    if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
      return const Center(child: CircularProgressIndicator());
    }
    if (snapshot.hasError && !snapshot.hasData) {
      return _ErrorView(
        message: _humanize(snapshot.error),
        onRetry: onRetry,
      );
    }
    final list = snapshot.data ?? const [];
    if (list.isEmpty) {
      return emptyView ?? const Center(child: Text('Nothing here yet.'));
    }
    return builder(list);
  }

  String _humanize(Object? error) {
    final s = error.toString();
    if (s.contains('SocketException')) return 'No connection. Check your network.';
    if (s.contains('TimeoutException')) return 'The server took too long. Try again.';
    if (s.contains('Session expired')) return 'Your session expired. Please sign in.';
    return 'Something went wrong.';
  }
}

class _ErrorView extends StatelessWidget {
  final String message;
  final VoidCallback? onRetry;
  const _ErrorView({required this.message, this.onRetry});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(message),
          if (onRetry != null) ...[
            const SizedBox(height: 12),
            FilledButton(onPressed: onRetry, child: const Text('Retry')),
          ],
        ],
      ),
    );
  }
}

BaseModel unwrap helpers

The HTTP client returns the raw decoded JSON. Records come back wrapped:

{ "pk": "...", "sk": "...", "data": { "name": "Acme" } }

Lists wrap each item the same way. A pair of helpers keeps services concise:

// lib/services/base_model.dart
class BaseModel {
  /// Unwrap a single record. Returns the inner `data` map, augmented with
  /// the BaseModel envelope fields under `__pk`, `__sk`, etc.
  static Map<String, dynamic> unwrap(Map<String, dynamic> json) {
    final data = json['data'];
    if (data is Map<String, dynamic>) {
      return {
        ...data,
        '__pk': json['pk'],
        '__sk': json['sk'],
        '__version': json['version'],
        '__state': json['state'],
        '__createdate': json['createdate'],
        '__modifydate': json['modifydate'],
      };
    }
    return Map<String, dynamic>.from(json);
  }

  /// Unwrap a paginated list response. Returns the inner array.
  /// Falls back across the variants the server has shipped historically.
  static List<dynamic> unwrapList(dynamic response) {
    if (response is List) return response;
    if (response is Map) {
      return (response['data'] ?? response['results'] ?? response['items'] ?? [])
          as List;
    }
    return const [];
  }

  /// Total count for pagination.
  static int totalOf(dynamic response, {int defaultValue = 0}) {
    if (response is Map) {
      final t = response['total'] ?? response['count'];
      if (t is int) return t;
      if (t is String) return int.tryParse(t) ?? defaultValue;
    }
    return defaultValue;
  }
}

Use them in services:

Future<List<LeadModel>> list({int page = 1}) async {
  final response = await _http.get('/crm/leads/detail', queryParams: {
    'page': page.toString(),
  });
  return BaseModel.unwrapList(response)
      .map((j) => LeadModel.fromJson(j as Map<String, dynamic>))
      .toList();
}

Exception types

The client throws plain Exception with a string. For operations where you want to branch on the error type, wrap it once in your service and rethrow typed exceptions:

// lib/services/errors.dart
sealed class AppmintError implements Exception {
  final String message;
  const AppmintError(this.message);
  @override
  String toString() => message;
}

class NetworkError extends AppmintError {
  const NetworkError(super.message);
}

class AuthError extends AppmintError {
  const AuthError(super.message);
}

class NotFoundError extends AppmintError {
  const NotFoundError(super.message);
}

class ServerError extends AppmintError {
  const ServerError(super.message);
}

AppmintError translate(Object error) {
  final s = error.toString();
  if (s.contains('Session expired')) return AuthError(s);
  if (s.contains('Not found')) return NotFoundError(s);
  if (s.contains('Server error')) return ServerError(s);
  if (s.contains('SocketException') || s.contains('TimeoutException')) {
    return NetworkError('No connection');
  }
  return ServerError(s);
}

Then in services:

Future<LeadModel> getById(String id) async {
  try {
    final response = await _http.get('/crm/leads/detail/$id');
    return LeadModel.fromJson(response);
  } catch (e) {
    throw translate(e);
  }
}

Exponential-backoff retry

The HTTP client does not retry by default — the only auto-retry is the single token-refresh on 401. For idempotent reads that are worth retrying transparently (KPIs, dashboards, list refreshes), wrap the call:

// lib/services/retry.dart
import 'dart:async';
import 'dart:math';

Future<T> retry<T>(
  Future<T> Function() fn, {
  int maxAttempts = 3,
  Duration baseDelay = const Duration(milliseconds: 500),
  bool Function(Object error)? retryIf,
}) async {
  Object lastError;
  for (var attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (e) {
      lastError = e;
      final shouldRetry = retryIf?.call(e) ?? _isTransient(e);
      if (!shouldRetry || attempt == maxAttempts - 1) rethrow;
      final jitter = Random().nextInt(200);
      final delay = baseDelay * pow(2, attempt) + Duration(milliseconds: jitter);
      await Future.delayed(delay);
    }
  }
  throw lastError!;
}

bool _isTransient(Object error) {
  final s = error.toString();
  return s.contains('SocketException') ||
      s.contains('TimeoutException') ||
      s.contains('Server error') ||
      s.contains('502') ||
      s.contains('503') ||
      s.contains('504');
}

Usage:

final leads = await retry(
  () => LeadsService().list(),
  maxAttempts: 3,
);

Don't retry mutations (POST, PUT, DELETE) blindly — the server may have committed the first call and the second creates a duplicate. If you must retry a write, send an idempotency key in the body and let the server deduplicate:

await _http.post('/storefront/order/process/$orderNumber', body: {
  'idempotencyKey': '$orderNumber-${DateTime.now().millisecondsSinceEpoch}',
});

Connectivity awareness

connectivity_plus lets you fast-fail when the device has no network:

import 'package:connectivity_plus/connectivity_plus.dart';

Future<bool> hasNetwork() async {
  final result = await Connectivity().checkConnectivity();
  return !result.contains(ConnectivityResult.none);
}

Skip the HTTP call entirely when offline — the request will time out 30 seconds later anyway, and the user has already lost confidence by then.

if (!await hasNetwork()) {
  throw const NetworkError('You are offline');
}

Logging in production

Debug logs are on in dev (controlled by EnvironmentConfig.enableDebugLogs) and off in release. For production observability, plug a crash reporter into FlutterError.onError:

FlutterError.onError = (FlutterErrorDetails details) {
  // Sentry, Crashlytics, your own pipeline.
  reportToBackend(details.exception, details.stack);
  FlutterError.presentError(details);
};

Don't ship verbose request/response logs to production — at minimum they leak orgIds and at worst they leak tokens and PII from response bodies.

What good error UX looks like

  • Always offer a way out — a Retry button, a "Sign in again" link, a "Go home" affordance.
  • Distinguish "the network failed" from "the server said no" — users can fix the first, not the second.
  • Don't surface stack traces. The user doesn't care, and they can't act on them.
  • Keep error copy short and human — "Couldn't load orders. Check your connection and try again." beats "Exception: SocketException: Failed host lookup."