feat: enhance API error handling and response structure; introduce ApiException for V2 error codes

This commit is contained in:
Achintha Isuru
2026-03-17 14:15:26 -04:00
parent d6ddb7829e
commit b6a655a261
5 changed files with 116 additions and 52 deletions

View File

@@ -5,7 +5,9 @@ import 'feature_gate.dart';
/// A service that handles HTTP communication using the [Dio] client. /// A service that handles HTTP communication using the [Dio] client.
/// ///
/// Integrates [FeatureGate] to validate endpoint scopes before each request. /// Integrates [FeatureGate] for scope validation and throws typed domain
/// exceptions ([ApiException], [NetworkException], [ServerException]) on
/// error responses so repositories never receive silent failures.
class ApiService implements BaseApiService { class ApiService implements BaseApiService {
/// Creates an [ApiService] with the given [Dio] instance. /// Creates an [ApiService] with the given [Dio] instance.
ApiService(this._dio); ApiService(this._dio);
@@ -27,7 +29,7 @@ class ApiService implements BaseApiService {
); );
return _handleResponse(response); return _handleResponse(response);
} on DioException catch (e) { } on DioException catch (e) {
return _handleError(e); throw _mapDioException(e);
} }
} }
@@ -47,7 +49,7 @@ class ApiService implements BaseApiService {
); );
return _handleResponse(response); return _handleResponse(response);
} on DioException catch (e) { } on DioException catch (e) {
return _handleError(e); throw _mapDioException(e);
} }
} }
@@ -67,7 +69,7 @@ class ApiService implements BaseApiService {
); );
return _handleResponse(response); return _handleResponse(response);
} on DioException catch (e) { } on DioException catch (e) {
return _handleError(e); throw _mapDioException(e);
} }
} }
@@ -87,7 +89,7 @@ class ApiService implements BaseApiService {
); );
return _handleResponse(response); return _handleResponse(response);
} on DioException catch (e) { } on DioException catch (e) {
return _handleError(e); throw _mapDioException(e);
} }
} }
@@ -107,7 +109,7 @@ class ApiService implements BaseApiService {
); );
return _handleResponse(response); return _handleResponse(response);
} on DioException catch (e) { } on DioException catch (e) {
return _handleError(e); throw _mapDioException(e);
} }
} }
@@ -117,43 +119,63 @@ class ApiService implements BaseApiService {
/// Extracts [ApiResponse] from a successful [Response]. /// Extracts [ApiResponse] from a successful [Response].
ApiResponse _handleResponse(Response<dynamic> response) { ApiResponse _handleResponse(Response<dynamic> response) {
final dynamic body = response.data;
final String message = body is Map<String, dynamic>
? body['message']?.toString() ?? 'Success'
: 'Success';
return ApiResponse( return ApiResponse(
code: response.statusCode?.toString() ?? '200', code: response.statusCode?.toString() ?? '200',
message: response.data['message']?.toString() ?? 'Success', message: message,
data: response.data, data: body,
); );
} }
/// Extracts [ApiResponse] from a [DioException]. /// Maps a [DioException] to a typed domain exception.
/// ///
/// Supports both legacy error format and V2 API error envelope /// The V2 API error envelope is `{ code, message, details, requestId }`.
/// (`{ code, message, details, requestId }`). /// This method parses it and throws the appropriate [AppException] subclass
ApiResponse _handleError(DioException e) { /// so that `BlocErrorHandler` can translate it for the user.
AppException _mapDioException(DioException e) {
// Network-level failures (no response from server).
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.connectionError) {
return NetworkException(technicalMessage: e.message);
}
final int? statusCode = e.response?.statusCode;
// Parse V2 error envelope if available.
if (e.response?.data is Map<String, dynamic>) { if (e.response?.data is Map<String, dynamic>) {
final Map<String, dynamic> body = final Map<String, dynamic> body =
e.response!.data as Map<String, dynamic>; e.response!.data as Map<String, dynamic>;
return ApiResponse(
code: final String apiCode =
body['code']?.toString() ?? body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN';
e.response?.statusCode?.toString() ?? final String apiMessage =
'error', body['message']?.toString() ?? e.message ?? 'An error occurred';
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
data: body['data'] ?? body['details'], // Map well-known codes to specific exceptions.
errors: _parseErrors(body['errors']), if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) {
return NotAuthenticatedException(technicalMessage: apiMessage);
}
return ApiException(
apiCode: apiCode,
apiMessage: apiMessage,
statusCode: statusCode,
details: body['details'],
technicalMessage: '$apiCode: $apiMessage',
); );
} }
return ApiResponse(
code: e.response?.statusCode?.toString() ?? 'error',
message: e.message ?? 'Unknown error',
errors: <String, dynamic>{'exception': e.type.toString()},
);
}
/// Helper to parse the errors map from various possible formats. // Server error without a parseable body.
Map<String, dynamic> _parseErrors(dynamic errors) { if (statusCode != null && statusCode >= 500) {
if (errors is Map) { return ServerException(technicalMessage: e.message);
return Map<String, dynamic>.from(errors);
} }
return const <String, dynamic>{};
return UnknownException(technicalMessage: e.message);
} }
} }

