Visitar URL original
fix(remote_config): classify fetch failures instead of reporting them… · firebase/flutterfire@128912e · GitHub
Skip to content

Commit 128912e

Browse files
authored
fix(remote_config): classify fetch failures instead of reporting them all as internal (#18585)
Every Remote Config failure that is not throttling or a server error arrived as `firebase_remote_config/internal` (or `unknown`), so callers could not tell a missing data connection from a real error and had to either report all of them to their crash reporter or none. Failures are now classified from what the native SDKs report, adding `network-error` and `cancelled`, and mapping a backend status in the native message onto the existing `forbidden`, `throttled` and `remote-config-server-error` codes. Anything unclassifiable keeps `internal`/`unknown`. Android also stops replacing the native message with `internal remote config fetch error`, which is what makes those failures classifiable. Closes #10904
1 parent 6131edd commit 128912e

6 files changed

Lines changed: 319 additions & 8 deletions

File tree

‎packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ class FirebaseRemoteConfigPlugin :
229229
details["message"] = "frequency of requests exceeds throttled limits"
230230
} else if (exception is FirebaseRemoteConfigClientException) {
231231
details["code"] = "internal"
232-
details["message"] = "internal remote config fetch error"
232+
// The native message is the only thing that says what went wrong here,
233+
// for example a missing data connection, so it is passed on as is and
234+
// the Dart side refines the code from it.
235+
details["message"] = exception.message ?: "internal remote config fetch error"
233236
} else if (exception is FirebaseRemoteConfigServerException) {
234237
details["code"] = "remote-config-server-error"
235238
details["message"] = exception.message
@@ -244,7 +247,7 @@ class FirebaseRemoteConfigPlugin :
244247
}
245248
} else {
246249
details["code"] = "unknown"
247-
details["message"] = "unknown remote config error"
250+
details["message"] = exception?.message ?: "unknown remote config error"
248251
}
249252
callback(Result.failure(FlutterError("firebase_remote_config", exception?.message, details)))
250253
}

‎packages/firebase_remote_config/firebase_remote_config/lib/src/firebase_remote_config.dart‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,36 @@ class FirebaseRemoteConfig extends FirebasePlugin {
7575
}
7676

7777
/// Fetches and caches configuration from the Remote Config service.
78+
///
79+
/// A [FirebaseException] may be thrown with one of the following codes:
80+
/// - **network-error**:
81+
/// - Thrown if the Remote Config backend could not be reached, for example
82+
/// when the device has no data connection, the request timed out or the
83+
/// host could not be resolved. Transient, the fetch can be retried.
84+
/// - **cancelled**:
85+
/// - Thrown if the fetch was cancelled before it completed. Transient.
86+
/// - **throttled**:
87+
/// - Thrown if the frequency of requests exceeds the throttled limits.
88+
/// - **forbidden**:
89+
/// - Thrown if the Google Cloud Platform Firebase Remote Config API is
90+
/// disabled for the project.
91+
/// - **remote-config-server-error**:
92+
/// - Thrown if the Remote Config backend answered with an error status.
93+
/// - **internal** or **unknown**:
94+
/// - Thrown if the failure could not be classified. Read
95+
/// [FirebaseException.message] for the description the native SDK gave.
96+
///
97+
/// On the web the codes come from the Firebase JavaScript SDK instead, for
98+
/// example `fetch-client-network`, `fetch-timeout` and `fetch-throttle`.
7899
Future<void> fetch() {
79100
return _delegate.fetch();
80101
}
81102

