feat(stripe_api): surface HTTP status and error code on failures (#3839)

This commit is contained in:
nickshorebird
2026-07-06 15:11:35 -04:00
committed by GitHub
parent 1b9b9bf201
commit ffe24a5fc1
4 changed files with 200 additions and 7 deletions
+19 -7
View File
@@ -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
View File
@@ -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',
);
});
});
}