View File

@@ -8,7 +8,7 @@ class ApiResponse {
this.errors = const <String, dynamic>{}, this.errors = const <String, dynamic>{},
}); });
/// The response code (e.g., '200', '404', or custom error code). /// The response code (e.g., '200', '404', or V2 error code like 'VALIDATION_ERROR').
final String code; final String code;
/// A descriptive message about the response. /// A descriptive message about the response.
@@ -19,4 +19,13 @@ class ApiResponse {
/// A map of field-specific error messages, if any. /// A map of field-specific error messages, if any.
final Map<String, dynamic> errors; final Map<String, dynamic> errors;
/// Whether the response indicates success (HTTP 2xx).
bool get isSuccess {
final int? statusCode = int.tryParse(code);
return statusCode != null && statusCode >= 200 && statusCode < 300;
}
/// Whether the response indicates failure.
bool get isFailure => !isSuccess;
} }

View File

@@ -1,3 +1,4 @@
import '../../../exceptions/app_exception.dart';
import 'api_response.dart'; import 'api_response.dart';
import 'base_api_service.dart'; import 'base_api_service.dart';
@@ -14,10 +15,13 @@ abstract class BaseCoreService {
/// Standardized wrapper to execute API actions. /// Standardized wrapper to execute API actions.
/// ///
/// This handles generic error normalization for unexpected non-HTTP errors. /// Rethrows [AppException] subclasses (domain errors) directly.
/// Wraps unexpected non-HTTP errors into an error [ApiResponse].
Future<ApiResponse> action(Future<ApiResponse> Function() execution) async { Future<ApiResponse> action(Future<ApiResponse> Function() execution) async {
try { try {
return await execution(); return await execution();
} on AppException {
rethrow;
} catch (e) { } catch (e) {
return ApiResponse( return ApiResponse(
code: 'CORE_INTERNAL_ERROR', code: 'CORE_INTERNAL_ERROR',

View File

@@ -286,6 +286,54 @@ class NoActiveShiftException extends ShiftException {
// NETWORK/GENERIC EXCEPTIONS // NETWORK/GENERIC EXCEPTIONS
// ============================================================ // ============================================================
// ============================================================
// API EXCEPTIONS (mapped from V2 error envelope codes)
// ============================================================
/// Thrown when the V2 API returns a non-success response.
///
/// Carries the full error envelope so callers can inspect details.
class ApiException extends AppException {
/// Creates an [ApiException].
const ApiException({
required String apiCode,
required String apiMessage,
this.statusCode,
this.details,
super.technicalMessage,
}) : super(code: apiCode);
/// The HTTP status code (e.g. 400, 404, 500).
final int? statusCode;
/// The V2 API error code string (e.g. 'VALIDATION_ERROR').
String get apiCode => code;
/// Optional details from the error envelope.
final dynamic details;
@override
String get messageKey {
switch (code) {
case 'VALIDATION_ERROR':
return 'errors.generic.validation_error';
case 'NOT_FOUND':
return 'errors.generic.not_found';
case 'FORBIDDEN':
return 'errors.generic.forbidden';
case 'UNAUTHENTICATED':
return 'errors.auth.not_authenticated';
case 'CONFLICT':
return 'errors.generic.conflict';
default:
if (statusCode != null && statusCode! >= 500) {
return 'errors.generic.server_error';
}
return 'errors.generic.unknown';
}
}
}
/// Thrown when there is no network connection. /// Thrown when there is no network connection.
class NetworkException extends AppException { class NetworkException extends AppException {
const NetworkException({super.technicalMessage}) const NetworkException({super.technicalMessage})

View File

@@ -55,20 +55,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
final Map<String, dynamic> body = final Map<String, dynamic> body =
response.data as Map<String, dynamic>; response.data as Map<String, dynamic>;
// Check for V2 error responses.
if (response.code != '200' && response.code != '201') {
final String errorCode = body['code']?.toString() ?? response.code;
if (errorCode == 'INVALID_CREDENTIALS' ||
response.message.contains('INVALID_LOGIN_CREDENTIALS')) {
throw InvalidCredentialsException(
technicalMessage: response.message,
);
}
throw SignInFailedException(
technicalMessage: '$errorCode: ${response.message}',
);
}
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens // Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so // to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state. // email/password sign-in establishes the local Firebase Auth state.
@@ -115,12 +101,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
}, },
); );
// Check for V2 error responses.
final Map<String, dynamic> body = response.data as Map<String, dynamic>; final Map<String, dynamic> body = response.data as Map<String, dynamic>;
if (response.code != '201' && response.code != '200') {
final String errorCode = body['code']?.toString() ?? response.code;
_throwSignUpError(errorCode, response.message);
}
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works // Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase // for subsequent requests. The V2 API already created the Firebase