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);
|
final response = await _client.get(uri, headers: _authHeaders);
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
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(
|
return StripeCustomer.fromJson(
|
||||||
@@ -55,8 +58,9 @@ class StripeApi {
|
|||||||
|
|
||||||
final response = await _client.get(uri, headers: _authHeaders);
|
final response = await _client.get(uri, headers: _authHeaders);
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
throw Exception(
|
throw StripeApiException.fromResponse(
|
||||||
'Failed to retrieve subscription with id $subscriptionId',
|
response,
|
||||||
|
message: 'Failed to retrieve subscription with id $subscriptionId',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +100,14 @@ class StripeApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
throw Exception('''
|
throw StripeApiException.fromResponse(
|
||||||
|
response,
|
||||||
|
message:
|
||||||
|
'''
|
||||||
Failed to report $value for customer $customerId. Error:
|
Failed to report $value for customer $customerId. Error:
|
||||||
${response.body}
|
${response.body}
|
||||||
''');
|
''',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +154,14 @@ ${response.body}
|
|||||||
|
|
||||||
final response = await _client.get(uri, headers: _authHeaders);
|
final response = await _client.get(uri, headers: _authHeaders);
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
throw Exception('''
|
throw StripeApiException.fromResponse(
|
||||||
|
response,
|
||||||
|
message:
|
||||||
|
'''
|
||||||
Failed to get paged response from $path with params $queryParameters. Error:
|
Failed to get paged response from $path with params $queryParameters. Error:
|
||||||
${response.body}
|
${response.body}
|
||||||
''');
|
''',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final pagedResponse = PagedResponse.fromJson(
|
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/models/models.dart';
|
||||||
export 'src/stripe_api.dart';
|
export 'src/stripe_api.dart';
|
||||||
|
export 'src/stripe_api_exception.dart';
|
||||||
|
|||||||
@@ -130,6 +130,32 @@ void main() {
|
|||||||
).called(1);
|
).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 {
|
test('returns a customer on successful request', () async {
|
||||||
when(
|
when(
|
||||||
() => httpClient.get(uri, headers: any(named: 'headers')),
|
() => httpClient.get(uri, headers: any(named: 'headers')),
|
||||||
@@ -171,6 +197,36 @@ void main() {
|
|||||||
).called(1);
|
).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 {
|
test('returns a subscription on successful request', () async {
|
||||||
when(
|
when(
|
||||||
() => httpClient.get(uri, headers: any(named: 'headers')),
|
() => 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