import 'package:dio/dio.dart'; import '../../models/pb/errors/errors.pb.dart' as kerrors; enum ApiExceptionKind { transport, http, backend, decode, cancelled } /// The single network failure shape exposed to application code. class ApiException implements Exception { const ApiException({ required this.kind, this.httpStatus, this.backendCode, this.reason, this.serverMessage, this.requestOptions, this.cause, }); final ApiExceptionKind kind; final int? httpStatus; final int? backendCode; final String? reason; final String? serverMessage; final RequestOptions? requestOptions; final Object? cause; bool get isUnauthorized => httpStatus == 401 || const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase()); bool get isRetryable { if (kind == ApiExceptionKind.transport) return true; final status = httpStatus; return status == 408 || status == 429 || (status != null && status >= 500); } int? get sessionGeneration => requestOptions?.extra[authSessionGenerationExtra] as int?; static ApiException from(Object error, {StackTrace? stackTrace}) { if (error is ApiException) return error; if (error is DioException) { if (error.error is ApiException) return error.error! as ApiException; return fromDio(error); } if (error is FormatException) { return ApiException(kind: ApiExceptionKind.decode, cause: error); } return ApiException(kind: ApiExceptionKind.decode, cause: error); } static ApiException fromDio(DioException error) { final response = error.response; final status = response?.statusCode; final kratos = parseKratosError(response?.data); if (error.type == DioExceptionType.cancel) { return ApiException( kind: ApiExceptionKind.cancelled, httpStatus: status, requestOptions: error.requestOptions, cause: error, ); } final kind = kratos != null ? ApiExceptionKind.backend : status != null ? ApiExceptionKind.http : ApiExceptionKind.transport; return ApiException( kind: kind, httpStatus: status, backendCode: kratos?.code, reason: _normalized(kratos?.reason), serverMessage: _normalized(kratos?.message), requestOptions: error.requestOptions, cause: error, ); } @override String toString() => 'ApiException($kind, status: $httpStatus, code: $backendCode, ' 'reason: $reason, message: $serverMessage)'; } const authSessionGenerationExtra = 'authSessionGeneration'; kerrors.Status? parseKratosError(Object? raw) { if (raw is List && raw.isNotEmpty) { try { final status = kerrors.Status.fromBuffer(raw); if (status.code != 0 || status.reason.isNotEmpty) return status; } catch (_) { return null; } } if (raw is Map) { final code = raw['code']; final reason = '${raw['reason'] ?? ''}'; final message = '${raw['message'] ?? ''}'; if (code is num || reason.isNotEmpty || message.isNotEmpty) { return kerrors.Status( code: code is num ? code.toInt() : 0, reason: reason, message: message, ); } } return null; } String? _normalized(String? value) { final result = value?.trim() ?? ''; return result.isEmpty ? null : result; } class ApiExceptionInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { final apiException = ApiException.fromDio(err); handler.next(err.copyWith(error: apiException)); } }