feat(stripe_api): surface HTTP status and error code on failures (#3839)
This commit is contained in:
@@ -36,7 +36,10 @@ class StripeApi {
|
||||
|
||||
final response = await _client.get(uri, headers: _authHeaders);
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception('Failed to retrieve customer with id $customerId');
|
||||
throw StripeApiException.fromResponse(
|
||||
response,
|
||||
message: 'Failed to retrieve customer with id $customerId',
|
||||
);
|
||||
}
|
||||
|
||||
return StripeCustomer.fromJson(
|
||||
@@ -55,8 +58,9 @@ class StripeApi {
|
||||
|
||||
final response = await _client.get(uri, headers: _authHeaders);
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Failed to retrieve subscription with id $subscriptionId',
|
||||
throw StripeApiException.fromResponse(
|
||||
response,
|
||||
message: 'Failed to retrieve subscription with id $subscriptionId',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,10 +100,14 @@ class StripeApi {
|
||||
);
|
||||
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception('''
|
||||
throw StripeApiException.fromResponse(
|
||||
response,
|
||||
message:
|
||||
'''
|
||||
Failed to report $value for customer $customerId. Error:
|
||||
${response.body}
|
||||
''');
|
||||
''',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +154,14 @@ ${response.body}
|
||||
|
||||
final response = await _client.get(uri, headers: _authHeaders);
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception('''
|
||||
throw StripeApiException.fromResponse(
|
||||
response,
|
||||
message:
|
||||
'''
|
||||
Failed to get paged response from $path with params $queryParameters. Error:
|
||||
${response.body}
|
||||
''');
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
final pagedResponse = PagedResponse.fromJson(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// {@template stripe_api_exception}
|
||||
/// An exception thrown when a request to the Stripe API returns a non-success
|
||||
/// response.
|
||||
///
|
||||
/// Carries the HTTP [statusCode] of the failed response so callers can
|
||||
/// distinguish, for example, a missing resource (`404`) from rate limiting
|
||||
/// (`429`). When the response body is a Stripe error object, the
|
||||
/// machine-readable [code] (e.g. `resource_missing`, `rate_limit`) is also
|
||||
/// surfaced.
|
||||
///
|
||||
/// See https://docs.stripe.com/api/errors.
|
||||
/// {@endtemplate}
|
||||
class StripeApiException implements Exception {
|
||||
/// {@macro stripe_api_exception}
|
||||
StripeApiException({
|
||||
required this.statusCode,
|
||||
required this.message,
|
||||
this.code,
|
||||
});
|
||||
|
||||
/// Builds a [StripeApiException] from a failed [response].
|
||||
///
|
||||
/// [message] is a human-readable description supplied by the caller and is
|
||||
/// preserved verbatim. The Stripe error [code] is parsed from the response
|
||||
/// body when it is a JSON error object.
|
||||
///
|
||||
/// Never throws: a non-JSON or unexpected body (e.g. an HTML gateway error)
|
||||
/// simply leaves [code] null.
|
||||
factory StripeApiException.fromResponse(
|
||||
http.Response response, {
|
||||
required String message,
|
||||
}) {
|
||||
String? code;
|
||||
try {
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final error = decoded['error'];
|
||||
if (error is Map<String, dynamic> && error['code'] is String) {
|
||||
code = error['code'] as String;
|
||||
}
|
||||
}
|
||||
} on Object catch (_) {
|
||||
// Non-JSON or unexpected body; the status code is still meaningful.
|
||||
}
|
||||
return StripeApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: message,
|
||||
code: code,
|
||||
);
|
||||
}
|
||||
|
||||
/// The HTTP status code of the failed response.
|
||||
final int statusCode;
|
||||
|
||||
/// The Stripe machine-readable error code (e.g. `resource_missing`), when the
|
||||
/// response body was a Stripe error object; otherwise null.
|
||||
final String? code;
|
||||
|
||||
/// A human-readable description of the failure.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'StripeApiException($statusCode${code == null ? '' : ', $code'}): '
|
||||
'$message';
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/models/models.dart';
|
||||
export 'src/stripe_api.dart';
|
||||
export 'src/stripe_api_exception.dart';
|
||||
|
||||
@@ -130,6 +130,32 @@ void main() {
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('throws StripeApiException carrying status and code on 404', () {
|
||||
when(
|
||||
() => httpClient.get(uri, headers: any(named: 'headers')),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'error': {
|
||||
'type': 'invalid_request_error',
|
||||
'code': 'resource_missing',
|
||||
'message': "No such customer: 'cus_123'",
|
||||
},
|
||||
}),
|
||||
HttpStatus.notFound,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => stripeApi.fetchCustomer(customerId: 'cus_123'),
|
||||
throwsA(
|
||||
isA<StripeApiException>()
|
||||
.having((e) => e.statusCode, 'statusCode', HttpStatus.notFound)
|
||||
.having((e) => e.code, 'code', 'resource_missing'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns a customer on successful request', () async {
|
||||
when(
|
||||
() => httpClient.get(uri, headers: any(named: 'headers')),
|
||||
@@ -171,6 +197,36 @@ void main() {
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('throws StripeApiException carrying status 429 on rate limit', () {
|
||||
when(
|
||||
() => httpClient.get(uri, headers: any(named: 'headers')),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'error': {
|
||||
'type': 'api_error',
|
||||
'code': 'rate_limit',
|
||||
'message': 'Too many requests',
|
||||
},
|
||||
}),
|
||||
HttpStatus.tooManyRequests,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => stripeApi.fetchSubscription(subscriptionId: subscriptionId),
|
||||
throwsA(
|
||||
isA<StripeApiException>()
|
||||
.having(
|
||||
(e) => e.statusCode,
|
||||
'statusCode',
|
||||
HttpStatus.tooManyRequests,
|
||||
)
|
||||
.having((e) => e.code, 'code', 'rate_limit'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns a subscription on successful request', () async {
|
||||
when(
|
||||
() => httpClient.get(uri, headers: any(named: 'headers')),
|
||||
@@ -409,4 +465,58 @@ void main() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group(StripeApiException, () {
|
||||
test('fromResponse parses the Stripe error code from a JSON body', () {
|
||||
final exception = StripeApiException.fromResponse(
|
||||
http.Response(
|
||||
jsonEncode({
|
||||
'error': {
|
||||
'code': 'resource_missing',
|
||||
'message': 'No such customer',
|
||||
},
|
||||
}),
|
||||
HttpStatus.notFound,
|
||||
),
|
||||
message: 'fallback',
|
||||
);
|
||||
|
||||
expect(exception.statusCode, HttpStatus.notFound);
|
||||
expect(exception.code, 'resource_missing');
|
||||
expect(exception.message, 'fallback');
|
||||
});
|
||||
|
||||
test('fromResponse leaves code null on a non-JSON body', () {
|
||||
final exception = StripeApiException.fromResponse(
|
||||
http.Response('<html>502 Bad Gateway</html>', HttpStatus.badGateway),
|
||||
message: 'fallback',
|
||||
);
|
||||
|
||||
expect(exception.statusCode, HttpStatus.badGateway);
|
||||
expect(exception.code, isNull);
|
||||
expect(exception.message, 'fallback');
|
||||
});
|
||||
|
||||
test('fromResponse leaves code null when the body has no error object', () {
|
||||
final exception = StripeApiException.fromResponse(
|
||||
http.Response(jsonEncode({'ok': true}), HttpStatus.badRequest),
|
||||
message: 'fallback',
|
||||
);
|
||||
|
||||
expect(exception.code, isNull);
|
||||
});
|
||||
|
||||
test('toString includes the status code and error code', () {
|
||||
final exception = StripeApiException(
|
||||
statusCode: HttpStatus.notFound,
|
||||
message: 'No such customer',
|
||||
code: 'resource_missing',
|
||||
);
|
||||
|
||||
expect(
|
||||
exception.toString(),
|
||||
'StripeApiException(404, resource_missing): No such customer',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user