loading state and error handling refactor
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
abstract class AppException implements Exception {
|
||||
final String userMessage;
|
||||
final String? technicalDetails;
|
||||
final bool allowRetry;
|
||||
|
||||
const AppException({
|
||||
required this.userMessage,
|
||||
this.technicalDetails,
|
||||
this.allowRetry = true,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => technicalDetails == null
|
||||
? '$runtimeType: $userMessage'
|
||||
: '$runtimeType: $userMessage ($technicalDetails)';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
class AuthException extends AppException {
|
||||
final int statusCode;
|
||||
|
||||
const AuthException({
|
||||
required this.statusCode,
|
||||
required super.userMessage,
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: false);
|
||||
|
||||
factory AuthException.unauthorized({String? technicalDetails}) => AuthException(
|
||||
statusCode: 401,
|
||||
userMessage: 'Bitte melde dich erneut an, um fortzufahren.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
|
||||
factory AuthException.forbidden({String? technicalDetails}) => AuthException(
|
||||
statusCode: 403,
|
||||
userMessage: 'Du hast keine Berechtigung für diese Aktion.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../apiError.dart';
|
||||
import '../marianumcloud/talk/talkError.dart';
|
||||
import '../webuntis/webuntisError.dart';
|
||||
import 'app_exception.dart';
|
||||
import 'network_exception.dart';
|
||||
import 'parse_exception.dart';
|
||||
import 'talk_exception.dart';
|
||||
import 'webuntis_exception.dart';
|
||||
|
||||
const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
|
||||
|
||||
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||
if (error == null) return fallback;
|
||||
if (error is AppException) return error.userMessage;
|
||||
|
||||
if (error is TalkError) return TalkException(error).userMessage;
|
||||
if (error is WebuntisError) return WebuntisException(error).userMessage;
|
||||
|
||||
if (error is SocketException) {
|
||||
return const NetworkException().userMessage;
|
||||
}
|
||||
if (error is TimeoutException) {
|
||||
return NetworkException.timeout().userMessage;
|
||||
}
|
||||
if (error is http.ClientException) {
|
||||
return const NetworkException().userMessage;
|
||||
}
|
||||
if (error is HandshakeException) {
|
||||
return 'Sichere Verbindung konnte nicht hergestellt werden.';
|
||||
}
|
||||
if (error is FormatException) {
|
||||
return const ParseException().userMessage;
|
||||
}
|
||||
if (error is ApiError) {
|
||||
return _stripDioPrefix(error.message);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String? errorToTechnicalDetails(Object? error) {
|
||||
if (error == null) return null;
|
||||
if (error is AppException) return error.technicalDetails ?? error.toString();
|
||||
if (error is TalkError) return TalkException(error).technicalDetails;
|
||||
if (error is WebuntisError) return WebuntisException(error).technicalDetails;
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
bool errorAllowsRetry(Object? error) {
|
||||
if (error == null) return true;
|
||||
if (error is AppException) return error.allowRetry;
|
||||
return true;
|
||||
}
|
||||
|
||||
String _stripDioPrefix(String raw) {
|
||||
// ApiError messages embed full request URIs; only surface the first line.
|
||||
final firstLine = raw.split('\n').first.trim();
|
||||
return firstLine.isEmpty ? _defaultFallback : firstLine;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException({
|
||||
super.userMessage = 'Keine Internetverbindung. Bitte prüfe dein Netzwerk und versuche es erneut.',
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: true);
|
||||
|
||||
factory NetworkException.timeout({String? technicalDetails}) => NetworkException(
|
||||
userMessage: 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
class NotFoundException extends AppException {
|
||||
const NotFoundException({
|
||||
super.userMessage = 'Der angeforderte Eintrag wurde nicht gefunden.',
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: false);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
class ParseException extends AppException {
|
||||
const ParseException({
|
||||
super.userMessage = 'Die Antwort des Servers konnte nicht gelesen werden.',
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: true);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
class ServerException extends AppException {
|
||||
final int statusCode;
|
||||
|
||||
ServerException({
|
||||
required this.statusCode,
|
||||
String? userMessage,
|
||||
super.technicalDetails,
|
||||
}) : super(
|
||||
userMessage: userMessage ?? 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.',
|
||||
allowRetry: true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../marianumcloud/talk/talkError.dart';
|
||||
import 'app_exception.dart';
|
||||
|
||||
class TalkException extends AppException {
|
||||
final TalkError source;
|
||||
|
||||
TalkException(this.source)
|
||||
: super(
|
||||
userMessage: _mapMessage(source),
|
||||
technicalDetails: 'Talk ${source.status} (${source.code}): ${source.message}',
|
||||
allowRetry: source.code >= 500,
|
||||
);
|
||||
|
||||
static String _mapMessage(TalkError e) {
|
||||
switch (e.code) {
|
||||
case 401:
|
||||
return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.';
|
||||
case 403:
|
||||
return 'Du hast keine Berechtigung für diese Talk-Aktion.';
|
||||
case 404:
|
||||
return 'Dieser Chat existiert nicht oder wurde entfernt.';
|
||||
case 412:
|
||||
return 'Diese Aktion ist im aktuellen Chat-Zustand nicht erlaubt.';
|
||||
case 429:
|
||||
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
|
||||
default:
|
||||
if (e.code >= 500) {
|
||||
return 'Talk-Server hat gerade Probleme (${e.code}).';
|
||||
}
|
||||
return e.message.isNotEmpty ? e.message : 'Talk meldet einen Fehler (${e.code}).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../webuntis/webuntisError.dart';
|
||||
import 'app_exception.dart';
|
||||
|
||||
class WebuntisException extends AppException {
|
||||
final WebuntisError source;
|
||||
|
||||
WebuntisException(this.source)
|
||||
: super(
|
||||
userMessage: _mapMessage(source),
|
||||
technicalDetails: 'WebUntis (${source.code}): ${source.message}',
|
||||
allowRetry: true,
|
||||
);
|
||||
|
||||
static String _mapMessage(WebuntisError e) {
|
||||
switch (e.code) {
|
||||
case -8504:
|
||||
case -8502:
|
||||
return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.';
|
||||
case -8520:
|
||||
return 'Bitte melde dich erneut an.';
|
||||
case -7004:
|
||||
return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.';
|
||||
case -32601:
|
||||
return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.';
|
||||
default:
|
||||
return e.message.isNotEmpty
|
||||
? 'WebUntis: ${e.message}'
|
||||
: 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../apiError.dart';
|
||||
import '../../apiParams.dart';
|
||||
import '../../apiRequest.dart';
|
||||
import '../../apiResponse.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
enum TalkApiMethod {
|
||||
@@ -32,16 +38,32 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
final endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters);
|
||||
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
||||
|
||||
http.Response? data;
|
||||
final http.Response data;
|
||||
try {
|
||||
data = await request(endpoint, body, mergedHeaders);
|
||||
if (data == null) throw Exception('No response Data');
|
||||
if (data.statusCode >= 400 || data.statusCode < 200) {
|
||||
throw Exception("Response status code '${data.statusCode}' might indicate an error");
|
||||
final raw = await request(endpoint, body, mergedHeaders);
|
||||
if (raw == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||
technicalDetails: 'Talk request returned null',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}');
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
}
|
||||
|
||||
final status = data.statusCode;
|
||||
if (status < 200 || status >= 300) {
|
||||
final detail = 'Talk $endpoint -> HTTP $status';
|
||||
log(detail);
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -49,10 +71,7 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
assembled?.headers = data.headers;
|
||||
return assembled;
|
||||
} catch (e) {
|
||||
final message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()}'
|
||||
' response with request body: $body and request headers: $mergedHeaders';
|
||||
log(message);
|
||||
throw Exception(message);
|
||||
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import '../apiError.dart';
|
||||
|
||||
import '../apiRequest.dart';
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import '../errors/server_exception.dart';
|
||||
|
||||
abstract class MhslApi<T> extends ApiRequest {
|
||||
String subpath;
|
||||
@@ -15,18 +20,38 @@ abstract class MhslApi<T> extends ApiRequest {
|
||||
T assemble(String raw);
|
||||
|
||||
Future<T> run() async {
|
||||
var endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath');
|
||||
final endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath');
|
||||
|
||||
var data = await request(endpoint);
|
||||
if(data == null) {
|
||||
throw ApiError('Request could not be dispatched!');
|
||||
final http.Response data;
|
||||
try {
|
||||
final raw = await request(endpoint);
|
||||
if (raw == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
|
||||
technicalDetails: 'mhsl request returned null',
|
||||
);
|
||||
}
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
||||
}
|
||||
|
||||
if(data.statusCode > 299) {
|
||||
throw ApiError('Non 200 Status code from mhsl services: $subpath: ${data.statusCode}');
|
||||
if (data.statusCode > 299) {
|
||||
throw ServerException(
|
||||
statusCode: data.statusCode,
|
||||
technicalDetails: 'mhsl $subpath HTTP ${data.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
return assemble(utf8.decode(data.bodyBytes));
|
||||
try {
|
||||
return assemble(utf8.decode(data.bodyBytes));
|
||||
} catch (e) {
|
||||
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String dateTimeToJson(DateTime time) => Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../model/endpoint_data.dart';
|
||||
import '../apiParams.dart';
|
||||
import '../apiRequest.dart';
|
||||
import '../apiResponse.dart';
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import 'queries/authenticate/authenticate.dart';
|
||||
import 'webuntisError.dart';
|
||||
|
||||
@@ -29,10 +34,15 @@ abstract class WebuntisApi extends ApiRequest {
|
||||
var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'});
|
||||
response = data;
|
||||
|
||||
dynamic jsonData = jsonDecode(data.body);
|
||||
final dynamic jsonData;
|
||||
try {
|
||||
jsonData = jsonDecode(data.body);
|
||||
} on FormatException catch (e) {
|
||||
throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}');
|
||||
}
|
||||
if(jsonData['error'] != null) {
|
||||
if(jsonData['error']['code'] == -8520) {
|
||||
if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', 1);
|
||||
if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520);
|
||||
await Authenticate.createSession();
|
||||
return await this.query(untis, retry: true);
|
||||
} else {
|
||||
@@ -51,10 +61,16 @@ abstract class WebuntisApi extends ApiRequest {
|
||||
|
||||
String _body() => genericParam == null ? '{}' : jsonEncode(genericParam);
|
||||
|
||||
Future<http.Response> post(String data, Map<String, String>? headers) async => await http
|
||||
.post(endpoint, body: data, headers: headers)
|
||||
.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () => throw WebuntisError('Timeout', 1)
|
||||
);
|
||||
Future<http.Response> post(String data, Map<String, String>? headers) async {
|
||||
try {
|
||||
return await http.post(endpoint, body: data, headers: headers).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () => throw NetworkException.timeout(technicalDetails: 'WebUntis $method timed out after 10s'),
|
||||
);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user