From 837f6d401b5b42e9549f1ffda96e3f2b4d4f3e3d Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 13:07:01 +0200 Subject: [PATCH 1/4] fix(cloud_functions): surface httpsCallable stream errors as FirebaseFunctionsException Android: StreamResponseSubscriber.onError dropped the throwable and only ended the stream, so a failing streaming callable completed with no result and no error. It now sends the error with the code, message and details mapping callable errors use, then ends the stream. Dart: errors emitted by the event channel are forwarded by `yield*` instead of being thrown into the surrounding `catch`, so they reached apps as raw PlatformExceptions. They are now converted on the stream. --- .../FlutterFirebaseFunctionsPlugin.kt | 92 ++++++++--------- .../functions/StreamResponseSubscriber.kt | 26 +++-- .../functions/TestFunctionsExceptions.java | 26 +++++ .../FlutterFirebaseFunctionsPluginTest.kt | 21 ++++ .../functions/StreamResponseSubscriberTest.kt | 99 +++++++++++++++++++ .../example/integration_test/e2e_test.dart | 16 +++ .../method_channel_https_callable.dart | 7 +- .../method_channel_https_callable_test.dart | 86 +++++++++++++++- 8 files changed, 318 insertions(+), 55 deletions(-) create mode 100644 packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFunctionsExceptions.java create mode 100644 packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt index a70556caf16a..a97bd24eba8e 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt @@ -101,51 +101,6 @@ class FlutterFirebaseFunctionsPlugin : FlutterPlugin, FlutterFirebasePlugin, Clo return taskCompletionSource.task } - private fun getExceptionDetails(exception: Exception?): Map { - val details: MutableMap = HashMap() - - if (exception == null) { - return details - } - - var code = "UNKNOWN" - var message = exception.message - var additionalData: Any? = null - - if (exception.cause is FirebaseFunctionsException) { - val functionsException = exception.cause as FirebaseFunctionsException? - code = functionsException!!.code.name - message = functionsException.message - additionalData = functionsException.details - - if (functionsException.cause is IOException && - "Canceled" == (functionsException.cause as IOException).message) { - // return DEADLINE_EXCEEDED for IOException cancel errors, to match iOS & Web - code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - } else if (functionsException.cause is - InterruptedIOException // return DEADLINE_EXCEEDED for InterruptedIOException errors, to - // match iOS & Web - && "timeout" == (functionsException.cause as InterruptedIOException).message) { - code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - } else if (functionsException.cause is IOException) { - // return UNAVAILABLE for network io errors, to match iOS & Web - code = FirebaseFunctionsException.Code.UNAVAILABLE.name - message = FirebaseFunctionsException.Code.UNAVAILABLE.name - } - } - - details["code"] = mapFunctionsErrorCode(code) - details["message"] = message - - if (additionalData != null) { - details["additionalData"] = additionalData - } - - return details - } - override fun getPluginConstantsForFirebaseApp(firebaseApp: FirebaseApp): Task> { val taskCompletionSource = TaskCompletionSource>() @@ -169,6 +124,53 @@ class FlutterFirebaseFunctionsPlugin : FlutterPlugin, FlutterFirebasePlugin, Clo internal fun mapFunctionsErrorCode(code: String): String { return code.replace("_", "-").lowercase(Locale.ROOT) } + + internal fun getExceptionDetails(exception: Throwable?): Map { + val details: MutableMap = HashMap() + + if (exception == null) { + return details + } + + var code = "UNKNOWN" + var message = exception.message + var additionalData: Any? = null + + // Callable errors arrive wrapped by Tasks.await, stream errors unwrapped. + val functionsException = + exception as? FirebaseFunctionsException ?: exception.cause as? FirebaseFunctionsException + if (functionsException != null) { + code = functionsException.code.name + message = functionsException.message + additionalData = functionsException.details + + if (functionsException.cause is IOException && + "Canceled" == (functionsException.cause as IOException).message) { + // return DEADLINE_EXCEEDED for IOException cancel errors, to match iOS & Web + code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name + message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name + } else if (functionsException.cause is + InterruptedIOException // return DEADLINE_EXCEEDED for InterruptedIOException errors, to + // match iOS & Web + && "timeout" == (functionsException.cause as InterruptedIOException).message) { + code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name + message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name + } else if (functionsException.cause is IOException) { + // return UNAVAILABLE for network io errors, to match iOS & Web + code = FirebaseFunctionsException.Code.UNAVAILABLE.name + message = FirebaseFunctionsException.Code.UNAVAILABLE.name + } + } + + details["code"] = mapFunctionsErrorCode(code) + details["message"] = message + + if (additionalData != null) { + details["additionalData"] = additionalData + } + + return details + } } override fun call(arguments: Map, callback: (Result) -> Unit) { diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt index d6224703433d..7d4b6dc384b7 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt @@ -7,14 +7,17 @@ import android.os.Handler import android.os.Looper import com.google.firebase.functions.StreamResponse import io.flutter.plugin.common.EventChannel.EventSink +import java.util.concurrent.Executor import org.reactivestreams.Subscriber import org.reactivestreams.Subscription -class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber { +class StreamResponseSubscriber( + private val eventSink: EventSink?, + // Injectable so unit tests can run the posted events without a main Looper. + private val mainThreadExecutor: Executor = mainThreadHandlerExecutor(), +) : Subscriber { private var subscription: Subscription? = null - private val mainThreadHandler = Handler(Looper.getMainLooper()) - override fun onSubscribe(s: Subscription) { this.subscription = s subscription!!.request(Long.MAX_VALUE) @@ -25,23 +28,27 @@ class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber>() + + override fun success(event: Any?) { + events.add(listOf("success", event)) + } + + override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + events.add(listOf("error", errorCode, errorMessage, errorDetails)) + } + + override fun endOfStream() { + events.add(listOf("endOfStream")) + } + } + + private val eventSink = RecordingEventSink() + private val subscriber = StreamResponseSubscriber(eventSink, Executor { it.run() }) + + @Test + fun onError_sendsFunctionsErrorThenEndsStream() { + val message = "The function must be called while authenticated." + + subscriber.onError( + TestFunctionsExceptions.create( + message, FirebaseFunctionsException.Code.UNAUTHENTICATED, mapOf("foo" to "bar"), null)) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + message, + mapOf( + "code" to "unauthenticated", + "message" to message, + "additionalData" to mapOf("foo" to "bar"))), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_mapsNetworkFailuresLikeCallableErrors() { + subscriber.onError( + TestFunctionsExceptions.create( + "INTERNAL", + FirebaseFunctionsException.Code.INTERNAL, + null, + IOException("Failed to connect"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "INTERNAL", + mapOf("code" to "unavailable", "message" to "UNAVAILABLE")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_reportsOtherThrowablesAsUnknown() { + subscriber.onError(IllegalStateException("boom")) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "boom", + mapOf("code" to "unknown", "message" to "boom")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onComplete_endsStream() { + subscriber.onComplete() + + assertEquals(listOf(listOf("endOfStream")), eventSink.events) + } +} diff --git a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart index b2f088f9cd01..ffcb7c23058e 100644 --- a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart +++ b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart @@ -406,6 +406,22 @@ void main() { }, ); + test('emits a [FirebaseFunctionsException] on error', () async { + await expectLater( + callable.stream({}), + emitsInOrder([ + emitsError( + isA().having( + (e) => e.message, + 'message', + contains('Invalid test requested.'), + ), + ), + emitsDone, + ]), + ); + }); + test('should emit a [Result] as last value', () async { final stream = await callable.stream().last; expect( diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart index e7e1b0ffe5c1..014576c86114 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart @@ -80,7 +80,12 @@ class MethodChannelHttpsCallable extends HttpsCallablePlatform { 'allowInsecureTokenAttachment': functions.allowInsecureTokenAttachment, 'timeout': options.timeout.inMilliseconds, }; - yield* channel.receiveBroadcastStream(eventData).map(_convertNested); + // Stream errors are forwarded by `yield*` rather than thrown into the + // `catch` below, so they are converted on the stream itself. + yield* channel + .receiveBroadcastStream(eventData) + .handleError(convertPlatformException) + .map(_convertNested); } catch (e, s) { convertPlatformException(e, s); } diff --git a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart index 421592b25fce..370c696feca7 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart @@ -28,6 +28,7 @@ void main() { HttpsCallableOptions kOptions = HttpsCallableOptions(); String kPlatformExceptionMessage = 'Mock platform exception thrown'; Map? lastCallArguments; + void Function(String eventChannelId)? onRegisterEventChannel; group('$MethodChannelHttpsCallable', () { setUpAll(() async { @@ -43,6 +44,8 @@ void main() { code: 'UNKNOWN', message: kPlatformExceptionMessage); } return kParameters; + }, (arguments) { + onRegisterEventChannel?.call(arguments['eventChannelId']! as String); })); functions = @@ -60,6 +63,7 @@ void main() { mockPlatformExceptionThrown = false; mockExceptionThrown = false; lastCallArguments = null; + onRegisterEventChannel = null; functions!.allowInsecureTokenAttachment = false; httpsCallable!.options = kOptions; }); @@ -166,19 +170,97 @@ void main() { isTrue, ); }); + + group('errors', () { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + // Mocks the native side of the stream's event channel, which is only + // known once `stream()` registers it. + void mockNativeStream( + void Function(MockStreamHandlerEventSink events) onListen, + ) { + onRegisterEventChannel = (eventChannelId) { + final channel = EventChannel( + 'plugins.flutter.io/firebase_functions/$eventChannelId'); + messenger.setMockStreamHandler( + channel, + MockStreamHandler.inline( + onListen: (arguments, events) => onListen(events), + ), + ); + addTearDown(() => messenger.setMockStreamHandler(channel, null)); + }; + } + + test('are emitted as [FirebaseFunctionsException]', () async { + mockNativeStream((events) { + events.success({'message': 'chunk'}); + events.error( + code: 'firebase_functions', + message: 'The function must be called while authenticated.', + details: { + 'code': 'unauthenticated', + 'message': 'The function must be called while authenticated.', + 'additionalData': {'foo': 'bar'}, + }, + ); + events.endOfStream(); + }); + + await expectLater( + httpsCallable!.stream(null), + emitsInOrder([ + {'message': 'chunk'}, + emitsError( + isA() + .having((e) => e.code, 'code', 'unauthenticated') + .having((e) => e.message, 'message', + 'The function must be called while authenticated.') + .having((e) => e.details, 'details', {'foo': 'bar'}), + ), + emitsDone, + ]), + ); + }); + + test('without details are emitted as [FirebaseFunctionsException]', + () async { + mockNativeStream((events) { + events.error(code: 'firebase_functions', message: 'Bad arguments'); + events.endOfStream(); + }); + + await expectLater( + httpsCallable!.stream(null), + emitsInOrder([ + emitsError( + isA() + .having((e) => e.code, 'code', 'unknown') + .having((e) => e.message, 'message', 'Bad arguments'), + ), + emitsDone, + ]), + ); + }); + }); }); }); } class _TestCloudFunctionsHostApi implements TestCloudFunctionsHostApi { - _TestCloudFunctionsHostApi(this.callHandler); + _TestCloudFunctionsHostApi( + this.callHandler, this.registerEventChannelHandler); final Future Function(Map arguments) callHandler; + final void Function(Map arguments) + registerEventChannelHandler; @override Future call(Map arguments) => callHandler(arguments); @override - Future registerEventChannel(Map arguments) async {} + Future registerEventChannel(Map arguments) async => + registerEventChannelHandler(arguments); } From 0e9d065763d9edf919c9071f4ab495273a97fa5b Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 15:34:09 +0200 Subject: [PATCH 2/4] fix(cloud_functions): report the real code for stream errors on iOS and macOS iOS/macOS: stream errors were always reported with the code `unknown`. They now use the same code, message and details mapping as non-streaming calls (for example `invalid-argument` for an `HttpsError`). The stream also ends natively after an error, so errored streams are no longer left open (a small leak). Dart: a stream error with no message, in both the PlatformException and its details, no longer surfaces as a TypeError; it becomes a FirebaseFunctionsException with an empty message. Android: a stream that fails before subscribing now sends the error details and ends the stream, like errors from the function itself, and cancelling such a stream no longer throws a NullPointerException. Known limitation: on Android the Firebase SDK reports every stream error as INTERNAL, with the function's code only inside the message (PublisherStream.processEvent), so Android apps see `code: 'internal'` for stream errors until the SDK is fixed. cloud_functions still allows cloud_functions_platform_interface ^6.0.7. With an older platform interface, these errors arrive as a raw PlatformException rather than a FirebaseFunctionsException. --- .../FirebaseFunctionsStreamHandler.kt | 7 +++- .../functions/TestFirebaseFunctions.java | 27 ++++++++++++ .../FirebaseFunctionsStreamHandlerTest.kt | 41 +++++++++++++++++++ .../firebase/functions/RecordingEventSink.kt | 23 +++++++++++ .../functions/StreamResponseSubscriberTest.kt | 17 -------- .../example/integration_test/e2e_test.dart | 17 +++++--- .../FirebaseFunctionsPlugin.swift | 6 +-- .../FunctionsStreamHandler.swift | 12 +++--- .../lib/src/firebase_functions_exception.dart | 6 +-- .../src/method_channel/utils/exception.dart | 2 +- .../method_channel_https_callable_test.dart | 23 +++++++++++ .../method_channel/utils/exception_test.dart | 14 +++++++ 12 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFirebaseFunctions.java create mode 100644 packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt create mode 100644 packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt index 5b2361db2c9f..56588a2928bd 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt @@ -25,7 +25,8 @@ class FirebaseFunctionsStreamHandler(private val firebaseFunctions: FirebaseFunc } override fun onCancel(arguments: Any) { - subscriber!!.cancel() + // Null when the stream failed before subscribing. + subscriber?.cancel() } private fun httpsStreamCall(arguments: Map, events: EventSink) { @@ -67,7 +68,9 @@ class FirebaseFunctionsStreamHandler(private val firebaseFunctions: FirebaseFunc subscriber = StreamResponseSubscriber(events) publisher.subscribe(subscriber) } catch (e: Exception) { - events.error("firebase_functions", e.message, null) + events.error( + "firebase_functions", e.message, FlutterFirebaseFunctionsPlugin.getExceptionDetails(e)) + events.endOfStream() } } } diff --git a/packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFirebaseFunctions.java b/packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFirebaseFunctions.java new file mode 100644 index 000000000000..7c436c844cbc --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFirebaseFunctions.java @@ -0,0 +1,27 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package io.flutter.plugins.firebase.functions; + +import android.content.ContextWrapper; +import com.google.android.gms.tasks.Tasks; +import com.google.firebase.functions.FirebaseFunctions; + +/** + * Creates a {@link FirebaseFunctions} for tests that never reach the network. Its constructor is + * Kotlin {@code internal}, so Kotlin code outside the SDK cannot call it, but Java can. + */ +final class TestFirebaseFunctions { + private TestFirebaseFunctions() {} + + static FirebaseFunctions create() { + return new FirebaseFunctions( + new ContextWrapper(null), + "test-project", + "us-central1", + limitedUseAppCheckTokens -> Tasks.forResult(null), + Runnable::run, + // Drops the security provider install, which needs a device. + command -> {}); + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt new file mode 100644 index 000000000000..2a31a205a2d6 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt @@ -0,0 +1,41 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package io.flutter.plugins.firebase.functions + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FirebaseFunctionsStreamHandlerTest { + private val eventSink = RecordingEventSink() + private val handler = FirebaseFunctionsStreamHandler(TestFirebaseFunctions.create()) + + // Sets neither functionName nor functionUri, so the stream fails before subscribing. + private val invalidArguments = mapOf("limitedUseAppCheckToken" to false) + + @Test + fun onListen_setupFailureSendsErrorThenEndsStream() { + handler.onListen(invalidArguments, eventSink) + + val message = "Either functionName or functionUri must be set" + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + message, + mapOf("code" to "unknown", "message" to message)), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onCancel_afterSetupFailureDoesNotThrow() { + handler.onListen(invalidArguments, eventSink) + + handler.onCancel(invalidArguments) + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt new file mode 100644 index 000000000000..01e490b93537 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt @@ -0,0 +1,23 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package io.flutter.plugins.firebase.functions + +import io.flutter.plugin.common.EventChannel.EventSink + +/** Records the events sent to it, in order. */ +class RecordingEventSink : EventSink { + val events = mutableListOf>() + + override fun success(event: Any?) { + events.add(listOf("success", event)) + } + + override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + events.add(listOf("error", errorCode, errorMessage, errorDetails)) + } + + override fun endOfStream() { + events.add(listOf("endOfStream")) + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt index 8fb87b40941a..3802847ccfb1 100644 --- a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt @@ -4,7 +4,6 @@ package io.flutter.plugins.firebase.functions import com.google.firebase.functions.FirebaseFunctionsException -import io.flutter.plugin.common.EventChannel.EventSink import java.io.IOException import java.util.concurrent.Executor import org.junit.Assert.assertEquals @@ -14,22 +13,6 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class StreamResponseSubscriberTest { - private class RecordingEventSink : EventSink { - val events = mutableListOf>() - - override fun success(event: Any?) { - events.add(listOf("success", event)) - } - - override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { - events.add(listOf("error", errorCode, errorMessage, errorDetails)) - } - - override fun endOfStream() { - events.add(listOf("endOfStream")) - } - } - private val eventSink = RecordingEventSink() private val subscriber = StreamResponseSubscriber(eventSink, Executor { it.run() }) diff --git a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart index cdc0772948c9..9a720fec231e 100644 --- a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart +++ b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart @@ -426,15 +426,22 @@ void main() { ); test('emits a [FirebaseFunctionsException] on error', () async { + // The Android SDK reports every stream error as INTERNAL, with the + // server's code only inside the message (PublisherStream.processEvent). + final expectedCode = + !kIsWeb && Platform.isAndroid ? 'internal' : 'invalid-argument'; + await expectLater( callable.stream({}), emitsInOrder([ emitsError( - isA().having( - (e) => e.message, - 'message', - contains('Invalid test requested.'), - ), + isA() + .having((e) => e.code, 'code', expectedCode) + .having( + (e) => e.message, + 'message', + contains('Invalid test requested.'), + ), ), emitsDone, ]), diff --git a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift index 0e679295c8c7..4268f681caac 100644 --- a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift +++ b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift @@ -141,7 +141,7 @@ public class FirebaseFunctionsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt function.call(parameters) { result, error in if let error { - let flutterError = self.createFlutterError(from: error) + let flutterError = Self.createFlutterError(from: error) completion(nil, flutterError) } else { completion(result?.data, nil) @@ -156,7 +156,7 @@ public class FirebaseFunctionsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt return Functions.functions(app: app, region: region ?? "") } - private func createFlutterError(from error: Error) -> FlutterError { + static func createFlutterError(from error: Error) -> FlutterError { let nsError = error as NSError var errorCode = "unknown" var additionalDetails: [String: Any] = [:] @@ -179,7 +179,7 @@ public class FirebaseFunctionsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt ) } - private func mapFunctionsErrorCode(_ code: Int) -> String { + private static func mapFunctionsErrorCode(_ code: Int) -> String { switch code { case FunctionsErrorCode.aborted.rawValue: return "aborted" case FunctionsErrorCode.alreadyExists.rawValue: return "already-exists" diff --git a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift index dbf12e83156b..2422e6e4a5d9 100644 --- a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift +++ b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift @@ -44,6 +44,7 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { details: nil ) ) + events(FlutterEndOfEventStream) } return } @@ -86,6 +87,7 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { details: nil ) ) + events(FlutterEndOfEventStream) } return } @@ -112,13 +114,8 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { } } catch { await MainActor.run { - events( - FlutterError( - code: "unknown", - message: error.localizedDescription, - details: ["code": "unknown", "message": error.localizedDescription] - ) - ) + events(FirebaseFunctionsPlugin.createFlutterError(from: error)) + events(FlutterEndOfEventStream) } } } else { @@ -130,6 +127,7 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { details: nil ) ) + events(FlutterEndOfEventStream) } } } diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart index 5634790dfaee..8c1957565f4f 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart @@ -52,9 +52,9 @@ import 'package:meta/meta.dart'; /// code, [message] its message and [details] its details. See /// [Handle errors](https://firebase.google.com/docs/functions/callable#handle-errors-client). /// -/// Errors from `HttpsCallable.stream` are reported with these codes on web -/// only. On Apple platforms, stream errors have the code `unknown`. On -/// Android, a stream that fails ends without an error. +/// Errors from `HttpsCallable.stream` are reported with these codes too, +/// except on Android, where the Firebase SDK reports errors sent by the +/// function as `internal`, with the function's code and message in [message]. class FirebaseFunctionsException extends FirebaseException implements Exception { // ignore: public_member_api_docs diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart index b97fd057914a..c006a42ff1ce 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart @@ -46,7 +46,7 @@ FirebaseException platformExceptionToFirebaseFunctionsException( return FirebaseFunctionsException( code: code, - message: message!, + message: message ?? '', details: additionalData, stackTrace: stackTrace, ); diff --git a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart index 370c696feca7..9a4a371e216d 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart @@ -243,6 +243,29 @@ void main() { ]), ); }); + + test('without a message are emitted as [FirebaseFunctionsException]', + () async { + mockNativeStream((events) { + events.error( + code: 'firebase_functions', + details: {'code': 'unknown'}, + ); + events.endOfStream(); + }); + + await expectLater( + httpsCallable!.stream(null), + emitsInOrder([ + emitsError( + isA() + .having((e) => e.code, 'code', 'unknown') + .having((e) => e.message, 'message', isEmpty), + ), + emitsDone, + ]), + ); + }); }); }); }); diff --git a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart index 0af22be8f48e..72a28e48f40d 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart @@ -42,6 +42,20 @@ void main() { ); }); + test('should tolerate a missing message', () async { + PlatformException platformException = PlatformException( + code: 'foo', details: {'code': 'internal', 'message': null}); + + expect( + () => convertPlatformException(platformException, StackTrace.empty), + throwsA( + isA() + .having((e) => e.code, 'code', 'internal') + .having((e) => e.message, 'message', isEmpty), + ), + ); + }); + test('should override code and message if provided to additional details', () async { String code = 'baz'; From c10dc156580dcbf071a81bc28a458fc45ccef296 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 22 Oct 2026 12:35:12 +0200 Subject: [PATCH 3/4] fix(cloud_functions,android): report the code and message the function sent for stream errors The Android SDK reports an error sent by the function during a stream as INTERNAL, with the decoded error ({status, message, details}) as details and its toString() as message. The plugin now reads the code, message and details from it, as FirebaseFunctionsException.fromResponse does for calls, so stream errors on Android match iOS, macOS and web (e.g. invalid-argument rather than internal). Like fromResponse, an unknown status makes the error internal, without message or details. --- .../functions/StreamResponseSubscriber.kt | 28 ++++++- .../functions/StreamResponseSubscriberTest.kt | 79 +++++++++++++++++++ .../example/integration_test/e2e_test.dart | 7 +- .../lib/src/firebase_functions_exception.dart | 4 +- 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt index 7d4b6dc384b7..46899fe0ef35 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt @@ -5,6 +5,7 @@ package io.flutter.plugins.firebase.functions import android.os.Handler import android.os.Looper +import com.google.firebase.functions.FirebaseFunctionsException import com.google.firebase.functions.StreamResponse import io.flutter.plugin.common.EventChannel.EventSink import java.util.concurrent.Executor @@ -38,14 +39,37 @@ class StreamResponseSubscriber( override fun onError(t: Throwable) { if (eventSink != null) { + val functionError = functionError(t) + val message = functionError?.get("message") as String? ?: t.message + val details = functionError ?: FlutterFirebaseFunctionsPlugin.getExceptionDetails(t) mainThreadExecutor.execute { - eventSink.error( - "firebase_functions", t.message, FlutterFirebaseFunctionsPlugin.getExceptionDetails(t)) + eventSink.error("firebase_functions", message, details) eventSink.endOfStream() } } } + // The SDK reports an error the function sent with code INTERNAL, the decoded error + // ({status, message, details}) as details, and its toString() as message. Read the real code, + // message and details from it, as FirebaseFunctionsException.fromResponse does for calls. Like + // fromResponse, an unknown status makes the whole error INTERNAL, without message or details. + private fun functionError(t: Throwable): Map? { + val exception = t as? FirebaseFunctionsException ?: return null + if (exception.code != FirebaseFunctionsException.Code.INTERNAL) return null + val error = exception.details as? Map<*, *> ?: return null + val status = error["status"] as? String ?: FirebaseFunctionsException.Code.INTERNAL.name + val code = + FirebaseFunctionsException.Code.values().firstOrNull { it.name == status } + ?: return hashMapOf("code" to "internal", "message" to "INTERNAL") + val message = (error["message"] as? String)?.takeIf { it.isNotEmpty() } ?: code.name + val details: MutableMap = + hashMapOf( + "code" to FlutterFirebaseFunctionsPlugin.mapFunctionsErrorCode(code.name), + "message" to message) + error["details"]?.let { details["additionalData"] = it } + return details + } + override fun onComplete() { if (eventSink != null) { mainThreadExecutor.execute { eventSink.endOfStream() } diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt index 3802847ccfb1..34c3ec16bc3d 100644 --- a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt @@ -38,6 +38,85 @@ class StreamResponseSubscriberTest { eventSink.events) } + // PublisherStream reports an error event from the function as INTERNAL, with the decoded error + // as details and its toString() as message. + private fun streamErrorEvent(error: Map) = + TestFunctionsExceptions.create( + error.toString(), FirebaseFunctionsException.Code.INTERNAL, error, null) + + @Test + fun onError_reportsTheCodeAndMessageTheFunctionSent() { + subscriber.onError( + streamErrorEvent( + mapOf( + "status" to "INVALID_ARGUMENT", + "message" to "Invalid test requested.", + "details" to mapOf("field" to "type")))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "Invalid test requested.", + mapOf( + "code" to "invalid-argument", + "message" to "Invalid test requested.", + "additionalData" to mapOf("field" to "type"))), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_reportsAnUnknownStatusAsInternal() { + subscriber.onError( + streamErrorEvent( + mapOf( + "status" to "NOT_A_CODE", + "message" to "Something went wrong.", + "details" to mapOf("field" to "type")))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "INTERNAL", + mapOf("code" to "internal", "message" to "INTERNAL")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_keepsTheMessageWhenTheFunctionSentNoStatus() { + subscriber.onError(streamErrorEvent(mapOf("message" to "Something went wrong."))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "Something went wrong.", + mapOf("code" to "internal", "message" to "Something went wrong.")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_usesTheCodeNameWhenTheFunctionSentNoMessage() { + subscriber.onError(streamErrorEvent(mapOf("status" to "NOT_FOUND"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "NOT_FOUND", + mapOf("code" to "not-found", "message" to "NOT_FOUND")), + listOf("endOfStream")), + eventSink.events) + } + @Test fun onError_mapsNetworkFailuresLikeCallableErrors() { subscriber.onError( diff --git a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart index 9a720fec231e..75a83a5d2c05 100644 --- a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart +++ b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart @@ -426,17 +426,12 @@ void main() { ); test('emits a [FirebaseFunctionsException] on error', () async { - // The Android SDK reports every stream error as INTERNAL, with the - // server's code only inside the message (PublisherStream.processEvent). - final expectedCode = - !kIsWeb && Platform.isAndroid ? 'internal' : 'invalid-argument'; - await expectLater( callable.stream({}), emitsInOrder([ emitsError( isA() - .having((e) => e.code, 'code', expectedCode) + .having((e) => e.code, 'code', 'invalid-argument') .having( (e) => e.message, 'message', diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart index 8c1957565f4f..3eacb87052e3 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart @@ -52,9 +52,7 @@ import 'package:meta/meta.dart'; /// code, [message] its message and [details] its details. See /// [Handle errors](https://firebase.google.com/docs/functions/callable#handle-errors-client). /// -/// Errors from `HttpsCallable.stream` are reported with these codes too, -/// except on Android, where the Firebase SDK reports errors sent by the -/// function as `internal`, with the function's code and message in [message]. +/// Errors from `HttpsCallable.stream` are reported with these codes too. class FirebaseFunctionsException extends FirebaseException implements Exception { // ignore: public_member_api_docs From ac7923073e5adc7c663d3759c3456781f3ca52ce Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 22 Oct 2026 14:02:59 +0200 Subject: [PATCH 4/4] fix(cloud_functions,android): don't crash when a stream fails while reading When reading a stream fails (a timeout or a dropped connection after the response started, or invalid JSON), PublisherStream passes the error as the exception's details rather than its cause. The plugin sent it as additionalData, which the event channel codec can't encode, so the app crashed on the main thread. The error is now used as the cause, so a timeout maps to deadline-exceeded and an IO error to unavailable, as for calls, and is never sent as details. The test event sink now encodes events like EventChannel, so a value the codec can't send fails the test. --- .../FlutterFirebaseFunctionsPlugin.kt | 16 +++--- .../firebase/functions/RecordingEventSink.kt | 8 ++- .../functions/StreamResponseSubscriberTest.kt | 52 +++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt index a97bd24eba8e..6694c2037c41 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt @@ -142,20 +142,20 @@ class FlutterFirebaseFunctionsPlugin : FlutterPlugin, FlutterFirebasePlugin, Clo if (functionsException != null) { code = functionsException.code.name message = functionsException.message - additionalData = functionsException.details + // A stream that fails while reading gets the error as details, not as cause + // (PublisherStream.processSSEStream). The codec can't send a Throwable. + val cause = functionsException.cause ?: functionsException.details as? Throwable + additionalData = functionsException.details.takeUnless { it is Throwable } - if (functionsException.cause is IOException && - "Canceled" == (functionsException.cause as IOException).message) { + if (cause is IOException && "Canceled" == cause.message) { // return DEADLINE_EXCEEDED for IOException cancel errors, to match iOS & Web code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - } else if (functionsException.cause is - InterruptedIOException // return DEADLINE_EXCEEDED for InterruptedIOException errors, to - // match iOS & Web - && "timeout" == (functionsException.cause as InterruptedIOException).message) { + } else if (cause is InterruptedIOException && "timeout" == cause.message) { + // return DEADLINE_EXCEEDED for InterruptedIOException errors, to match iOS & Web code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - } else if (functionsException.cause is IOException) { + } else if (cause is IOException) { // return UNAVAILABLE for network io errors, to match iOS & Web code = FirebaseFunctionsException.Code.UNAVAILABLE.name message = FirebaseFunctionsException.Code.UNAVAILABLE.name diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt index 01e490b93537..27cc872ee4f3 100644 --- a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt @@ -4,16 +4,22 @@ package io.flutter.plugins.firebase.functions import io.flutter.plugin.common.EventChannel.EventSink +import io.flutter.plugin.common.StandardMethodCodec -/** Records the events sent to it, in order. */ +/** + * Records the events sent to it, in order. Encodes them first, as EventChannel does, so a value the + * codec can't send fails the test instead of crashing the app. + */ class RecordingEventSink : EventSink { val events = mutableListOf>() override fun success(event: Any?) { + StandardMethodCodec.INSTANCE.encodeSuccessEnvelope(event) events.add(listOf("success", event)) } override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + StandardMethodCodec.INSTANCE.encodeErrorEnvelope(errorCode!!, errorMessage, errorDetails) events.add(listOf("error", errorCode, errorMessage, errorDetails)) } diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt index 34c3ec16bc3d..306ecf37bb1d 100644 --- a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt @@ -5,6 +5,7 @@ package io.flutter.plugins.firebase.functions import com.google.firebase.functions.FirebaseFunctionsException import java.io.IOException +import java.net.SocketTimeoutException import java.util.concurrent.Executor import org.junit.Assert.assertEquals import org.junit.Test @@ -137,6 +138,57 @@ class StreamResponseSubscriberTest { eventSink.events) } + // PublisherStream reports an error while reading the stream as INTERNAL, with the error as + // details rather than as cause. + private fun streamReadError(message: String, error: Throwable) = + TestFunctionsExceptions.create(message, FirebaseFunctionsException.Code.INTERNAL, error, null) + + @Test + fun onError_mapsStreamReadFailuresLikeNetworkErrors() { + subscriber.onError( + streamReadError("unexpected end of stream", IOException("unexpected end of stream"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "unexpected end of stream", + mapOf("code" to "unavailable", "message" to "UNAVAILABLE")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_mapsStreamReadTimeoutsToDeadlineExceeded() { + subscriber.onError(streamReadError("timeout", SocketTimeoutException("timeout"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "timeout", + mapOf("code" to "deadline-exceeded", "message" to "DEADLINE_EXCEEDED")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_doesNotSendTheErrorBehindInvalidJson() { + subscriber.onError(streamReadError("Invalid JSON: {", IllegalStateException("Unterminated"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "Invalid JSON: {", + mapOf("code" to "internal", "message" to "Invalid JSON: {")), + listOf("endOfStream")), + eventSink.events) + } + @Test fun onError_reportsOtherThrowablesAsUnknown() { subscriber.onError(IllegalStateException("boom"))