Skip to content

fix(cloud_functions): surface httpsCallable stream errors as FirebaseFunctionsException - #18834

Merged
Lyokone merged 5 commits into
mainfrom
fix/functions-stream-errors
Oct 23, 2026
Merged

Lyokone merged 5 commits into
mainfrom
fix/functions-stream-errors

Conversation

@Lyokone

@Lyokone Lyokone commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Errors from httpsCallable(...).stream() did not reach apps as FirebaseFunctionsException with the right code. There were three bugs.

1. Android: an error looked like a normal end of stream. StreamResponseSubscriber.onError dropped the throwable and only called endOfStream(). So a streaming callable that failed (unauthenticated, network error, function threw) completed with no result and no error in Dart. It now sends eventSink.error(...) on the main thread, then ends the stream. The error uses the same code, message and additionalData mapping as non-streaming calls. getExceptionDetails moved to the companion object so the subscriber can share it. It now also accepts an unwrapped FirebaseFunctionsException: the stream publisher passes the exception as is, while Tasks.await wraps callable errors in an ExecutionException.

The SDK reports an error the function sent during a stream as INTERNAL, with the decoded error ({status, message, details}) as details and its toString() as message (PublisherStream.processEvent). StreamResponseSubscriber.onError reads it as FirebaseFunctionsException.fromResponse does for calls: status becomes the code, message the message, and only the inner details is kept. As in fromResponse, an unknown status makes the error internal, without message or details. This only runs on the stream path.

2. Dart: stream errors skipped the conversion. In MethodChannelHttpsCallable.stream, yield* adds the event channel's errors to the output stream. It does not throw them into the surrounding try, so convertPlatformException never ran. Apps got a raw PlatformException instead of a FirebaseFunctionsException; this is what iOS and macOS apps saw. Errors are now converted on the stream with handleError(convertPlatformException).

Reproduced on an Android emulator against the functions emulator. With testFunctionDefaultRegion, callable.stream({}) makes the function throw HttpsError('invalid-argument'):

before after
Android stream closes, no event and no error FirebaseFunctionsException (invalid-argument), then done
iOS / macOS PlatformException (unknown), stream left open FirebaseFunctionsException (invalid-argument), then done

3. iOS/macOS: every stream error had the code unknown. FunctionsStreamHandler ignored the SDK's FunctionsError code. It now uses the plugin's createFlutterError(from:), so stream errors get the same code, message and details as non-streaming calls. It also sends FlutterEndOfEventStream after every error, as Android does; before, errored streams were never closed natively (a small leak).

