From f0fcf0593d97e75c401143e46486f114cacef516 Mon Sep 17 00:00:00 2001 From: Brandon Trautmann <8343465+btrautmann@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:28:18 -0500 Subject: [PATCH 1/3] feat: support capturing and emitting header values --- example/main.dart | 13 +- lib/src/sturdy_http.dart | 31 +- lib/src/sturdy_http_event_listener.dart | 36 +- test/src/network_request_test.dart | 33 ++ test/src/sturdy_http_event_listener_test.dart | 383 ++++++++++++++++++ test/src/sturdy_http_test.dart | 14 +- 6 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 test/src/network_request_test.dart create mode 100644 test/src/sturdy_http_event_listener_test.dart diff --git a/example/main.dart b/example/main.dart index b0844dd..fdfbc88 100644 --- a/example/main.dart +++ b/example/main.dart @@ -53,8 +53,17 @@ class ExampleEventListener implements SturdyHttpEventListener { print('decoding error'); case AuthFailure(): print('auth failure'); - case MutativeRequestSuccess(): - print('mutative request success'); + case RequestCompleted( + :final headers, + :final statusCode, + :final isSuccess, + :final shouldTriggerDataMutation, + ): + print('request completed: $statusCode, success: $isSuccess'); + print('headers: $headers'); + if (shouldTriggerDataMutation) { + print('mutative request success'); + } } } } diff --git a/lib/src/sturdy_http.dart b/lib/src/sturdy_http.dart index 62bccba..fc089a4 100644 --- a/lib/src/sturdy_http.dart +++ b/lib/src/sturdy_http.dart @@ -28,6 +28,7 @@ class SturdyHttp { final Deserializer _deserializer; final SturdyHttpEventListener? _eventListener; final RetryBehavior _retryBehavior; + final List _headerKeysToCapture; /// The interceptors provided when this [SturdyHttp] was constructed. UnmodifiableListView get interceptors => @@ -49,6 +50,7 @@ class SturdyHttp { Map? proxy, bool inferContentType = true, RetryBehavior retryBehavior = const NeverRetry(), + List headerKeysToCapture = const [], }) : this._( dio: _configureDio( baseUrl: baseUrl, @@ -61,6 +63,7 @@ class SturdyHttp { deserializer: deserializer, eventListener: eventListener, retryBehavior: retryBehavior, + headerKeysToCapture: headerKeysToCapture, ); /// {@macro http_client} @@ -69,10 +72,12 @@ class SturdyHttp { required Deserializer deserializer, required SturdyHttpEventListener? eventListener, required RetryBehavior retryBehavior, + required List headerKeysToCapture, }) : _dio = dio, _deserializer = deserializer, _eventListener = eventListener, - _retryBehavior = retryBehavior; + _retryBehavior = retryBehavior, + _headerKeysToCapture = headerKeysToCapture; /// {@macro http_client} SturdyHttp withBaseUrl(String baseUrl) { @@ -87,6 +92,7 @@ class SturdyHttp { deserializer: _deserializer, eventListener: _eventListener, retryBehavior: _retryBehavior, + headerKeysToCapture: _headerKeysToCapture, ); } @@ -94,6 +100,17 @@ class SturdyHttp { await _eventListener?.onEvent(event); } + Map _filterHeaders(Headers headers) { + final filtered = {}; + for (final key in _headerKeysToCapture) { + final value = headers.value(key); + if (value != null) { + filtered[key] = value; + } + } + return filtered; + } + /// Executes the provided [request] and returns the result of type [M] that is /// produced by the [onResponse] parameter. /// @@ -181,6 +198,7 @@ class SturdyHttp { } } } on DioException catch (error) { + dioResponse = error.response; switch (error.response?.statusCode) { case 401: await _onEvent(AuthFailure(request: error.requestOptions)); @@ -239,9 +257,16 @@ class SturdyHttp { response = await send(request); } - if (response.$2.isSuccess && request.shouldTriggerDataMutation) { + final dioResponse = response.$1; + if (dioResponse != null) { await _onEvent( - MutativeRequestSuccess(request: response.$1!.requestOptions), + RequestCompleted( + request: dioResponse.requestOptions, + headers: _filterHeaders(dioResponse.headers), + statusCode: dioResponse.statusCode ?? 0, + isSuccess: response.$2.isSuccess, + shouldTriggerDataMutation: request.shouldTriggerDataMutation, + ), ); } diff --git a/lib/src/sturdy_http_event_listener.dart b/lib/src/sturdy_http_event_listener.dart index 6257231..1ddd7f5 100644 --- a/lib/src/sturdy_http_event_listener.dart +++ b/lib/src/sturdy_http_event_listener.dart @@ -49,12 +49,34 @@ final class AuthFailure extends SturdyHttpEvent { AuthFailure({required super.request}); } -/// {@template mutative_request_success} -/// Indicates that a "mutative" request succeeded and the data on the client -/// likely does not match the data on the server. -/// See [NetworkRequest.shouldTriggerDataMutation]. +/// {@template request_completed} +/// Indicates that a network request has completed and a response was received. +/// This event fires for all requests that receive any HTTP response, +/// regardless of success or error status. +/// +/// Use [headers] to access response headers (filtered to only keys specified +/// in `headerKeysToCapture` when constructing [SturdyHttp]). /// {@endtemplate} -final class MutativeRequestSuccess extends SturdyHttpEvent { - /// {@macro mutative_request_success} - MutativeRequestSuccess({required super.request}); +final class RequestCompleted extends SturdyHttpEvent { + /// Filtered response headers (only keys specified in `headerKeysToCapture`). + final Map headers; + + /// The HTTP status code of the response. + final int statusCode; + + /// Whether the response was successful (2xx status code). + final bool isSuccess; + + /// Whether the request was marked as mutative (data-changing). + /// See [NetworkRequest.shouldTriggerDataMutation]. + final bool shouldTriggerDataMutation; + + /// {@macro request_completed} + RequestCompleted({ + required super.request, + required this.headers, + required this.statusCode, + required this.isSuccess, + required this.shouldTriggerDataMutation, + }); } diff --git a/test/src/network_request_test.dart b/test/src/network_request_test.dart new file mode 100644 index 0000000..8626582 --- /dev/null +++ b/test/src/network_request_test.dart @@ -0,0 +1,33 @@ +import 'package:sturdy_http/sturdy_http.dart'; +import 'package:test/test.dart'; + +void main() { + group('NetworkRequest', () { + group('shouldTriggerDataMutation defaults', () { + test('GetRequest defaults to false', () { + const request = GetRequest('/path'); + expect(request.shouldTriggerDataMutation, isFalse); + }); + + test('PostRequest defaults to true', () { + final request = PostRequest('/path', data: JsonRequestBody({})); + expect(request.shouldTriggerDataMutation, isTrue); + }); + + test('PutRequest defaults to true', () { + final request = PutRequest('/path', data: JsonRequestBody({})); + expect(request.shouldTriggerDataMutation, isTrue); + }); + + test('DeleteRequest defaults to true', () { + const request = DeleteRequest('/path'); + expect(request.shouldTriggerDataMutation, isTrue); + }); + + test('RawRequest defaults to true', () { + const request = RawRequest('/path', type: NetworkRequestType.Post); + expect(request.shouldTriggerDataMutation, isTrue); + }); + }); + }); +} diff --git a/test/src/sturdy_http_event_listener_test.dart b/test/src/sturdy_http_event_listener_test.dart new file mode 100644 index 0000000..1da217a --- /dev/null +++ b/test/src/sturdy_http_event_listener_test.dart @@ -0,0 +1,383 @@ +import 'package:charlatan/charlatan.dart'; +import 'package:dio/dio.dart'; +import 'package:sturdy_http/sturdy_http.dart'; +import 'package:test/test.dart'; + +void main() { + group('SturdyHttpEventListener', () { + group('RequestCompleted', () { + test('emits for successful GET request', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/test', + (request) => CharlatanHttpResponse(body: {'data': 'value'}), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/test'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.request.path, '/test'); + expect(events.first.statusCode, 200); + expect(events.first.isSuccess, isTrue); + expect(events.first.shouldTriggerDataMutation, isFalse); + }); + + test( + 'emits for successful POST request with shouldTriggerDataMutation true', + () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenPost( + '/create', + (request) => CharlatanHttpResponse(body: {'id': 1}), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + PostRequest('/create', data: JsonRequestBody({'name': 'test'})), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.request.path, '/create'); + expect(events.first.statusCode, 200); + expect(events.first.isSuccess, isTrue); + expect(events.first.shouldTriggerDataMutation, isTrue); + }, + ); + + test('emits for error responses', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/not-found', + (request) => CharlatanHttpResponse(statusCode: 404), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/not-found'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.request.path, '/not-found'); + expect(events.first.statusCode, 404); + expect(events.first.isSuccess, isFalse); + }); + + test('emits for 401 responses alongside AuthFailure', () async { + final charlatan = Charlatan(); + final requestCompletedEvents = []; + final authFailureEvents = []; + + charlatan.whenGet( + '/unauthorized', + (request) => CharlatanHttpResponse(statusCode: 401), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener( + onRequestCompleted: requestCompletedEvents.add, + onAuthFailure: authFailureEvents.add, + ), + ); + + await client.execute( + const GetRequest('/unauthorized'), + onResponse: (_) {}, + ); + + expect(requestCompletedEvents, hasLength(1)); + expect(requestCompletedEvents.first.statusCode, 401); + expect(requestCompletedEvents.first.isSuccess, isFalse); + + expect(authFailureEvents, hasLength(1)); + expect(authFailureEvents.first.path, '/unauthorized'); + }); + + group('header filtering', () { + test('includes only specified header keys', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/with-headers', + (request) => CharlatanHttpResponse( + body: {'data': 'value'}, + headers: { + 'X-Custom-Header': 'custom-value', + 'X-Another-Header': 'another-value', + 'X-Ignored-Header': 'ignored-value', + }, + ), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + headerKeysToCapture: ['X-Custom-Header', 'X-Another-Header'], + ); + + await client.execute( + const GetRequest('/with-headers'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.headers, { + 'X-Custom-Header': 'custom-value', + 'X-Another-Header': 'another-value', + }); + expect(events.first.headers.containsKey('X-Ignored-Header'), isFalse); + }); + + test('returns empty map when no header keys configured', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/with-headers', + (request) => CharlatanHttpResponse( + body: {'data': 'value'}, + headers: {'X-Custom-Header': 'custom-value'}, + ), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/with-headers'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.headers, isEmpty); + }); + + test('omits missing headers from result', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/with-headers', + (request) => CharlatanHttpResponse( + body: {'data': 'value'}, + headers: {'X-Present': 'present-value'}, + ), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + headerKeysToCapture: ['X-Present', 'X-Missing'], + ); + + await client.execute( + const GetRequest('/with-headers'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.headers, {'X-Present': 'present-value'}); + expect(events.first.headers.containsKey('X-Missing'), isFalse); + }); + + test('captures headers from error responses', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/error-with-headers', + (request) => CharlatanHttpResponse( + statusCode: 500, + headers: {'X-Error-Id': 'error-123'}, + ), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + headerKeysToCapture: ['X-Error-Id'], + ); + + await client.execute( + const GetRequest('/error-with-headers'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.statusCode, 500); + expect(events.first.isSuccess, isFalse); + expect(events.first.headers, {'X-Error-Id': 'error-123'}); + }); + }); + + group('shouldTriggerDataMutation', () { + test('passes through true from request', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenPost('/post', (request) => CharlatanHttpResponse()); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + PostRequest( + '/post', + data: JsonRequestBody({}), + shouldTriggerDataMutation: true, + ), + onResponse: (_) {}, + ); + + expect(events.first.shouldTriggerDataMutation, isTrue); + }); + + test('passes through false from request', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenPost('/post', (request) => CharlatanHttpResponse()); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + PostRequest( + '/post', + data: JsonRequestBody({}), + shouldTriggerDataMutation: false, + ), + onResponse: (_) {}, + ); + + expect(events.first.shouldTriggerDataMutation, isFalse); + }); + }); + }); + + group('AuthFailure', () { + test('emits for 401 responses', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/unauthorized', + (request) => CharlatanHttpResponse(statusCode: 401), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onAuthFailure: events.add), + ); + + await client.execute( + const GetRequest('/unauthorized'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.path, '/unauthorized'); + }); + }); + + group('DecodingError', () { + test('emits when onResponse throws', () async { + final charlatan = Charlatan(); + final events = <(RequestOptions, Exception, StackTrace?)>[]; + + charlatan.whenGet( + '/test', + (request) => CharlatanHttpResponse(body: {'data': 'value'}), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener( + onDecodingError: (request, exception, stackTrace) { + events.add((request, exception, stackTrace)); + }, + ), + ); + + await expectLater( + () => client.execute( + const GetRequest('/test'), + onResponse: (_) => throw Exception('Decoding failed'), + ), + throwsException, + ); + + expect(events, hasLength(1)); + expect(events.first.$1.path, '/test'); + expect(events.first.$2.toString(), contains('Decoding failed')); + }); + }); + }); +} + +class _TestEventListener extends SturdyHttpEventListener { + final void Function(RequestCompleted)? onRequestCompleted; + final void Function(RequestOptions)? onAuthFailure; + final void Function(RequestOptions, Exception, StackTrace?)? onDecodingError; + + _TestEventListener({ + this.onRequestCompleted, + this.onAuthFailure, + this.onDecodingError, + }); + + @override + Future onEvent(SturdyHttpEvent event) async { + switch (event) { + case RequestCompleted(): + onRequestCompleted?.call(event); + case AuthFailure(:final request): + onAuthFailure?.call(request); + case DecodingError(:final request, :final exception, :final stackTrace): + onDecodingError?.call(request, exception, stackTrace); + } + } +} diff --git a/test/src/sturdy_http_test.dart b/test/src/sturdy_http_test.dart index 0378cb6..08e9dd6 100644 --- a/test/src/sturdy_http_test.dart +++ b/test/src/sturdy_http_test.dart @@ -972,8 +972,8 @@ void main() { maxRetries: 2, retryInterval: Duration(milliseconds: 100), retryClause: (r) { - // Body will be `null`; essentially disallow retrying - return r != null; + // Disallow retrying by always returning false + return false; }, ), ), @@ -1049,8 +1049,14 @@ class _SturdyHttpEventListener extends SturdyHttpEventListener { onDecodingError(request, exception, stackTrace); case AuthFailure(:final request): onAuthFailure(request); - case MutativeRequestSuccess(:final request): - onMutativeRequestSuccess(request); + case RequestCompleted( + :final request, + :final isSuccess, + :final shouldTriggerDataMutation, + ): + if (isSuccess && shouldTriggerDataMutation) { + onMutativeRequestSuccess(request); + } } } } From 91e81166c455cc15d242ed575ef6686f425fc623 Mon Sep 17 00:00:00 2001 From: Brandon Trautmann <8343465+btrautmann@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:00:28 -0500 Subject: [PATCH 2/3] refactor: don't filter headers (for now) --- lib/src/sturdy_http.dart | 23 +----- lib/src/sturdy_http_event_listener.dart | 5 +- test/src/sturdy_http_event_listener_test.dart | 74 ++----------------- 3 files changed, 11 insertions(+), 91 deletions(-) diff --git a/lib/src/sturdy_http.dart b/lib/src/sturdy_http.dart index fc089a4..d85d3ff 100644 --- a/lib/src/sturdy_http.dart +++ b/lib/src/sturdy_http.dart @@ -28,7 +28,6 @@ class SturdyHttp { final Deserializer _deserializer; final SturdyHttpEventListener? _eventListener; final RetryBehavior _retryBehavior; - final List _headerKeysToCapture; /// The interceptors provided when this [SturdyHttp] was constructed. UnmodifiableListView get interceptors => @@ -50,7 +49,6 @@ class SturdyHttp { Map? proxy, bool inferContentType = true, RetryBehavior retryBehavior = const NeverRetry(), - List headerKeysToCapture = const [], }) : this._( dio: _configureDio( baseUrl: baseUrl, @@ -63,7 +61,6 @@ class SturdyHttp { deserializer: deserializer, eventListener: eventListener, retryBehavior: retryBehavior, - headerKeysToCapture: headerKeysToCapture, ); /// {@macro http_client} @@ -72,12 +69,10 @@ class SturdyHttp { required Deserializer deserializer, required SturdyHttpEventListener? eventListener, required RetryBehavior retryBehavior, - required List headerKeysToCapture, }) : _dio = dio, _deserializer = deserializer, _eventListener = eventListener, - _retryBehavior = retryBehavior, - _headerKeysToCapture = headerKeysToCapture; + _retryBehavior = retryBehavior; /// {@macro http_client} SturdyHttp withBaseUrl(String baseUrl) { @@ -92,7 +87,6 @@ class SturdyHttp { deserializer: _deserializer, eventListener: _eventListener, retryBehavior: _retryBehavior, - headerKeysToCapture: _headerKeysToCapture, ); } @@ -100,17 +94,6 @@ class SturdyHttp { await _eventListener?.onEvent(event); } - Map _filterHeaders(Headers headers) { - final filtered = {}; - for (final key in _headerKeysToCapture) { - final value = headers.value(key); - if (value != null) { - filtered[key] = value; - } - } - return filtered; - } - /// Executes the provided [request] and returns the result of type [M] that is /// produced by the [onResponse] parameter. /// @@ -262,7 +245,9 @@ class SturdyHttp { await _onEvent( RequestCompleted( request: dioResponse.requestOptions, - headers: _filterHeaders(dioResponse.headers), + headers: dioResponse.headers.map.map( + (key, values) => MapEntry(key, values.join(', ')), + ), statusCode: dioResponse.statusCode ?? 0, isSuccess: response.$2.isSuccess, shouldTriggerDataMutation: request.shouldTriggerDataMutation, diff --git a/lib/src/sturdy_http_event_listener.dart b/lib/src/sturdy_http_event_listener.dart index 1ddd7f5..39670d4 100644 --- a/lib/src/sturdy_http_event_listener.dart +++ b/lib/src/sturdy_http_event_listener.dart @@ -53,12 +53,9 @@ final class AuthFailure extends SturdyHttpEvent { /// Indicates that a network request has completed and a response was received. /// This event fires for all requests that receive any HTTP response, /// regardless of success or error status. -/// -/// Use [headers] to access response headers (filtered to only keys specified -/// in `headerKeysToCapture` when constructing [SturdyHttp]). /// {@endtemplate} final class RequestCompleted extends SturdyHttpEvent { - /// Filtered response headers (only keys specified in `headerKeysToCapture`). + /// The response headers. final Map headers; /// The HTTP status code of the response. diff --git a/test/src/sturdy_http_event_listener_test.dart b/test/src/sturdy_http_event_listener_test.dart index 1da217a..fcb2e25 100644 --- a/test/src/sturdy_http_event_listener_test.dart +++ b/test/src/sturdy_http_event_listener_test.dart @@ -121,8 +121,8 @@ void main() { expect(authFailureEvents.first.path, '/unauthorized'); }); - group('header filtering', () { - test('includes only specified header keys', () async { + group('headers', () { + test('includes all response headers', () async { final charlatan = Charlatan(); final events = []; @@ -133,7 +133,6 @@ void main() { headers: { 'X-Custom-Header': 'custom-value', 'X-Another-Header': 'another-value', - 'X-Ignored-Header': 'ignored-value', }, ), ); @@ -142,7 +141,6 @@ void main() { baseUrl: 'http://example.com', customAdapter: charlatan.toFakeHttpClientAdapter(), eventListener: _TestEventListener(onRequestCompleted: events.add), - headerKeysToCapture: ['X-Custom-Header', 'X-Another-Header'], ); await client.execute( @@ -151,70 +149,11 @@ void main() { ); expect(events, hasLength(1)); - expect(events.first.headers, { - 'X-Custom-Header': 'custom-value', - 'X-Another-Header': 'another-value', - }); - expect(events.first.headers.containsKey('X-Ignored-Header'), isFalse); + expect(events.first.headers['X-Custom-Header'], 'custom-value'); + expect(events.first.headers['X-Another-Header'], 'another-value'); }); - test('returns empty map when no header keys configured', () async { - final charlatan = Charlatan(); - final events = []; - - charlatan.whenGet( - '/with-headers', - (request) => CharlatanHttpResponse( - body: {'data': 'value'}, - headers: {'X-Custom-Header': 'custom-value'}, - ), - ); - - final client = SturdyHttp( - baseUrl: 'http://example.com', - customAdapter: charlatan.toFakeHttpClientAdapter(), - eventListener: _TestEventListener(onRequestCompleted: events.add), - ); - - await client.execute( - const GetRequest('/with-headers'), - onResponse: (_) {}, - ); - - expect(events, hasLength(1)); - expect(events.first.headers, isEmpty); - }); - - test('omits missing headers from result', () async { - final charlatan = Charlatan(); - final events = []; - - charlatan.whenGet( - '/with-headers', - (request) => CharlatanHttpResponse( - body: {'data': 'value'}, - headers: {'X-Present': 'present-value'}, - ), - ); - - final client = SturdyHttp( - baseUrl: 'http://example.com', - customAdapter: charlatan.toFakeHttpClientAdapter(), - eventListener: _TestEventListener(onRequestCompleted: events.add), - headerKeysToCapture: ['X-Present', 'X-Missing'], - ); - - await client.execute( - const GetRequest('/with-headers'), - onResponse: (_) {}, - ); - - expect(events, hasLength(1)); - expect(events.first.headers, {'X-Present': 'present-value'}); - expect(events.first.headers.containsKey('X-Missing'), isFalse); - }); - - test('captures headers from error responses', () async { + test('includes headers from error responses', () async { final charlatan = Charlatan(); final events = []; @@ -230,7 +169,6 @@ void main() { baseUrl: 'http://example.com', customAdapter: charlatan.toFakeHttpClientAdapter(), eventListener: _TestEventListener(onRequestCompleted: events.add), - headerKeysToCapture: ['X-Error-Id'], ); await client.execute( @@ -241,7 +179,7 @@ void main() { expect(events, hasLength(1)); expect(events.first.statusCode, 500); expect(events.first.isSuccess, isFalse); - expect(events.first.headers, {'X-Error-Id': 'error-123'}); + expect(events.first.headers['X-Error-Id'], 'error-123'); }); }); From cdd60c0aa8dd7c4c4c549ac7482ff2117e30a345 Mon Sep 17 00:00:00 2001 From: Brandon Trautmann <8343465+btrautmann@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:14:51 -0500 Subject: [PATCH 3/3] make statusCode nullable, don't return a 0 status code --- lib/src/sturdy_http.dart | 2 +- lib/src/sturdy_http_event_listener.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/sturdy_http.dart b/lib/src/sturdy_http.dart index d85d3ff..db4c904 100644 --- a/lib/src/sturdy_http.dart +++ b/lib/src/sturdy_http.dart @@ -248,7 +248,7 @@ class SturdyHttp { headers: dioResponse.headers.map.map( (key, values) => MapEntry(key, values.join(', ')), ), - statusCode: dioResponse.statusCode ?? 0, + statusCode: dioResponse.statusCode, isSuccess: response.$2.isSuccess, shouldTriggerDataMutation: request.shouldTriggerDataMutation, ), diff --git a/lib/src/sturdy_http_event_listener.dart b/lib/src/sturdy_http_event_listener.dart index 39670d4..ee98f61 100644 --- a/lib/src/sturdy_http_event_listener.dart +++ b/lib/src/sturdy_http_event_listener.dart @@ -58,8 +58,8 @@ final class RequestCompleted extends SturdyHttpEvent { /// The response headers. final Map headers; - /// The HTTP status code of the response. - final int statusCode; + /// The HTTP status code of the response, if available. + final int? statusCode; /// Whether the response was successful (2xx status code). final bool isSuccess;