From ffe24a5fc18fa11f91eeed4c2a8bc39dac9147b0 Mon Sep 17 00:00:00 2001 From: nickshorebird Date: Mon, 6 Jul 2026 15:11:35 -0400 Subject: [PATCH] feat(stripe_api): surface HTTP status and error code on failures (#3839) --- packages/stripe_api/lib/src/stripe_api.dart | 26 +++-- .../lib/src/stripe_api_exception.dart | 70 +++++++++++ packages/stripe_api/lib/stripe_api.dart | 1 + .../stripe_api/test/src/stripe_api_test.dart | 110 ++++++++++++++++++ 4 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 packages/stripe_api/lib/src/stripe_api_exception.dart diff --git a/packages/stripe_api/lib/src/stripe_api.dart b/packages/stripe_api/lib/src/stripe_api.dart index 31c540ea..cb038949 100644 --- a/packages/stripe_api/lib/src/stripe_api.dart +++ b/packages/stripe_api/lib/src/stripe_api.dart @@ -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( diff --git a/packages/stripe_api/lib/src/stripe_api_exception.dart b/packages/stripe_api/lib/src/stripe_api_exception.dart new file mode 100644 index 00000000..9267bdd4 --- /dev/null +++ b/packages/stripe_api/lib/src/stripe_api_exception.dart @@ -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) { + final error = decoded['error']; + if (error is Map && 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'; +} diff --git a/packages/stripe_api/lib/stripe_api.dart b/packages/stripe_api/lib/stripe_api.dart index f557cb47..7f5e4967 100644 --- a/packages/stripe_api/lib/stripe_api.dart +++ b/packages/stripe_api/lib/stripe_api.dart @@ -1,2 +1,3 @@ export 'src/models/models.dart'; export 'src/stripe_api.dart'; +export 'src/stripe_api_exception.dart'; diff --git a/packages/stripe_api/test/src/stripe_api_test.dart b/packages/stripe_api/test/src/stripe_api_test.dart index c80fab2e..9ec57ae3 100644 --- a/packages/stripe_api/test/src/stripe_api_test.dart +++ b/packages/stripe_api/test/src/stripe_api_test.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() + .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() + .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('502 Bad Gateway', 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', + ); + }); + }); }