Skip to content

Commit 6ef5486

Browse files
committed
Cache FCM acces_token until expiration
Retrieving the Auth tokens is the slowest part of the notification process. Caching tokens will give a huge performance boost.
1 parent b8da812 commit 6ef5486

File tree

5 files changed

+82
-24
lines changed

5 files changed

+82
-24
lines changed

lib/action_push_native/service/fcm.rb

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def initialize(config)
1313
end
1414

1515
def push(notification)
16-
response = httpx_session.post("v1/projects/#{config.fetch(:project_id)}/messages:send", json: payload_from(notification), headers: { authorization: "Bearer #{access_token}" })
16+
response = httpx_session.post("v1/projects/#{config.fetch(:project_id)}/messages:send", json: payload_from(notification))
1717
handle_error(response) if response.error
1818
end
1919

@@ -22,20 +22,7 @@ def push(notification)
2222

2323
def httpx_session
2424
self.class.httpx_sessions ||= {}
25-
self.class.httpx_sessions[config] ||= build_httpx_session
26-
end
27-
28-
# FCM suggests at least a 10s timeout for requests, we set 15 to add some buffer.
29-
# https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts
30-
DEFAULT_REQUEST_TIMEOUT = 15.seconds
31-
DEFAULT_POOL_SIZE = 5
32-
33-
def build_httpx_session
34-
HTTPX.
35-
plugin(:persistent, close_on_fork: true).
36-
with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }).
37-
with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }).
38-
with(origin: "https://fcm.googleapis.com")
25+
self.class.httpx_sessions[config] ||= HttpxSession.new(config)
3926
end
4027

4128
def payload_from(notification)
@@ -75,13 +62,6 @@ def stringify(hash)
7562
hash.compact.transform_values(&:to_s)
7663
end
7764

78-
def access_token
79-
authorizer = Google::Auth::ServiceAccountCredentials.make_creds \
80-
json_key_io: StringIO.new(config.fetch(:encryption_key)),
81-
scope: "https://www.googleapis.com/auth/firebase.messaging"
82-
authorizer.fetch_access_token!["access_token"]
83-
end
84-
8565
def handle_error(response)
8666
if response.is_a?(HTTPX::ErrorResponse)
8767
handle_network_error(response.error)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
class ActionPushNative::Service::Fcm::HttpxSession
4+
# FCM suggests at least a 10s timeout for requests, we set 15 to add some buffer.
5+
# https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts
6+
DEFAULT_REQUEST_TIMEOUT = 15.seconds
7+
DEFAULT_POOL_SIZE = 5
8+
9+
def initialize(config)
10+
@session = \
11+
HTTPX.
12+
plugin(:persistent, close_on_fork: true).
13+
with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }).
14+
with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }).
15+
with(origin: "https://fcm.googleapis.com")
16+
@token_provider = ActionPushNative::Service::Fcm::TokenProvider.new(config)
17+
end
18+
19+
def post(*uri, **options)
20+
options[:headers] ||= {}
21+
options[:headers][:authorization] = "Bearer #{token_provider.fresh_access_token}"
22+
session.post(*uri, **options)
23+
end
24+
25+
private
26+
attr_reader :token_provider, :session
27+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
class ActionPushNative::Service::Fcm::TokenProvider
4+
EXPIRED = -1
5+
6+
def initialize(config)
7+
@config = config
8+
@expires_at = EXPIRED
9+
end
10+
11+
def fresh_access_token
12+
regenerate_if_expired
13+
token
14+
end
15+
16+
private
17+
attr_reader :config, :token, :expires_at
18+
19+
def regenerate_if_expired
20+
regenerate if Time.now.utc >= expires_at
21+
end
22+
23+
REFRESH_BUFFER = 1.minutes
24+
25+
def regenerate
26+
authorizer = Google::Auth::ServiceAccountCredentials.make_creds \
27+
json_key_io: StringIO.new(config.fetch(:encryption_key)),
28+
scope: "https://www.googleapis.com/auth/firebase.messaging"
29+
oauth2 = authorizer.fetch_access_token!
30+
@token = oauth2["access_token"]
31+
@expires_at = oauth2["expires_in"].seconds.from_now.utc - REFRESH_BUFFER
32+
end
33+
end

test/jobs/action_push_native/notification_job_test.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ class NotificationJobTest < ActiveSupport::TestCase
3838
device = action_push_native_devices(:pixel9)
3939
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send").
4040
to_raise(SocketError.new)
41-
ActionPushNative::Service::Fcm.any_instance.stubs(:access_token).returns("fake_access_token")
41+
authorizer = stub("authorizer")
42+
authorizer.stubs(:fetch_access_token!).returns({ "access_token" => "fake_access_token", "expires_in" => 3599 })
43+
Google::Auth::ServiceAccountCredentials.stubs(:make_creds).returns(authorizer)
4244

4345
assert_enqueued_jobs 1, only: ActionPushNative::NotificationJob do
4446
ActionPushNative::NotificationJob.perform_later("ApplicationPushNotification", @notification_attributes, device)

test/lib/action_push_native/service/fcm_test.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ class FcmTest < ActiveSupport::TestCase
7474
end
7575
end
7676

77+
test "access tokens are refreshed" do
78+
@fcm.httpx_sessions = {}
79+
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send")
80+
81+
authorizer = stub("authorizer")
82+
authorizer.stubs(:fetch_access_token!).once.returns({ "access_token" => "fake_access_token", "expires_in" => 3599 })
83+
Google::Auth::ServiceAccountCredentials.stubs(:make_creds).returns(authorizer)
84+
@fcm.push(@notification)
85+
@fcm.push(@notification)
86+
87+
authorizer.stubs(:fetch_access_token!).once.returns({ "access_token" => "fake_access_token", "expires_in" => 3599 })
88+
travel 3600 do
89+
@fcm.push(@notification)
90+
end
91+
end
92+
7793
private
7894
def build_notification
7995
ActionPushNative::Notification.
@@ -93,7 +109,7 @@ def build_notification
93109

94110
def stub_authorizer
95111
authorizer = stub("authorizer")
96-
authorizer.stubs(:fetch_access_token!).returns({ "access_token" => "fake_access_token" })
112+
authorizer.stubs(:fetch_access_token!).returns({ "access_token" => "fake_access_token", "expires_in" => 3599 })
97113
Google::Auth::ServiceAccountCredentials.stubs(:make_creds).returns(authorizer)
98114
end
99115
end

0 commit comments

Comments
 (0)