Review fixes:

  • A stream error with no message (neither PlatformException.message nor details['message']) made message! throw inside handleError, so the stream emitted a TypeError. The conversion now falls back to an empty message, like _flutterfire_internals.
  • Android: a stream that fails before subscribing (FirebaseFunctionsStreamHandler's catch) now sends the error details and ends the stream, like onError. onCancel no longer throws a NullPointerException when setup failed and there is no subscriber.
  • The FirebaseFunctionsException doc no longer says stream errors are unknown on Apple platforms and silent on Android.
  • Android: stream errors now carry the function's code and message (see above) instead of internal.
  • Android: a stream that failed while reading crashed the app. When the read fails after the response started (a timeout, a dropped connection, invalid JSON), PublisherStream.processSSEStream/processEvent pass the error as the exception's details, not its cause. The plugin sent that Throwable as additionalData, the event channel codec threw IllegalArgumentException: Unsupported value on the main thread, and the app died. Reproduced on the Android emulator: a 3 s callable timeout on testFunctionTimeout with its chunk at 2.9 s gives FATAL EXCEPTION: main … Unsupported value: 'java.io.InterruptedIOException: timeout'. getExceptionDetails now uses such an error as the cause (so a timeout maps to deadline-exceeded and an IO error to unavailable, as for calls) and never sends it as details. With the fix, the same run emits deadline-exceeded. The probe is too timing-dependent for CI, so unit tests cover it.

Known limit:

  • Older platform interface. cloud_functions still allows cloud_functions_platform_interface: ^6.0.7. With the new native code and an older platform interface, stream errors arrive as a raw PlatformException, which still beats a stream that ends silently.

Tests

  • Dart unit tests (cloud_functions_platform_interface).
    • method_channel_https_callable_test.dart mocks the stream's event channel. A native error with details arrives as a FirebaseFunctionsException with the right code, message and details, after earlier data events. An error without details arrives with code unknown. Both fail on main, where they receive a PlatformException.
    • An error with no message arrives as a FirebaseFunctionsException with an empty message. This test, and one in exception_test.dart, fail without the null fix ("Null check operator used on a null value").
  • Android JUnit (run by android-unit-tests).
    • StreamResponseSubscriberTest: onError sends the functions error, then ends the stream. An IOException cause maps to unavailable, as it does for callable errors, and any other throwable to unknown. onComplete only ends the stream. The subscriber now takes an Executor (by default the main-thread Handler), so the tests run without a Looper. The onError tests fail without the fix, since only endOfStream is recorded.
    • Function errors: onError_reportsTheCodeAndMessageTheFunctionSent (invalid-argument, the function's message and inner details), onError_reportsAnUnknownStatusAsInternal, onError_keepsTheMessageWhenTheFunctionSentNoStatus and onError_usesTheCodeNameWhenTheFunctionSentNoMessage.
    • Read failures: onError_mapsStreamReadFailuresLikeNetworkErrors (unavailable), onError_mapsStreamReadTimeoutsToDeadlineExceeded and onError_doesNotSendTheErrorBehindInvalidJson. RecordingEventSink now encodes each event with StandardMethodCodec, as EventChannel does, so a value the codec can't send fails the test. All three fail on the previous code with Unsupported value.
    • FirebaseFunctionsStreamHandlerTest: a setup failure sends the error with details, then ends the stream, and a later onCancel doesn't throw. Both fail on the previous code (details null with no end of stream; NPE). The test builds a real FirebaseFunctions that never reaches the network.
    • FlutterFirebaseFunctionsPluginTest gains a test for the wrapped callable path.
    • TestFunctionsExceptions.java and TestFirebaseFunctions.java exist because those constructors are Kotlin internal.
  • e2e (e2e_test.dart, e2e-functions): emits a [FirebaseFunctionsException] on error streams from testFunctionDefaultRegion with invalid input. It expects a FirebaseFunctionsException whose message contains the server message, then the end of the stream. The code must be invalid-argument on every platform.
    • Before the fix it fails on Android ("Stream closed"; internal before the review fix) and on iOS ([firebase_functions/unknown] Invalid test requested.).
    • Run locally against the Functions emulator: the full suite passes on the iPhone 17 simulator (iOS 27) and on macOS (32 passed, 4 skipped each), and the error test passes on the Android emulator with invalid-argument. Web was not run locally.

Related Issues

None found. #18824 also edits the functions e2e_test.dart; main is merged in.

Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • My PR includes unit or integration tests for all changed/updated/fixed behaviors (See Contributor Guide).
  • All existing and new tests are passing.
  • I updated/added relevant documentation (doc comments with ///).
  • The analyzer (melos run analyze) does not report any problems on my PR.
  • I read and followed the Flutter Style Guide.
  • I signed the CLA.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

  • No, this is not a breaking change.

Streams that used to end silently on Android now emit an error. On iOS and macOS, stream errors change type from PlatformException to FirebaseFunctionsException, and their code changes from unknown to the real code. None of these was the documented behaviour.

…FunctionsException

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.
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

…nd 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.
@Lyokone
Lyokone marked this pull request as ready for review October 22, 2026 09:19
eventSink.error(
"firebase_functions", t.message, FlutterFirebaseFunctionsPlugin.getExceptionDetails(t))
eventSink.endOfStream()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems android can report the function's real code. PublisherStream sets the code to INTERNAL, but details is the decoded error {status, message, details}, and the exception message is that map's toString(). In StreamResponseSubscriber.onError, parse it the way FirebaseFunctionsException.fromResponse does: status becomes the code, message becomes the message, and only the inner details is kept. Do this on the stream path only. An unknown status stays internal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c10dc15: on the stream path, onError now reads the decoded error the way fromResponse does. status becomes the code, message the message, and only the inner details is kept. As in fromResponse, an unknown status gives internal without message or details. The e2e now expects invalid-argument on Android too and passes on the emulator.

While verifying it I found a crash on this branch, fixed in ac79230. When reading the stream fails after the response started (timeout, dropped connection, bad JSON), PublisherStream passes the error as details, and the codec threw Unsupported value on the main thread. It now maps to deadline-exceeded/unavailable like calls. Details are in the PR description.

…n 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.
…eading

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.
@Lyokone
Lyokone merged commit 62218f4 into main Oct 23, 2026
39 checks passed
@Lyokone
Lyokone deleted the fix/functions-stream-errors branch October 23, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants