Repository navigation
feat(messaging): Add FCM v1 topic management - #162
Merged
Merged
Conversation
subscribe_devices_to_topic() and unsubscribe_devices_from_topic() now call the FCM v1 topic subscriptions endpoint instead of the Instance ID API, following firebase/firebase-admin-python#980. One request is sent per device token, at most 100 in flight (or max_connections if lower). Request-level failures (401, timeouts, connection errors) are now reported per token in TopicManagementResponse.errors instead of TopicManagementResponse.exception. Subscribing an already subscribed token (ALREADY_EXISTS / CONFLICT) counts as success; 409 ABORTED does not. Arguments are validated: up to 1000 non-empty device tokens and a well-formed topic name, optionally prefixed with /topics/. The previous IID behavior is kept as the deprecated subscribe_devices_to_topic_legacy() and unsubscribe_devices_from_topic_legacy(). Bump version to 6.3.0.
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
akalex
commented
Oct 1, 2026
- Prepare headers once per call instead of once per device token. A failed access token fetch is now reported for every token without sending requests, instead of being retried serially by each task. - Share the concurrency limit across concurrent calls on a client. - Escape the device token in the request path, so a "/" in a token can no longer change the path. - Tolerate non-dict JSON error bodies and malformed "details" when resolving error reasons, instead of raising AttributeError and aborting the whole batch.
- CredentialManager.get_access_token() now calls raise_for_status(), so
an error response from the token endpoint (e.g. a revoked key) is
handled as an HTTP error instead of escaping as KeyError('expires_in').
send() and send_each() return an FCMResponse with exception set, and
topic management reports the error for every device token.
- Topic management sends one request per unique device token and maps
each outcome back to every index where the token appears, avoiding
concurrent requests for the same subscription.
Gera3dartist
previously approved these changes
Oct 2, 2026
Bodies like {"error": "invalid_grant"} from the OAuth token endpoint, or
{"error": "INVALID_REGISTRATION"} from legacy-style APIs, now report the
string as the per-token reason instead of the generic HTTP status code.
The send() error path is unchanged.
Restore subscribe_devices_to_topic() and unsubscribe_devices_from_topic() to the Instance ID implementation, unchanged apart from a DeprecationWarning, and expose the FCM v1 implementation as the new subscribe_to_topic() and unsubscribe_from_topic(). Remove the *_legacy methods. Revert the changes to shared code paths so existing behavior matches 6.2.2: CredentialManager no longer calls raise_for_status(), and _parse_platform_error(), _get_fcm_error_type() and the IID response parser are back to their original code. The v1 error handler now has its own lenient body parsing.
- Make TOPIC_PREFIX, TOPIC_NAME_PATTERN and TopicManagementResponse.from_error_reasons() private, so 6.3.0 adds only the public names callers need. - Reword the subscribe_to_topic()/unsubscribe_from_topic() docstrings and README so they don't imply a rejected access token is reported per token. - Move the v1 error helpers above the public API section in responses, fix the public function count, and note why the FCM error code lookup is not shared with _get_fcm_error_type(). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…egacy behavior - Rename TOPIC_MANAGEMENT_MAX_CONCURRENCY to _TOPIC_MANAGEMENT_MAX_CONCURRENCY; it is an internal tuning value. - List the remaining public additions (TOPIC_MANAGEMENT_MAX_DEVICE_TOKENS and AsyncClientBase.FCM_REGISTRATIONS_ENDPOINT) in CHANGES. - Add a test that the deprecated IID methods still send input the new v1 methods reject, guarding backward compatibility.
akalex
commented
Oct 3, 2026
akalex
left a comment
Member
Author
There was a problem hiding this comment.
Review from Claude Code (/code-review high). I reproduced the two bugs on client.py (L217 TypeError, L205 cross-loop semaphore). The rest come from reading the code. The L41 finding depends on what a real FCM 409 body looks like.
- Decide "already subscribed" from the HTTP status (409) or a status of ALREADY_EXISTS/CONFLICT, before resolving the reason, matching the official Firebase Admin SDKs (Python, Node, Go, Java, .NET). An FCM errorCode in the body can no longer override it, and 409 ABORTED now counts as already subscribed, as in those SDKs. - Use only non-empty string error codes, so a malformed error body (e.g. a list status) falls back to the HTTP status instead of raising TypeError and aborting the call. - Create the concurrency semaphore per call. A semaphore cached on the client bound to the first event loop and raised RuntimeError when the client was reused from another loop. - Stop logging the request URL, which contains the device token. - Return the HTTP error from _send_topic_subscription_request and let each per-token step apply its own rule. - Clarify in CHANGES that the deprecated methods now emit a warning.
- Build TopicManagementResponse from per-token outcomes in a private client function that sets only public fields, instead of calling the private TopicManagementResponse._from_error_reasons() from another module. messages.py is back to its 6.2.2 code. - Use the fake_device_token fixture in the logging test and check both the raw and URL-encoded token, replacing the hardcoded token literal that Bandit reported as B105.
Exercise subscribe_to_topic()/unsubscribe_from_topic() against the real FCM project with a made-up registration token. FCM may accept or reject it, so the tests assert that each request is routed, authorized and answered with a Google API error or success, and that the public methods report exactly one outcome per token. This catches a wrong endpoint, HTTP method or missing permission, but cannot cover the success or "already subscribed" paths for a real device. Move the shared credential check into tests/integration.py and the fcm_client fixture into conftest.py so both integration modules use them.
The step runs every test marked integration, which now includes the topic management tests, not only FCM payload validation.
Gera3dartist
approved these changes
Oct 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds FCM v1 topic management alongside the legacy Instance ID (IID) methods, following firebase-admin-python#980. Releases as 6.3.0.
This release is backward compatible: existing methods behave as in 6.2.2, apart from a new
DeprecationWarningon the two IID methods. The test suite frommasterpasses unmodified against this branch.What's new
subscribe_to_topic()/unsubscribe_from_topic()(new) use the FCM v1 API, one request per unique device token:POST /v1/projects/{project_id}/registrations/{token}/topicSubscriptions?topic_name={topic}DELETE /v1/projects/{project_id}/registrations/{token}/topicSubscriptions/{topic}?allow_missing=truesubscribe_devices_to_topic()/unsubscribe_devices_from_topic()are deprecated. They still use IID and behave as before, but now emit aDeprecationWarningpointing to the new methods.Behavior of the new methods
401, timeouts, connection errors) appear inTopicManagementResponse.errors;TopicManagementResponse.exceptionis not set by these methods.details(e.g.UNREGISTERED), thestatusfield, a plain stringerrorfield, then a code mapped from the HTTP status. Only non-empty string values are used, so malformed error bodies fall back to the HTTP status instead of raising.ValueError:device_tokensmust be a non-empty sequence of up to 1000 non-empty strings, andtopic_namemust match[a-zA-Z0-9-_.~%]+, optionally prefixed with/topics/.409, or astatusofALREADY_EXISTSorCONFLICT) counts as success, decided before the error reason is resolved, as in the official Python, Node, Go, Java and .NET Admin SDKs. Unsubscribing a token that isn't subscribed counts as success.How it works
RequestLimits.max_connectionsis lower), as in the official Admin SDKs. The semaphore is created per call, so a client can be reused across event loops./and:in a token are escaped.Compatibility
send(),send_each(), the IID methods,CredentialManagerand the existing error handlers behave as in 6.2.2. The new error handler has its own parsing rather than modifying_parse_platform_error().KeyError('expires_in')from every method, including the new ones. Fixing it would changesend()/send_each()behavior, so it's left for a release that allows behavior changes.Differences from the reference PR
_asyncvariants: this library is async-only.Testing
make test: 215 passed, 12 skipped (integration tests that requireFIREBASE_SERVICE_ACCOUNT_KEY).master's test suite run unmodified against this branch: 138 passed, 8 skipped.integration-testjob against the real FCM project: 12 passed, none skipped. That's 8 dry-runsend()payload tests, plus 4 new topic management tests (tests/test_fcm_topic_management.py) that subscribe and unsubscribe a made-up token and assert FCM routed, authorized and answered each request.make mypyandmake pre_commitpass.Not in this PR
429/503. A call can issue up to 1000 requests where IID made one; callers currently retry failed tokens themselves.🤖 Generated with Claude Code