82103
/// Performs a fetch and activate operation, as a convenience.
83104
///
84105
/// Returns [bool] in the same way that is done for [activate].
85-
/// A [FirebaseException] maybe thrown with the following error code:
86-
/// - **forbidden**:
87-
/// - Thrown if the Google Cloud Platform Firebase Remote Config API is disabled
106+
///
107+
/// Throws the same [FirebaseException] codes as [fetch].
88108
Future<bool> fetchAndActivate() async {
89109
bool configChanged = await _delegate.fetchAndActivate();
90110
return configChanged;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
/// The codes the native SDKs report when they could not tell us what went
6+
/// wrong, and which therefore carry no information a caller can act on.
7+
const Set<String> _genericCodes = <String>{'internal', 'unknown'};
8+
9+
/// Message fragments the native SDKs use for a fetch that could not reach the
10+
/// Remote Config backend.
11+
const List<String> _networkFragments = <String>[
12+
'data connection is not currently allowed',
13+
'network',
14+
'unable to resolve host',
15+
'no internet',
16+
'offline',
17+
'unreachable',
18+
'connection refused',
19+
'connection reset',
20+
'connection lost',
21+
'timed out',
22+
'timeout',
23+
];
24+
25+
/// Message fragments the native SDKs use for a fetch that was cancelled.
26+
const List<String> _cancelledFragments = <String>['cancelled', 'canceled'];
27+
28+
/// The HTTP status the backend replied with, as the native SDKs word it, e.g.
29+
/// `Internal Error. Status code: 503`.
30+
final RegExp _httpStatus = RegExp(r'status(?:\s+code)?:?\s*(\d{3})');
31+
32+
/// Turns a generic native error code into a code describing what actually
33+
/// failed, so that callers can tell transient failures apart from real ones.
34+
///
35+
/// Every failure that is not throttling or a server error arrives from the
36+
/// native SDKs as `internal` or `unknown`, which is why the native message is
37+
/// the only thing left to classify on. Codes the native SDKs already
38+
/// classified, and messages that cannot be classified, are returned unchanged.
39+
String refineRemoteConfigErrorCode(String? code, String? message) {
40+
if (code == null || !_genericCodes.contains(code) || message == null) {
41+
return code ?? 'unknown';
42+
}
43+
44+
final String lowerCaseMessage = message.toLowerCase();
45+
46+
final Match? status = _httpStatus.firstMatch(lowerCaseMessage);
47+
if (status != null) {
48+
final int? statusCode = int.tryParse(status.group(1)!);
49+
if (statusCode == 403) {
50+
return 'forbidden';
51+
}
52+
if (statusCode == 429) {
53+
return 'throttled';
54+
}
55+
if (statusCode != null && statusCode >= 400) {
56+
return 'remote-config-server-error';
57+
}
58+
}
59+
60+
if (_cancelledFragments.any(lowerCaseMessage.contains)) {
61+
return 'cancelled';
62+
}
63+
64+
if (_networkFragments.any(lowerCaseMessage.contains)) {
65+
return 'network-error';
66+
}
67+
68+
return code;
69+
}

‎packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/method_channel/utils/exception.dart‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,41 @@ import 'package:flutter/services.dart';
88

99
import 'package:_flutterfire_internals/_flutterfire_internals.dart';
1010

11+
import 'error_code.dart';
12+
1113
/// Catches a [PlatformException] and returns an [Exception].
1214
///
1315
/// If the [Exception] is a [PlatformException], a [FirebaseException] is returned.
16+
///
17+
/// The native SDKs report anything that is not throttling or a server error as
18+
/// `internal` or `unknown`, so the code is refined from the native message
19+
/// where possible, see [refineRemoteConfigErrorCode].
1420
Never convertPlatformException(Object exception, StackTrace stackTrace) {
21+
if (exception is PlatformException) {
22+
final FirebaseException firebaseException =
23+
platformExceptionToFirebaseException(
24+
exception,
25+
plugin: 'firebase_remote_config',
26+
);
27+
28+
final String code = refineRemoteConfigErrorCode(
29+
firebaseException.code,
30+
firebaseException.message,
31+
);
32+
33+
if (code != firebaseException.code) {
34+
Error.throwWithStackTrace(
35+
FirebaseException(
36+
plugin: firebaseException.plugin,
37+
code: code,
38+
message: firebaseException.message,
39+
stackTrace: firebaseException.stackTrace,
40+
),
41+
stackTrace == StackTrace.empty ? StackTrace.current : stackTrace,
42+
);
43+
}
44+
}
45+
1546
convertPlatformExceptionToFirebaseException(
1647
exception,
1748
stackTrace,

‎packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/platform_interface/platform_interface_firebase_remote_config.dart‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,36 @@ abstract class FirebaseRemoteConfigPlatform extends PlatformInterface {
115115
}
116116

117117
/// Fetches and caches configuration from the Remote Config service.
118+
///
119+
/// A [FirebaseException] may be thrown with one of the following codes:
120+
/// - **network-error**:
121+
/// - Thrown if the Remote Config backend could not be reached, for example
122+
/// when the device has no data connection, the request timed out or the
123+
/// host could not be resolved. Transient, the fetch can be retried.
124+
/// - **cancelled**:
125+
/// - Thrown if the fetch was cancelled before it completed. Transient.
126+
/// - **throttled**:
127+
/// - Thrown if the frequency of requests exceeds the throttled limits.
128+
/// - **forbidden**:
129+
/// - Thrown if the Google Cloud Platform Firebase Remote Config API is
130+
/// disabled for the project.
131+
/// - **remote-config-server-error**:
132+
/// - Thrown if the Remote Config backend answered with an error status.
133+
/// - **internal** or **unknown**:
134+
/// - Thrown if the failure could not be classified. Read
135+
/// [FirebaseException.message] for the description the native SDK gave.
136+
///
137+
/// On the web the codes come from the Firebase JavaScript SDK instead, for
138+
/// example `fetch-client-network`, `fetch-timeout` and `fetch-throttle`.
118139
Future<void> fetch() {
119140
throw UnimplementedError('fetch() is not implemented');
120141
}
121142

122143
/// Performs a fetch and activate operation, as a convenience.
123144
///
124145
/// Returns [bool] in the same way that is done for [activate].
125-
/// A [FirebaseException] maybe thrown with the following error code:
126-
/// - **forbidden**:
127-
/// - Thrown if the Google Cloud Platform Firebase Remote Config API is disabled
146+
///
147+
/// Throws the same [FirebaseException] codes as [fetch].
128148
Future<bool> fetchAndActivate() {
129149
throw UnimplementedError('fetchAndActivate() is not implemented');
130150
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:firebase_core/firebase_core.dart';
6+
import 'package:firebase_remote_config_platform_interface/src/method_channel/utils/error_code.dart';
7+
import 'package:firebase_remote_config_platform_interface/src/method_channel/utils/exception.dart';
8+
import 'package:flutter/services.dart';
9+
import 'package:flutter_test/flutter_test.dart';
10+
11+
void main() {
12+
group('refineRemoteConfigErrorCode', () {
13+
test('classifies a missing data connection as a network error', () {
14+
// Android, through `FirebaseRemoteConfigClientException`.
15+
expect(
16+
refineRemoteConfigErrorCode(
17+
'internal',
18+
'A data connection is not currently allowed.',
19+
),
20+
'network-error',
21+
);
22+
expect(
23+
refineRemoteConfigErrorCode(
24+
'internal',
25+
'Failed to get installations token. A data connection is not '
26+
'currently allowed.',
27+
),
28+
'network-error',
29+
);
30+
});
31+
32+
test('classifies other connectivity failures as a network error', () {
33+
for (final String message in <String>[
34+
'Fetch failed: Unable to resolve host "firebaseremoteconfig.googleapis.com"',
35+
'The request timed out.',
36+
'The Internet connection appears to be offline.',
37+
'Network error (such as timeout or unreachable host) has occurred.',
38+
]) {
39+
expect(
40+
refineRemoteConfigErrorCode('internal', message),
41+
'network-error',
42+
reason: message,
43+
);
44+
}
45+
});
46+
47+
test('classifies a cancelled fetch', () {
48+
expect(refineRemoteConfigErrorCode('internal', 'cancelled'), 'cancelled');
49+
expect(refineRemoteConfigErrorCode('unknown', 'Canceled'), 'cancelled');
50+
});
51+
52+
test('classifies a backend status code', () {
53+
expect(
54+
refineRemoteConfigErrorCode(
55+
'internal',
56+
'Internal Error. Status code: 503',
57+
),
58+
'remote-config-server-error',
59+
);
60+
expect(
61+
refineRemoteConfigErrorCode(
62+
'internal',
63+
'Internal Error. Status code: 403',
64+
),
65+
'forbidden',
66+
);
67+
expect(
68+
refineRemoteConfigErrorCode(
69+
'internal',
70+
'Internal Error. Status code: 429',
71+
),
72+
'throttled',
73+
);
74+
});
75+
76+
test('leaves an informative status code alone', () {
77+
expect(
78+
refineRemoteConfigErrorCode('internal', 'Status code: 200'),
79+
'internal',
80+
);
81+
});
82+
83+
test('leaves codes the native SDKs classified alone', () {
84+
expect(
85+
refineRemoteConfigErrorCode(
86+
'throttled',
87+
'frequency of requests exceeds throttled limits',
88+
),
89+
'throttled',
90+
);
91+
expect(
92+
refineRemoteConfigErrorCode(
93+
'forbidden',
94+
'A data connection is not currently allowed.',
95+
),
96+
'forbidden',
97+
);
98+
});
99+
100+
test('leaves a message it cannot classify alone', () {
101+
expect(
102+
refineRemoteConfigErrorCode(
103+
'internal',
104+
'internal remote config fetch error',
105+
),
106+
'internal',
107+
);
108+
expect(refineRemoteConfigErrorCode('internal', null), 'internal');
109+
expect(refineRemoteConfigErrorCode(null, 'cancelled'), 'unknown');
110+
});
111+
});
112+
113+
group('convertPlatformException', () {
114+
test('throws a FirebaseException with the refined code', () {
115+
expect(
116+
() => convertPlatformException(
117+
PlatformException(
118+
code: 'firebase_remote_config',
119+
message: 'A data connection is not currently allowed.',
120+
details: <String, Object?>{
121+
'code': 'internal',
122+
'message': 'A data connection is not currently allowed.',
123+
},
124+
),
125+
StackTrace.empty,
126+
),
127+
throwsA(
128+
isA<FirebaseException>()
129+
.having((e) => e.plugin, 'plugin', 'firebase_remote_config')
130+
.having((e) => e.code, 'code', 'network-error')
131+
.having(
132+
(e) => e.message,
133+
'message',
134+
'A data connection is not currently allowed.',
135+
),
136+
),
137+
);
138+
});
139+
140+
test('keeps the code the native SDK reported', () {
141+
expect(
142+
() => convertPlatformException(
143+
PlatformException(
144+
code: 'firebase_remote_config',
145+
message: 'frequency of requests exceeds throttled limits',
146+
details: <String, Object?>{
147+
'code': 'throttled',
148+
'message': 'frequency of requests exceeds throttled limits',
149+
},
150+
),
151+
StackTrace.empty,
152+
),
153+
throwsA(
154+
isA<FirebaseException>().having((e) => e.code, 'code', 'throttled'),
155+
),
156+
);
157+
});
158+
159+
test('rethrows anything that is not a PlatformException', () {
160+
final error = StateError('nope');
161+
162+
expect(
163+
() => convertPlatformException(error, StackTrace.empty),
164+
throwsA(same(error)),
165+
);
166+
});
167+
});
168+
}

0 commit comments

Comments
 (0)