Skip to content

feat(fcm): Migrate topic management to FCM v1 API - #980

Open
lahirumaramba wants to merge 1 commit into
mainfrom
lm-fcm-topics
Open

feat(fcm): Migrate topic management to FCM v1 API#980
lahirumaramba wants to merge 1 commit into
mainfrom
lm-fcm-topics

Conversation

@lahirumaramba

Copy link
Copy Markdown
Member

Migrates subscribe_to_topic and unsubscribe_from_topic in the messaging module from the legacy Instance ID (IID) API to the FCM v1 Topic Subscriptions API.

Key changes:

  • Updated subscribe_to_topic and unsubscribe_from_topic to call the FCM v1 endpoints (/v1/projects/{projectId}/registrations/{token}/topicSubscriptions).
  • Added asynchronous counterparts subscribe_to_topic_async and unsubscribe_from_topic_async utilizing HttpxAsyncClient (HTTP/2).
  • Added deprecated legacy methods subscribe_to_topic_legacy and unsubscribe_from_topic_legacy with deprecation warnings.
  • Bounded client-side concurrency to 100 simultaneous requests.
  • Handled idempotency: ALREADY_EXISTS (HTTP 409) is treated as success for topic subscriptions, while NOT_FOUND (HTTP 404) is recorded as a failure for topic unsubscriptions.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request migrates the topic management functionality in the Firebase Cloud Messaging module from the legacy Instance ID (IID) API to the FCM v1 API, introducing new synchronous and asynchronous methods (subscribe_to_topic, subscribe_to_topic_async, unsubscribe_from_topic, and unsubscribe_from_topic_async) while deprecating the legacy versions. The feedback highlights a potential concurrency race condition in the multi-threaded request execution where a shared headers dictionary is mutated, and suggests extracting duplicated error-handling logic into a single helper method to improve maintainability.

Comment on lines +658 to +663
self._client.request(
method,
url=url,
headers=self._fcm_headers,
json=json_data,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Passing self._fcm_headers directly to self._client.request inside a ThreadPoolExecutor introduces a concurrency race condition. JsonHttpClient.request mutates the passed headers dictionary by calling .update(METRICS_HEADERS) on it. Since self._fcm_headers is a shared instance variable, concurrent mutations from multiple threads can lead to dictionary corruption or RuntimeError: dictionary changed size during iteration.

To prevent this, pass a copy of the headers dictionary instead.

Suggested change
self._client.request(
method,
url=url,
headers=self._fcm_headers,
json=json_data,
)
self._client.request(
method,
url=url,
headers=dict(self._fcm_headers),
json=json_data,
)

Comment on lines +757 to +804
def _build_topic_subscription_result_from_requests_error(self, error, is_subscribe):
"""Constructs a result dict from a requests error."""
if error.response is not None:
if is_subscribe and error.response.status_code == 409:
return {'success': True}
error_dict = {}
try:
parsed = error.response.json()
if isinstance(parsed, dict):
error_dict = parsed
except ValueError:
pass

error_data = error_dict.get('error')
if is_subscribe and isinstance(error_data, dict) and (
error_data.get('status') == 'ALREADY_EXISTS'
):
return {'success': True}

error_code = self._get_topic_error_code(error_dict, error.response.status_code)
return {'success': False, 'error': error_code}

return {'success': False, 'error': 'UNKNOWN_ERROR'}

def _build_topic_subscription_result_from_httpx_error(self, error, is_subscribe):
"""Constructs a result dict from an httpx error."""
if isinstance(error, httpx.HTTPStatusError):
if is_subscribe and error.response.status_code == 409:
return {'success': True}
error_dict = {}
try:
parsed = error.response.json()
if isinstance(parsed, dict):
error_dict = parsed
except ValueError:
pass

error_data = error_dict.get('error')
if is_subscribe and isinstance(error_data, dict) and (
error_data.get('status') == 'ALREADY_EXISTS'
):
return {'success': True}

error_code = self._get_topic_error_code(error_dict, error.response.status_code)
return {'success': False, 'error': error_code}

return {'success': False, 'error': 'UNKNOWN_ERROR'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The methods _build_topic_subscription_result_from_requests_error and _build_topic_subscription_result_from_httpx_error share almost identical logic for parsing error responses and handling the ALREADY_EXISTS (409) idempotency case.

To improve maintainability and reduce code duplication, we can extract the common parsing and validation logic into a single private helper method that operates on the response object.

    def _build_topic_subscription_result(self, response, is_subscribe):
        \"\"\"Constructs a result dict from a response object.\"\"\"
        if response is not None:
            if is_subscribe and response.status_code == 409:
                return {'success': True}
            error_dict = {}
            try:
                parsed = response.json()
                if isinstance(parsed, dict):
                    error_dict = parsed
            except ValueError:
                pass

            error_data = error_dict.get('error')
            if is_subscribe and isinstance(error_data, dict) and (
                error_data.get('status') == 'ALREADY_EXISTS'
            ):
                return {'success': True}

            error_code = self._get_topic_error_code(error_dict, response.status_code)
            return {'success': False, 'error': error_code}

        return {'success': False, 'error': 'UNKNOWN_ERROR'}

    def _build_topic_subscription_result_from_requests_error(self, error, is_subscribe):
        \"\"\"Constructs a result dict from a requests error.\"\"\"
        return self._build_topic_subscription_result(error.response, is_subscribe)

    def _build_topic_subscription_result_from_httpx_error(self, error, is_subscribe):
        \"\"\"Constructs a result dict from an httpx error.\"\"\"
        response = error.response if isinstance(error, httpx.HTTPStatusError) else None
        return self._build_topic_subscription_result(response, is_subscribe)

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.

1 participant