chore: initial release
This commit is contained in:
397
lib/core/error/api_error.dart
Normal file
397
lib/core/error/api_error.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Standardized API error representation
|
||||
/// Provides consistent error information across all API operations
|
||||
@immutable
|
||||
class ApiError implements Exception {
|
||||
const ApiError._({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.endpoint,
|
||||
this.method,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
this.fieldErrors = const {},
|
||||
this.originalError,
|
||||
this.technical,
|
||||
this.retryAfter,
|
||||
this.timeoutDuration,
|
||||
});
|
||||
|
||||
// Factory constructors for different error types
|
||||
const ApiError.network({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
dynamic originalError,
|
||||
String? technical,
|
||||
}) : this._(
|
||||
type: ApiErrorType.network,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
originalError: originalError,
|
||||
technical: technical,
|
||||
);
|
||||
|
||||
const ApiError.timeout({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Duration? timeoutDuration,
|
||||
}) : this._(
|
||||
type: ApiErrorType.timeout,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
timeoutDuration: timeoutDuration,
|
||||
);
|
||||
|
||||
const ApiError.authentication({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
}) : this._(
|
||||
type: ApiErrorType.authentication,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
const ApiError.authorization({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
}) : this._(
|
||||
type: ApiErrorType.authorization,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
const ApiError.validation({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Map<String, List<String>> fieldErrors = const {},
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.validation,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: 422,
|
||||
fieldErrors: fieldErrors,
|
||||
details: details,
|
||||
);
|
||||
|
||||
const ApiError.badRequest({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.badRequest,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: 400,
|
||||
details: details,
|
||||
);
|
||||
|
||||
const ApiError.notFound({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
}) : this._(
|
||||
type: ApiErrorType.notFound,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode ?? 404,
|
||||
);
|
||||
|
||||
const ApiError.server({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.server,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
details: details,
|
||||
);
|
||||
|
||||
const ApiError.rateLimit({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
Duration? retryAfter,
|
||||
}) : this._(
|
||||
type: ApiErrorType.rateLimit,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode ?? 429,
|
||||
retryAfter: retryAfter,
|
||||
);
|
||||
|
||||
const ApiError.cancelled({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
}) : this._(
|
||||
type: ApiErrorType.cancelled,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
);
|
||||
|
||||
const ApiError.security({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
}) : this._(
|
||||
type: ApiErrorType.security,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
);
|
||||
|
||||
const ApiError.unknown({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
dynamic originalError,
|
||||
String? technical,
|
||||
}) : this._(
|
||||
type: ApiErrorType.unknown,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
originalError: originalError,
|
||||
technical: technical,
|
||||
);
|
||||
|
||||
const ApiError.client({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.badRequest,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
details: details,
|
||||
);
|
||||
|
||||
final ApiErrorType type;
|
||||
final String message;
|
||||
final String? endpoint;
|
||||
final String? method;
|
||||
final int? statusCode;
|
||||
final ParsedErrorResponse? details;
|
||||
final Map<String, List<String>> fieldErrors;
|
||||
final dynamic originalError;
|
||||
final String? technical;
|
||||
final Duration? retryAfter;
|
||||
final Duration? timeoutDuration;
|
||||
|
||||
/// Check if this error has field-specific validation errors
|
||||
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||
|
||||
/// Check if this error is retryable
|
||||
bool get isRetryable {
|
||||
switch (type) {
|
||||
case ApiErrorType.network:
|
||||
case ApiErrorType.timeout:
|
||||
case ApiErrorType.server:
|
||||
case ApiErrorType.rateLimit:
|
||||
return true;
|
||||
case ApiErrorType.authentication:
|
||||
case ApiErrorType.authorization:
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
case ApiErrorType.notFound:
|
||||
case ApiErrorType.cancelled:
|
||||
case ApiErrorType.security:
|
||||
case ApiErrorType.unknown:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all field error messages as a flattened list
|
||||
List<String> get allFieldErrorMessages {
|
||||
final messages = <String>[];
|
||||
for (final entry in fieldErrors.entries) {
|
||||
final field = entry.key;
|
||||
final errors = entry.value;
|
||||
for (final error in errors) {
|
||||
messages.add('$field: $error');
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// Get first field error message for quick display
|
||||
String? get firstFieldError {
|
||||
if (fieldErrors.isEmpty) return null;
|
||||
final firstEntry = fieldErrors.entries.first;
|
||||
final field = firstEntry.key;
|
||||
final firstError = firstEntry.value.first;
|
||||
return '$field: $firstError';
|
||||
}
|
||||
|
||||
/// Create a copy with updated fields
|
||||
ApiError copyWith({
|
||||
ApiErrorType? type,
|
||||
String? message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
ParsedErrorResponse? details,
|
||||
Map<String, List<String>>? fieldErrors,
|
||||
dynamic originalError,
|
||||
String? technical,
|
||||
Duration? retryAfter,
|
||||
Duration? timeoutDuration,
|
||||
}) {
|
||||
return ApiError._(
|
||||
type: type ?? this.type,
|
||||
message: message ?? this.message,
|
||||
endpoint: endpoint ?? this.endpoint,
|
||||
method: method ?? this.method,
|
||||
statusCode: statusCode ?? this.statusCode,
|
||||
details: details ?? this.details,
|
||||
fieldErrors: fieldErrors ?? this.fieldErrors,
|
||||
originalError: originalError ?? this.originalError,
|
||||
technical: technical ?? this.technical,
|
||||
retryAfter: retryAfter ?? this.retryAfter,
|
||||
timeoutDuration: timeoutDuration ?? this.timeoutDuration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to map for logging and debugging
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'message': message,
|
||||
'endpoint': endpoint,
|
||||
'method': method,
|
||||
'statusCode': statusCode,
|
||||
'fieldErrors': fieldErrors,
|
||||
'technical': technical,
|
||||
'retryAfter': retryAfter?.inSeconds,
|
||||
'timeoutDuration': timeoutDuration?.inSeconds,
|
||||
'isRetryable': isRetryable,
|
||||
'hasFieldErrors': hasFieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write('ApiError(');
|
||||
buffer.write('type: ${type.name}, ');
|
||||
buffer.write('message: $message');
|
||||
|
||||
if (endpoint != null) {
|
||||
buffer.write(', endpoint: $endpoint');
|
||||
}
|
||||
|
||||
if (method != null) {
|
||||
buffer.write(', method: $method');
|
||||
}
|
||||
|
||||
if (statusCode != null) {
|
||||
buffer.write(', statusCode: $statusCode');
|
||||
}
|
||||
|
||||
if (hasFieldErrors) {
|
||||
buffer.write(', fieldErrors: ${fieldErrors.length}');
|
||||
}
|
||||
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ApiError &&
|
||||
other.type == type &&
|
||||
other.message == message &&
|
||||
other.endpoint == endpoint &&
|
||||
other.method == method &&
|
||||
other.statusCode == statusCode &&
|
||||
mapEquals(other.fieldErrors, fieldErrors);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
type,
|
||||
message,
|
||||
endpoint,
|
||||
method,
|
||||
statusCode,
|
||||
fieldErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of API errors for categorization and handling
|
||||
enum ApiErrorType {
|
||||
network, // Connection issues, DNS resolution, etc.
|
||||
timeout, // Request timeout (send, receive, connection)
|
||||
authentication, // 401, invalid credentials, expired tokens
|
||||
authorization, // 403, insufficient permissions
|
||||
validation, // 422, field validation errors
|
||||
badRequest, // 400, malformed request
|
||||
notFound, // 404, resource not found
|
||||
server, // 5xx server errors
|
||||
rateLimit, // 429, too many requests
|
||||
cancelled, // Request was cancelled
|
||||
security, // Certificate, SSL/TLS issues
|
||||
unknown, // Unexpected or unhandled errors
|
||||
}
|
||||
|
||||
/// Parsed error response from API
|
||||
/// Contains structured error information from server responses
|
||||
class ParsedErrorResponse {
|
||||
const ParsedErrorResponse({
|
||||
this.message,
|
||||
this.code,
|
||||
this.errors = const [],
|
||||
this.fieldErrors = const {},
|
||||
this.metadata = const {},
|
||||
});
|
||||
|
||||
final String? message;
|
||||
final String? code;
|
||||
final List<String> errors;
|
||||
final Map<String, List<String>> fieldErrors;
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||
bool get hasGeneralErrors => errors.isNotEmpty;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ParsedErrorResponse(message: $message, errors: ${errors.length}, fieldErrors: ${fieldErrors.length})';
|
||||
}
|
||||
}
|
||||
408
lib/core/error/api_error_handler.dart
Normal file
408
lib/core/error/api_error_handler.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_error.dart';
|
||||
import 'error_parser.dart';
|
||||
|
||||
/// Comprehensive API error handler with structured error parsing
|
||||
/// Handles all types of API errors and converts them to standardized format
|
||||
class ApiErrorHandler {
|
||||
static final ApiErrorHandler _instance = ApiErrorHandler._internal();
|
||||
factory ApiErrorHandler() => _instance;
|
||||
ApiErrorHandler._internal();
|
||||
|
||||
final ErrorParser _errorParser = ErrorParser();
|
||||
|
||||
/// Transform any exception into standardized ApiError
|
||||
ApiError transformError(
|
||||
dynamic error, {
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Map<String, dynamic>? requestData,
|
||||
}) {
|
||||
try {
|
||||
if (error is DioException) {
|
||||
return _handleDioException(error, endpoint: endpoint, method: method);
|
||||
} else if (error is ApiError) {
|
||||
return error;
|
||||
} else {
|
||||
return ApiError.unknown(
|
||||
message: 'An unexpected error occurred',
|
||||
originalError: error,
|
||||
technical: error.toString(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback error if transformation itself fails
|
||||
debugPrint('ApiErrorHandler: Error transforming exception: $e');
|
||||
return ApiError.unknown(
|
||||
message: 'A system error occurred',
|
||||
originalError: error,
|
||||
technical: 'Error transformation failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle DioException with detailed error parsing
|
||||
ApiError _handleDioException(
|
||||
DioException dioError, {
|
||||
String? endpoint,
|
||||
String? method,
|
||||
}) {
|
||||
final statusCode = dioError.response?.statusCode;
|
||||
final responseData = dioError.response?.data;
|
||||
final requestPath = endpoint ?? dioError.requestOptions.path;
|
||||
final httpMethod = method ?? dioError.requestOptions.method;
|
||||
|
||||
// Log error details for debugging
|
||||
_logErrorDetails(dioError, requestPath, httpMethod);
|
||||
|
||||
switch (dioError.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return ApiError.timeout(
|
||||
message: 'Connection timeout - please check your internet connection',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
timeoutDuration: dioError.requestOptions.connectTimeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.sendTimeout:
|
||||
return ApiError.timeout(
|
||||
message: 'Request send timeout - the upload took too long',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
timeoutDuration: dioError.requestOptions.sendTimeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return ApiError.timeout(
|
||||
message: 'Response timeout - the server took too long to respond',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
timeoutDuration: dioError.requestOptions.receiveTimeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return ApiError.security(
|
||||
message:
|
||||
'Security certificate error - unable to verify server identity',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return ApiError.network(
|
||||
message:
|
||||
'Network connection error - please check your internet connection',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
originalError: dioError,
|
||||
);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return ApiError.cancelled(
|
||||
message: 'Request was cancelled',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
return _handleBadResponse(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
statusCode,
|
||||
responseData,
|
||||
);
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
return ApiError.unknown(
|
||||
message: 'An unexpected network error occurred',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
originalError: dioError,
|
||||
technical: dioError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle bad response errors with detailed status code analysis
|
||||
ApiError _handleBadResponse(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
int? statusCode,
|
||||
dynamic responseData,
|
||||
) {
|
||||
if (statusCode == null) {
|
||||
return ApiError.server(
|
||||
message: 'Invalid server response',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: null,
|
||||
);
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return _handleBadRequest(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
responseData,
|
||||
);
|
||||
|
||||
case 401:
|
||||
return ApiError.authentication(
|
||||
message: 'Authentication failed - please sign in again',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
case 403:
|
||||
return ApiError.authorization(
|
||||
message: 'Access denied - you don\'t have permission for this action',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
case 404:
|
||||
return ApiError.notFound(
|
||||
message: 'The requested resource was not found',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
case 422:
|
||||
return _handleValidationError(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
responseData,
|
||||
);
|
||||
|
||||
case 429:
|
||||
return ApiError.rateLimit(
|
||||
message: 'Too many requests - please wait before trying again',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
retryAfter: _extractRetryAfter(dioError.response?.headers),
|
||||
);
|
||||
|
||||
default:
|
||||
if (statusCode >= 500) {
|
||||
return _handleServerError(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
statusCode,
|
||||
responseData,
|
||||
);
|
||||
} else {
|
||||
return ApiError.client(
|
||||
message: 'Client error occurred',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
details: _errorParser.parseErrorResponse(responseData),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle 400 Bad Request with detailed parsing
|
||||
ApiError _handleBadRequest(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
dynamic responseData,
|
||||
) {
|
||||
final parsedError = _errorParser.parseErrorResponse(responseData);
|
||||
|
||||
return ApiError.badRequest(
|
||||
message:
|
||||
parsedError.message ?? 'Invalid request - please check your input',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
details: parsedError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle 422 Validation Error with field-specific parsing
|
||||
ApiError _handleValidationError(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
dynamic responseData,
|
||||
) {
|
||||
final parsedError = _errorParser.parseValidationError(responseData);
|
||||
|
||||
return ApiError.validation(
|
||||
message: 'Validation failed - please check your input',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
fieldErrors: parsedError.fieldErrors,
|
||||
details: parsedError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle server errors (5xx)
|
||||
ApiError _handleServerError(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
int statusCode,
|
||||
dynamic responseData,
|
||||
) {
|
||||
final parsedError = _errorParser.parseErrorResponse(responseData);
|
||||
|
||||
String message;
|
||||
switch (statusCode) {
|
||||
case 500:
|
||||
message = 'Internal server error - please try again later';
|
||||
break;
|
||||
case 502:
|
||||
message = 'Bad gateway - the server is temporarily unavailable';
|
||||
break;
|
||||
case 503:
|
||||
message = 'Service unavailable - the server is temporarily down';
|
||||
break;
|
||||
case 504:
|
||||
message = 'Gateway timeout - the server took too long to respond';
|
||||
break;
|
||||
default:
|
||||
message = 'Server error occurred - please try again later';
|
||||
}
|
||||
|
||||
return ApiError.server(
|
||||
message: message,
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
details: parsedError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract retry-after header for rate limiting
|
||||
Duration? _extractRetryAfter(Headers? headers) {
|
||||
if (headers == null) return null;
|
||||
|
||||
final retryAfterHeader =
|
||||
headers.value('retry-after') ??
|
||||
headers.value('Retry-After') ??
|
||||
headers.value('X-RateLimit-Reset-After');
|
||||
|
||||
if (retryAfterHeader != null) {
|
||||
final seconds = int.tryParse(retryAfterHeader);
|
||||
if (seconds != null) {
|
||||
return Duration(seconds: seconds);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Log error details for debugging and monitoring
|
||||
void _logErrorDetails(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('🔴 API Error Details:');
|
||||
debugPrint(' Method: ${httpMethod.toUpperCase()}');
|
||||
debugPrint(' Endpoint: $requestPath');
|
||||
debugPrint(' Type: ${dioError.type}');
|
||||
debugPrint(' Status: ${dioError.response?.statusCode}');
|
||||
|
||||
if (dioError.response?.data != null) {
|
||||
debugPrint(' Response: ${dioError.response?.data}');
|
||||
}
|
||||
|
||||
if (dioError.requestOptions.data != null) {
|
||||
debugPrint(' Request Data: ${dioError.requestOptions.data}');
|
||||
}
|
||||
|
||||
debugPrint(' Error: ${dioError.message}');
|
||||
}
|
||||
|
||||
// In production, you would send this to your error tracking service
|
||||
// FirebaseCrashlytics.instance.recordError(dioError, stackTrace);
|
||||
// Sentry.captureException(dioError);
|
||||
}
|
||||
|
||||
/// Check if error is retryable
|
||||
bool isRetryable(ApiError error) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.timeout:
|
||||
case ApiErrorType.network:
|
||||
case ApiErrorType.server:
|
||||
return true;
|
||||
case ApiErrorType.rateLimit:
|
||||
return true; // Can retry after waiting
|
||||
case ApiErrorType.authentication:
|
||||
return false; // Need new token
|
||||
case ApiErrorType.authorization:
|
||||
case ApiErrorType.notFound:
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
return false; // Client errors aren't retryable
|
||||
case ApiErrorType.cancelled:
|
||||
case ApiErrorType.security:
|
||||
case ApiErrorType.unknown:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggested retry delay for retryable errors
|
||||
Duration? getRetryDelay(ApiError error) {
|
||||
if (!isRetryable(error)) return null;
|
||||
|
||||
switch (error.type) {
|
||||
case ApiErrorType.rateLimit:
|
||||
return error.retryAfter ?? const Duration(minutes: 1);
|
||||
case ApiErrorType.timeout:
|
||||
return const Duration(seconds: 5);
|
||||
case ApiErrorType.network:
|
||||
return const Duration(seconds: 3);
|
||||
case ApiErrorType.server:
|
||||
return const Duration(seconds: 10);
|
||||
default:
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly error message with actionable advice
|
||||
String getUserMessage(ApiError error) {
|
||||
final baseMessage = error.message;
|
||||
|
||||
// Add actionable advice based on error type
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
return '$baseMessage\n\nPlease check your internet connection and try again.';
|
||||
case ApiErrorType.timeout:
|
||||
return '$baseMessage\n\nThis might be due to a slow connection. Try again in a moment.';
|
||||
case ApiErrorType.authentication:
|
||||
return '$baseMessage\n\nPlease sign in again to continue.';
|
||||
case ApiErrorType.authorization:
|
||||
return '$baseMessage\n\nContact support if you believe this is an error.';
|
||||
case ApiErrorType.validation:
|
||||
return '$baseMessage\n\nPlease correct the highlighted fields and try again.';
|
||||
case ApiErrorType.rateLimit:
|
||||
final delay = error.retryAfter;
|
||||
if (delay != null) {
|
||||
final minutes = delay.inMinutes;
|
||||
final seconds = delay.inSeconds % 60;
|
||||
return '$baseMessage\n\nPlease wait ${minutes > 0 ? '${minutes}m ' : ''}${seconds}s before trying again.';
|
||||
}
|
||||
return '$baseMessage\n\nPlease wait a moment before trying again.';
|
||||
case ApiErrorType.server:
|
||||
return '$baseMessage\n\nOur servers are experiencing issues. Please try again in a few minutes.';
|
||||
default:
|
||||
return baseMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
239
lib/core/error/api_error_interceptor.dart
Normal file
239
lib/core/error/api_error_interceptor.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_error_handler.dart';
|
||||
import 'api_error.dart';
|
||||
|
||||
/// Dio interceptor for automatic error handling and transformation
|
||||
/// Converts all HTTP errors into standardized ApiError format
|
||||
class ApiErrorInterceptor extends Interceptor {
|
||||
final ApiErrorHandler _errorHandler = ApiErrorHandler();
|
||||
final bool logErrors;
|
||||
final bool throwApiErrors;
|
||||
|
||||
ApiErrorInterceptor({this.logErrors = true, this.throwApiErrors = true});
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
try {
|
||||
// Transform the error into our standardized format
|
||||
final apiError = _errorHandler.transformError(
|
||||
err,
|
||||
endpoint: err.requestOptions.path,
|
||||
method: err.requestOptions.method,
|
||||
);
|
||||
|
||||
if (logErrors) {
|
||||
_logApiError(apiError, err);
|
||||
}
|
||||
|
||||
if (throwApiErrors) {
|
||||
// Replace the DioException with our ApiError
|
||||
final enhancedError = DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
type: err.type,
|
||||
error: apiError,
|
||||
message: apiError.message,
|
||||
);
|
||||
handler.reject(enhancedError);
|
||||
} else {
|
||||
// Store the ApiError in the response extra data
|
||||
if (err.response != null) {
|
||||
err.response!.extra['apiError'] = apiError;
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if error transformation fails
|
||||
debugPrint('ApiErrorInterceptor: Failed to transform error: $e');
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
// Check for errors in successful responses (some APIs return errors with 200 status)
|
||||
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
// Check for error indicators in successful responses
|
||||
if (_isErrorResponse(data)) {
|
||||
final apiError = _errorHandler.transformError(
|
||||
data,
|
||||
endpoint: response.requestOptions.path,
|
||||
method: response.requestOptions.method,
|
||||
);
|
||||
|
||||
if (logErrors) {
|
||||
debugPrint('🟡 API Error in successful response: $apiError');
|
||||
}
|
||||
|
||||
// Store the error for later handling
|
||||
response.extra['apiError'] = apiError;
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
/// Check if a successful response actually contains an error
|
||||
bool _isErrorResponse(Map<String, dynamic> data) {
|
||||
// Common error indicators in successful responses
|
||||
const errorIndicators = [
|
||||
'error',
|
||||
'errors',
|
||||
'error_message',
|
||||
'errorMessage',
|
||||
'success',
|
||||
];
|
||||
|
||||
for (final indicator in errorIndicators) {
|
||||
if (data.containsKey(indicator)) {
|
||||
final value = data[indicator];
|
||||
|
||||
// Check for explicit error indicators
|
||||
if (indicator == 'success' && value == false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for error messages or arrays
|
||||
if (indicator != 'success' && value != null) {
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return true;
|
||||
} else if (value is List && value.isNotEmpty) {
|
||||
return true;
|
||||
} else if (value is Map && value.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Log API error with structured information
|
||||
void _logApiError(ApiError apiError, DioException originalError) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
final typeIcon = _getErrorTypeIcon(apiError.type);
|
||||
debugPrint('$typeIcon API Error [${apiError.type.name.toUpperCase()}]');
|
||||
debugPrint(' Method: ${apiError.method?.toUpperCase() ?? 'UNKNOWN'}');
|
||||
debugPrint(' Endpoint: ${apiError.endpoint ?? 'unknown'}');
|
||||
debugPrint(' Status: ${apiError.statusCode ?? 'N/A'}');
|
||||
debugPrint(' Message: ${apiError.message}');
|
||||
|
||||
if (apiError.hasFieldErrors) {
|
||||
debugPrint(' Field Errors:');
|
||||
for (final entry in apiError.fieldErrors.entries) {
|
||||
final field = entry.key;
|
||||
final errors = entry.value;
|
||||
debugPrint(' $field: ${errors.join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
if (apiError.technical != null) {
|
||||
debugPrint(' Technical: ${apiError.technical}');
|
||||
}
|
||||
|
||||
if (apiError.retryAfter != null) {
|
||||
debugPrint(' Retry After: ${apiError.retryAfter!.inSeconds}s');
|
||||
}
|
||||
|
||||
// Log original error type for debugging
|
||||
debugPrint(' Original Type: ${originalError.type}');
|
||||
|
||||
// Log request details if available
|
||||
final requestData = originalError.requestOptions.data;
|
||||
if (requestData != null && requestData.toString().length < 500) {
|
||||
debugPrint(' Request: $requestData');
|
||||
}
|
||||
|
||||
// Log response data if available and not too large
|
||||
final responseData = originalError.response?.data;
|
||||
if (responseData != null && responseData.toString().length < 1000) {
|
||||
debugPrint(' Response: $responseData');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get emoji icon for error type
|
||||
String _getErrorTypeIcon(ApiErrorType type) {
|
||||
switch (type) {
|
||||
case ApiErrorType.network:
|
||||
return '🌐';
|
||||
case ApiErrorType.timeout:
|
||||
return '⏱️';
|
||||
case ApiErrorType.authentication:
|
||||
return '🔐';
|
||||
case ApiErrorType.authorization:
|
||||
return '🚫';
|
||||
case ApiErrorType.validation:
|
||||
return '✏️';
|
||||
case ApiErrorType.badRequest:
|
||||
return '❌';
|
||||
case ApiErrorType.notFound:
|
||||
return '🔍';
|
||||
case ApiErrorType.server:
|
||||
return '🔥';
|
||||
case ApiErrorType.rateLimit:
|
||||
return '🐌';
|
||||
case ApiErrorType.cancelled:
|
||||
return '🛑';
|
||||
case ApiErrorType.security:
|
||||
return '🔒';
|
||||
case ApiErrorType.unknown:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract ApiError from DioException if available
|
||||
static ApiError? extractApiError(DioException error) {
|
||||
return error.error is ApiError ? error.error as ApiError : null;
|
||||
}
|
||||
|
||||
/// Extract ApiError from Response if available
|
||||
static ApiError? extractApiErrorFromResponse(Response response) {
|
||||
return response.extra['apiError'] as ApiError?;
|
||||
}
|
||||
|
||||
/// Check if DioException contains an ApiError
|
||||
static bool hasApiError(DioException error) {
|
||||
return extractApiError(error) != null;
|
||||
}
|
||||
|
||||
/// Get user-friendly message from DioException
|
||||
static String getUserMessage(DioException error) {
|
||||
final apiError = extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return ApiErrorHandler().getUserMessage(apiError);
|
||||
}
|
||||
|
||||
// Fallback to basic DioException handling
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Connection timeout - please check your internet connection';
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Network connection error - please check your internet connection';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
return 'Authentication failed - please sign in again';
|
||||
} else if (statusCode == 403) {
|
||||
return 'Access denied - you don\'t have permission for this action';
|
||||
} else if (statusCode == 404) {
|
||||
return 'The requested resource was not found';
|
||||
} else if (statusCode != null && statusCode >= 500) {
|
||||
return 'Server error occurred - please try again later';
|
||||
}
|
||||
return 'An error occurred with your request';
|
||||
case DioExceptionType.cancel:
|
||||
return 'Request was cancelled';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'Security certificate error - unable to verify server identity';
|
||||
case DioExceptionType.unknown:
|
||||
return 'An unexpected error occurred - please try again';
|
||||
}
|
||||
}
|
||||
}
|
||||
467
lib/core/error/enhanced_error_service.dart
Normal file
467
lib/core/error/enhanced_error_service.dart
Normal file
@@ -0,0 +1,467 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'api_error.dart';
|
||||
import 'api_error_handler.dart';
|
||||
import 'api_error_interceptor.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced error service with comprehensive error handling capabilities
|
||||
/// Provides unified error management across the application
|
||||
class EnhancedErrorService {
|
||||
static final EnhancedErrorService _instance =
|
||||
EnhancedErrorService._internal();
|
||||
factory EnhancedErrorService() => _instance;
|
||||
EnhancedErrorService._internal();
|
||||
|
||||
final ApiErrorHandler _errorHandler = ApiErrorHandler();
|
||||
|
||||
/// Transform any error into ApiError format
|
||||
ApiError transformError(
|
||||
dynamic error, {
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Map<String, dynamic>? requestData,
|
||||
}) {
|
||||
return _errorHandler.transformError(
|
||||
error,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
requestData: requestData,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
String getUserMessage(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return _errorHandler.getUserMessage(error);
|
||||
} else if (error is DioException) {
|
||||
return ApiErrorInterceptor.getUserMessage(error);
|
||||
} else {
|
||||
return _getGenericErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get technical error details for debugging
|
||||
String getTechnicalDetails(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return error.technical ?? error.toString();
|
||||
} else if (error is DioException) {
|
||||
final apiError = ApiErrorInterceptor.extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return apiError.technical ?? apiError.toString();
|
||||
}
|
||||
return '${error.type}: ${error.message}';
|
||||
} else {
|
||||
return error.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if error is retryable
|
||||
bool isRetryable(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return _errorHandler.isRetryable(error);
|
||||
} else if (error is DioException) {
|
||||
final apiError = ApiErrorInterceptor.extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return _errorHandler.isRetryable(apiError);
|
||||
}
|
||||
return _isDioErrorRetryable(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get suggested retry delay
|
||||
Duration? getRetryDelay(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return _errorHandler.getRetryDelay(error);
|
||||
} else if (error is DioException) {
|
||||
final apiError = ApiErrorInterceptor.extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return _errorHandler.getRetryDelay(apiError);
|
||||
}
|
||||
return _getDioRetryDelay(error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Show error snackbar with appropriate styling and actions
|
||||
void showErrorSnackbar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
Duration? duration,
|
||||
bool showTechnicalDetails = false,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final isRetryableError = isRetryable(error);
|
||||
final retryDelay = getRetryDelay(error);
|
||||
|
||||
final snackBar = SnackBar(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(error),
|
||||
color: AppTheme.neutral50,
|
||||
size: IconSize.md,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: AppTheme.neutral50),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showTechnicalDetails) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
getTechnicalDetails(error),
|
||||
style: TextStyle(
|
||||
color: AppTheme.neutral50.withValues(alpha: Alpha.strong),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
backgroundColor: _getErrorColor(error),
|
||||
duration: duration ?? _getSnackbarDuration(error),
|
||||
action: isRetryableError && onRetry != null
|
||||
? SnackBarAction(
|
||||
label: retryDelay != null && retryDelay.inSeconds > 5
|
||||
? 'Retry (${retryDelay.inSeconds}s)'
|
||||
: 'Retry',
|
||||
textColor: AppTheme.neutral50,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
/// Show error dialog with detailed information and recovery options
|
||||
Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
String? title,
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showTechnicalDetails = false,
|
||||
}) async {
|
||||
final message = getUserMessage(error);
|
||||
final technicalDetails = getTechnicalDetails(error);
|
||||
final isRetryableError = isRetryable(error);
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(_getErrorIcon(error), color: _getErrorColor(error)),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(child: Text(title ?? _getErrorTitle(error))),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(message),
|
||||
if (showTechnicalDetails) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
const Text(
|
||||
'Technical Details:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.neutral100,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
technicalDetails,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.monospaceFontFamily,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (isRetryableError && onRetry != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onRetry();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onDismiss?.call();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget for displaying in UI
|
||||
Widget buildErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showTechnicalDetails = false,
|
||||
EdgeInsets? padding,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final technicalDetails = getTechnicalDetails(error);
|
||||
final isRetryableError = isRetryable(error);
|
||||
|
||||
return Container(
|
||||
padding: padding ?? const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(error),
|
||||
size: IconSize.xxl,
|
||||
color: _getErrorColor(error),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
_getErrorTitle(error),
|
||||
style: const TextStyle(
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppTheme.neutral600),
|
||||
),
|
||||
if (showTechnicalDetails) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.neutral100,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
technicalDetails,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.monospaceFontFamily,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isRetryableError && onRetry != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Log error with structured information
|
||||
void logError(
|
||||
dynamic error, {
|
||||
String? context,
|
||||
Map<String, dynamic>? additionalData,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (kDebugMode) {
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
debugPrint('🔴 ERROR [$timestamp] ${context ?? 'Unknown Context'}');
|
||||
debugPrint(' Message: ${getUserMessage(error)}');
|
||||
debugPrint(' Technical: ${getTechnicalDetails(error)}');
|
||||
|
||||
if (additionalData != null && additionalData.isNotEmpty) {
|
||||
debugPrint(' Additional Data: $additionalData');
|
||||
}
|
||||
|
||||
if (stackTrace != null) {
|
||||
debugPrint(' Stack Trace: $stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
// In production, send to error tracking service
|
||||
// FirebaseCrashlytics.instance.recordError(error, stackTrace, context: context);
|
||||
// Sentry.captureException(error, stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
String _getGenericErrorMessage(dynamic error) {
|
||||
if (error is Exception) {
|
||||
return 'An error occurred: ${error.toString()}';
|
||||
}
|
||||
return 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
bool _isDioErrorRetryable(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode != null && statusCode >= 500;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Duration? _getDioRetryDelay(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return const Duration(seconds: 5);
|
||||
case DioExceptionType.connectionError:
|
||||
return const Duration(seconds: 3);
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return const Duration(seconds: 10);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData _getErrorIcon(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
return Icons.wifi_off;
|
||||
case ApiErrorType.timeout:
|
||||
return Icons.timer_off;
|
||||
case ApiErrorType.authentication:
|
||||
return Icons.lock;
|
||||
case ApiErrorType.authorization:
|
||||
return Icons.block;
|
||||
case ApiErrorType.validation:
|
||||
return Icons.edit_off;
|
||||
case ApiErrorType.badRequest:
|
||||
return Icons.error_outline;
|
||||
case ApiErrorType.notFound:
|
||||
return Icons.search_off;
|
||||
case ApiErrorType.server:
|
||||
return Icons.dns;
|
||||
case ApiErrorType.rateLimit:
|
||||
return Icons.speed;
|
||||
case ApiErrorType.cancelled:
|
||||
return Icons.cancel;
|
||||
case ApiErrorType.security:
|
||||
return Icons.security;
|
||||
case ApiErrorType.unknown:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
return Icons.error_outline;
|
||||
}
|
||||
|
||||
Color _getErrorColor(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
case ApiErrorType.timeout:
|
||||
return AppTheme.warning;
|
||||
case ApiErrorType.authentication:
|
||||
case ApiErrorType.authorization:
|
||||
return AppTheme.error;
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
return AppTheme.warning;
|
||||
case ApiErrorType.server:
|
||||
return AppTheme.error;
|
||||
case ApiErrorType.rateLimit:
|
||||
return AppTheme.info;
|
||||
default:
|
||||
return AppTheme.error;
|
||||
}
|
||||
}
|
||||
return AppTheme.error;
|
||||
}
|
||||
|
||||
String _getErrorTitle(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
return 'Connection Problem';
|
||||
case ApiErrorType.timeout:
|
||||
return 'Request Timeout';
|
||||
case ApiErrorType.authentication:
|
||||
return 'Authentication Required';
|
||||
case ApiErrorType.authorization:
|
||||
return 'Access Denied';
|
||||
case ApiErrorType.validation:
|
||||
return 'Invalid Input';
|
||||
case ApiErrorType.badRequest:
|
||||
return 'Bad Request';
|
||||
case ApiErrorType.notFound:
|
||||
return 'Not Found';
|
||||
case ApiErrorType.server:
|
||||
return 'Server Error';
|
||||
case ApiErrorType.rateLimit:
|
||||
return 'Rate Limited';
|
||||
case ApiErrorType.cancelled:
|
||||
return 'Request Cancelled';
|
||||
case ApiErrorType.security:
|
||||
return 'Security Error';
|
||||
case ApiErrorType.unknown:
|
||||
return 'Unknown Error';
|
||||
}
|
||||
}
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
Duration _getSnackbarDuration(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
return const Duration(seconds: 6); // Longer for validation errors
|
||||
case ApiErrorType.rateLimit:
|
||||
return const Duration(seconds: 8); // Longer for rate limits
|
||||
default:
|
||||
return const Duration(seconds: 4);
|
||||
}
|
||||
}
|
||||
return const Duration(seconds: 4);
|
||||
}
|
||||
}
|
||||
|
||||
/// Global instance for easy access
|
||||
final enhancedErrorService = EnhancedErrorService();
|
||||
405
lib/core/error/error_parser.dart
Normal file
405
lib/core/error/error_parser.dart
Normal file
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_error.dart';
|
||||
|
||||
/// Comprehensive error response parser
|
||||
/// Handles various API error response formats and extracts structured information
|
||||
class ErrorParser {
|
||||
/// Parse general error response from API
|
||||
ParsedErrorResponse parseErrorResponse(dynamic responseData) {
|
||||
if (responseData == null) {
|
||||
return const ParsedErrorResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
return _parseErrorMap(responseData);
|
||||
} else if (responseData is String) {
|
||||
return _parseErrorString(responseData);
|
||||
} else if (responseData is List) {
|
||||
return _parseErrorList(responseData);
|
||||
} else {
|
||||
return ParsedErrorResponse(
|
||||
message: 'Unexpected error format',
|
||||
metadata: {'rawData': responseData.toString()},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ErrorParser: Error parsing response: $e');
|
||||
return ParsedErrorResponse(
|
||||
message: 'Failed to parse error response',
|
||||
metadata: {
|
||||
'parseError': e.toString(),
|
||||
'rawData': responseData.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse validation error (422) with field-specific errors
|
||||
ParsedErrorResponse parseValidationError(dynamic responseData) {
|
||||
final baseResult = parseErrorResponse(responseData);
|
||||
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
final fieldErrors = _extractFieldErrors(responseData);
|
||||
|
||||
return ParsedErrorResponse(
|
||||
message: baseResult.message ?? 'Validation failed',
|
||||
code: baseResult.code,
|
||||
errors: baseResult.errors,
|
||||
fieldErrors: fieldErrors,
|
||||
metadata: baseResult.metadata,
|
||||
);
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
/// Parse error response from a Map (most common format)
|
||||
ParsedErrorResponse _parseErrorMap(Map<String, dynamic> data) {
|
||||
final message = _extractMessage(data);
|
||||
final code = _extractCode(data);
|
||||
final errors = _extractGeneralErrors(data);
|
||||
final fieldErrors = _extractFieldErrors(data);
|
||||
final metadata = _extractMetadata(data);
|
||||
|
||||
return ParsedErrorResponse(
|
||||
message: message,
|
||||
code: code,
|
||||
errors: errors,
|
||||
fieldErrors: fieldErrors,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse error response from a String
|
||||
ParsedErrorResponse _parseErrorString(String data) {
|
||||
return ParsedErrorResponse(message: data, metadata: {'format': 'string'});
|
||||
}
|
||||
|
||||
/// Parse error response from a List
|
||||
ParsedErrorResponse _parseErrorList(List<dynamic> data) {
|
||||
final errors = <String>[];
|
||||
|
||||
for (final item in data) {
|
||||
if (item is String) {
|
||||
errors.add(item);
|
||||
} else if (item is Map<String, dynamic>) {
|
||||
final message = _extractMessage(item);
|
||||
if (message != null) {
|
||||
errors.add(message);
|
||||
}
|
||||
} else {
|
||||
errors.add(item.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedErrorResponse(
|
||||
message: errors.isNotEmpty ? errors.first : 'Multiple errors occurred',
|
||||
errors: errors,
|
||||
metadata: {'format': 'list', 'count': data.length},
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract error message from various possible fields
|
||||
String? _extractMessage(Map<String, dynamic> data) {
|
||||
// Common error message fields in order of preference
|
||||
const messageFields = [
|
||||
'message',
|
||||
'error',
|
||||
'detail',
|
||||
'description',
|
||||
'msg',
|
||||
'error_description',
|
||||
'title',
|
||||
'summary',
|
||||
];
|
||||
|
||||
for (final field in messageFields) {
|
||||
final value = data[field];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extract error code from response
|
||||
String? _extractCode(Map<String, dynamic> data) {
|
||||
const codeFields = [
|
||||
'code',
|
||||
'error_code',
|
||||
'errorCode',
|
||||
'type',
|
||||
'error_type',
|
||||
'errorType',
|
||||
];
|
||||
|
||||
for (final field in codeFields) {
|
||||
final value = data[field];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
} else if (value is int) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extract general error messages (non-field-specific)
|
||||
List<String> _extractGeneralErrors(Map<String, dynamic> data) {
|
||||
final errors = <String>[];
|
||||
|
||||
// Check for error arrays
|
||||
const errorArrayFields = ['errors', 'messages', 'details', 'issues'];
|
||||
|
||||
for (final field in errorArrayFields) {
|
||||
final value = data[field];
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
if (item is String && item.isNotEmpty) {
|
||||
errors.add(item);
|
||||
} else if (item is Map<String, dynamic>) {
|
||||
final message = _extractMessage(item);
|
||||
if (message != null) {
|
||||
errors.add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// Extract field-specific validation errors
|
||||
Map<String, List<String>> _extractFieldErrors(Map<String, dynamic> data) {
|
||||
final fieldErrors = <String, List<String>>{};
|
||||
|
||||
// Common patterns for field errors
|
||||
_extractFromFieldErrorsObject(data, fieldErrors);
|
||||
_extractFromValidationErrorsArray(data, fieldErrors);
|
||||
_extractFromDetailsObject(data, fieldErrors);
|
||||
_extractFromOpenAPIFormat(data, fieldErrors);
|
||||
|
||||
return fieldErrors;
|
||||
}
|
||||
|
||||
/// Extract from 'field_errors' or 'fieldErrors' object
|
||||
void _extractFromFieldErrorsObject(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
const fieldErrorFields = [
|
||||
'field_errors',
|
||||
'fieldErrors',
|
||||
'validation_errors',
|
||||
'validationErrors',
|
||||
'field_messages',
|
||||
'fieldMessages',
|
||||
];
|
||||
|
||||
for (final field in fieldErrorFields) {
|
||||
final value = data[field];
|
||||
if (value is Map<String, dynamic>) {
|
||||
for (final entry in value.entries) {
|
||||
final fieldName = entry.key;
|
||||
final fieldValue = entry.value;
|
||||
|
||||
final errors = <String>[];
|
||||
if (fieldValue is String) {
|
||||
errors.add(fieldValue);
|
||||
} else if (fieldValue is List) {
|
||||
for (final item in fieldValue) {
|
||||
if (item is String) {
|
||||
errors.add(item);
|
||||
} else {
|
||||
errors.add(item.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
fieldErrors[fieldName] = errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract from validation errors array format
|
||||
void _extractFromValidationErrorsArray(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
const arrayFields = ['errors', 'details', 'issues'];
|
||||
|
||||
for (final field in arrayFields) {
|
||||
final value = data[field];
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final field =
|
||||
item['field'] as String? ??
|
||||
item['property'] as String? ??
|
||||
item['path'] as String?;
|
||||
final message = _extractMessage(item);
|
||||
|
||||
if (field != null && message != null) {
|
||||
fieldErrors.putIfAbsent(field, () => []).add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract from 'details' object (common in some APIs)
|
||||
void _extractFromDetailsObject(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
final details = data['details'];
|
||||
if (details is Map<String, dynamic>) {
|
||||
for (final entry in details.entries) {
|
||||
final fieldName = entry.key;
|
||||
final fieldValue = entry.value;
|
||||
|
||||
if (fieldValue is String) {
|
||||
fieldErrors.putIfAbsent(fieldName, () => []).add(fieldValue);
|
||||
} else if (fieldValue is List) {
|
||||
final errors = fieldValue
|
||||
.map((e) => e.toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (errors.isNotEmpty) {
|
||||
fieldErrors[fieldName] = errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract from OpenAPI specification error format
|
||||
void _extractFromOpenAPIFormat(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
// OpenAPI validation errors often come in this format
|
||||
final detail = data['detail'];
|
||||
if (detail is List) {
|
||||
for (final item in detail) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final loc = item['loc'];
|
||||
final msg = item['msg'] as String?;
|
||||
|
||||
if (loc is List && loc.isNotEmpty && msg != null) {
|
||||
// Location can be like ['body', 'fieldName'] or ['fieldName']
|
||||
final fieldName = loc.last.toString();
|
||||
fieldErrors.putIfAbsent(fieldName, () => []).add(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract additional metadata from error response
|
||||
Map<String, dynamic> _extractMetadata(Map<String, dynamic> data) {
|
||||
final metadata = <String, dynamic>{};
|
||||
|
||||
// Common metadata fields
|
||||
const metadataFields = [
|
||||
'timestamp',
|
||||
'request_id',
|
||||
'requestId',
|
||||
'trace_id',
|
||||
'traceId',
|
||||
'correlation_id',
|
||||
'correlationId',
|
||||
'instance',
|
||||
'path',
|
||||
'method',
|
||||
'status',
|
||||
'documentation',
|
||||
'help',
|
||||
'support',
|
||||
];
|
||||
|
||||
for (final field in metadataFields) {
|
||||
final value = data[field];
|
||||
if (value != null) {
|
||||
metadata[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Include any unrecognized fields as metadata
|
||||
final recognizedFields = {
|
||||
'message',
|
||||
'error',
|
||||
'detail',
|
||||
'description',
|
||||
'msg',
|
||||
'error_description',
|
||||
'title',
|
||||
'summary',
|
||||
'code',
|
||||
'error_code',
|
||||
'errorCode',
|
||||
'type',
|
||||
'error_type',
|
||||
'errorType',
|
||||
'errors',
|
||||
'messages',
|
||||
'details',
|
||||
'issues',
|
||||
'field_errors',
|
||||
'fieldErrors',
|
||||
'validation_errors',
|
||||
'validationErrors',
|
||||
'field_messages',
|
||||
'fieldMessages',
|
||||
...metadataFields,
|
||||
};
|
||||
|
||||
for (final entry in data.entries) {
|
||||
if (!recognizedFields.contains(entry.key)) {
|
||||
metadata[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// Convert field name from API format to user-friendly format
|
||||
String formatFieldName(String fieldName) {
|
||||
// Convert snake_case to human readable
|
||||
if (fieldName.contains('_')) {
|
||||
return fieldName
|
||||
.split('_')
|
||||
.map(
|
||||
(word) =>
|
||||
word.isEmpty ? word : word[0].toUpperCase() + word.substring(1),
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Convert camelCase to human readable
|
||||
return fieldName
|
||||
.replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(1)}')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Get user-friendly error message for a field
|
||||
String formatFieldError(String fieldName, String error) {
|
||||
final friendlyFieldName = formatFieldName(fieldName);
|
||||
|
||||
// If error already mentions the field, don't duplicate it
|
||||
if (error.toLowerCase().contains(fieldName.toLowerCase()) ||
|
||||
error.toLowerCase().contains(friendlyFieldName.toLowerCase())) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return '$friendlyFieldName: $error';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user