Skip to content

feat(messaging): Add FCM v1 topic management - #162

Merged
akalex merged 11 commits into
masterfrom
feature/fcm-v1-topic-management
Oct 4, 2026
Merged

akalex merged 11 commits into
masterfrom
feature/fcm-v1-topic-management

Conversation

@akalex

@akalex akalex commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

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 DeprecationWarning on the two IID methods. The test suite from master passes 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:
    • Subscribe: POST /v1/projects/{project_id}/registrations/{token}/topicSubscriptions?topic_name={topic}
    • Unsubscribe: DELETE /v1/projects/{project_id}/registrations/{token}/topicSubscriptions/{topic}?allow_missing=true
  • subscribe_devices_to_topic() / unsubscribe_devices_from_topic() are deprecated. They still use IID and behave as before, but now emit a DeprecationWarning pointing to the new methods.

Behavior of the new methods

  • Errors are reported per token. HTTP failures (401, timeouts, connection errors) appear in TopicManagementResponse.errors; TopicManagementResponse.exception is not set by these methods.
  • Error reasons, in order of preference: the FCM error code from details (e.g. UNREGISTERED), the status field, a plain string error field, 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.
  • Validation raises ValueError: device_tokens must be a non-empty sequence of up to 1000 non-empty strings, and topic_name must match [a-zA-Z0-9-_.~%]+, optionally prefixed with /topics/.
  • Idempotency: subscribing an already-subscribed token (HTTP 409, or a status of ALREADY_EXISTS or CONFLICT) 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

  • At most 100 requests in flight per call (lower if RequestLimits.max_connections is lower), as in the official Admin SDKs. The semaphore is created per call, so a client can be reused across event loops.
  • Headers and the access token are prepared once per call. If the token fetch fails with an HTTP error, that reason is reported for every token and no subscription requests are sent.
  • Duplicate device tokens are sent once; each outcome is mapped back to every index where the token appears.
  • Device tokens and topic names are percent-encoded in the path, so / and : in a token are escaped.
  • If an unexpected non-HTTP error occurs, the remaining requests are cancelled and awaited before it is raised.

Compatibility

  • Shared code paths are unchanged: send(), send_each(), the IID methods, CredentialManager and the existing error handlers behave as in 6.2.2. The new error handler has its own parsing rather than modifying _parse_platform_error().
  • Known gap, kept for compatibility: an error response from the OAuth token endpoint (e.g. a revoked key) still raises KeyError('expires_in') from every method, including the new ones. Fixing it would change send() / send_each() behavior, so it's left for a release that allows behavior changes.

Differences from the reference PR

  • New method names instead of switching the existing methods, to keep this release backward compatible.
  • Duplicate device tokens are deduplicated, to avoid concurrent requests for the same subscription.
  • No _async variants: this library is async-only.

Testing

  • make test: 215 passed, 12 skipped (integration tests that require FIREBASE_SERVICE_ACCOUNT_KEY).
  • master's test suite run unmodified against this branch: 138 passed, 8 skipped.
  • CI integration-test job against the real FCM project: 12 passed, none skipped. That's 8 dry-run send() 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 mypy and make pre_commit pass.
  • No real device is available, so the success and "already subscribed" paths can't be exercised against FCM before merging. The integration tests confirm the endpoint, HTTP methods and credentials. The response handling is tested with mocked responses, and the request shapes and 409 handling follow the official Python, Node, Go, Java and .NET Admin SDKs, since the public FCM REST reference doesn't document this endpoint.

Not in this PR

  • Retry/backoff for 429 / 503. A call can issue up to 1000 requests where IID made one; callers currently retry failed tokens themselves.

🤖 Generated with Claude Code

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 akalex self-assigned this Oct 1, 2026
@akalex
akalex requested a review from Gera3dartist October 1, 2026 17:44
Comment thread async_firebase/base.py Outdated
Comment thread async_firebase/base.py Outdated
Comment thread pyproject.toml
Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/responses.py Outdated
Comment thread async_firebase/client.py
Comment thread async_firebase/base.py Outdated
Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/base.py
Comment thread async_firebase/messages.py Outdated
akalex added 2 commits October 1, 2026 21:00
- 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
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.
@akalex akalex changed the title feat(messaging): Migrate topic management to FCM v1 API feat(messaging): Add FCM v1 topic management Oct 2, 2026
akalex and others added 2 commits October 2, 2026 20:50
- 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 akalex left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/client.py Outdated
Comment thread async_firebase/client.py
Comment thread async_firebase/base.py Outdated
Comment thread async_firebase/client.py
Comment thread async_firebase/responses.py
Comment thread async_firebase/responses.py Outdated
akalex added 4 commits October 3, 2026 19:20
- 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.
@akalex
akalex merged commit fa7a598 into master Oct 4, 2026
12 checks passed
@akalex
akalex deleted the feature/fcm-v1-topic-management branch October 4, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants