apskel-pos-flutter-v2/lib/common/api/interceptors/idempotency_interceptor.dart

105 lines
3.2 KiB
Dart

import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
/// Interceptor yang secara otomatis menambahkan header X-Idempotency-Key
/// untuk endpoint-endpoint kritis yang memerlukan idempotency.
///
/// Key sebaiknya di-generate di level BLoC/Cubit saat user trigger action,
/// lalu dikirim via headers. Interceptor ini hanya berfungsi sebagai fallback
/// jika key belum di-set.
///
/// Jika response 409 dengan error code `request_in_progress`, interceptor
/// akan retry setelah delay 1-2 detik.
class IdempotencyInterceptor extends Interceptor {
static const _headerKey = 'X-Idempotency-Key';
static const _replayHeader = 'X-Idempotent-Replay';
// Endpoints that require idempotency keys (exact match)
static const _idempotentPaths = [
'/api/v1/payments',
'/api/v1/orders/void',
];
// Endpoints that require idempotency keys (pattern match)
static final _idempotentPathPatterns = [
RegExp(r'/api/v1/orders/.+/add-items$'),
RegExp(r'/api/v1/orders/.+/refund$'),
RegExp(r'/api/v1/payments/.+/refund$'),
];
static const _maxRetries = 3;
static const _retryDelay = Duration(seconds: 2);
final Dio _dio;
IdempotencyInterceptor(this._dio);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (options.method == 'POST' && _requiresIdempotencyKey(options.path)) {
// Gunakan key yang sudah di-set dari BLoC, atau generate baru sebagai fallback
options.headers[_headerKey] ??= const Uuid().v4();
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Log jika response adalah replay dari request sebelumnya
final isReplay = response.headers.value(_replayHeader);
if (isReplay == 'true') {
// Response ini adalah hasil dari request pertama yang sudah diproses.
// Treat as success — tidak perlu perlakuan khusus.
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final response = err.response;
final options = err.requestOptions;
if (response == null) {
handler.next(err);
return;
}
// Handle 409 Conflict with request_in_progress
if (response.statusCode == 409 &&
_isRequestInProgress(response.data) &&
_requiresIdempotencyKey(options.path)) {
final retryCount = options.extra['_idempotency_retry_count'] ?? 0;
if (retryCount < _maxRetries) {
await Future.delayed(_retryDelay);
options.extra['_idempotency_retry_count'] = retryCount + 1;
try {
final retryResponse = await _dio.fetch(options);
handler.resolve(retryResponse);
} on DioException catch (e) {
handler.next(e);
}
return;
}
}
handler.next(err);
}
bool _requiresIdempotencyKey(String path) {
if (_idempotentPaths.any((p) => path.endsWith(p))) return true;
if (_idempotentPathPatterns.any((r) => r.hasMatch(path))) return true;
return false;
}
bool _isRequestInProgress(dynamic data) {
if (data is Map<String, dynamic>) {
final errorCode = data['error_code'] ?? data['code'] ?? '';
return errorCode == 'request_in_progress';
}
return false;
}
}