Skip to content

Commit b8da812

Browse files
committed
FCM: HTTP persistent connections with HTTPX
As a result of this change, FCM connections now also make use of a connection pool. Accordingly, a configuration option, similar to the one used for APNs, has been introduced.
1 parent b57ba45 commit b8da812

File tree

10 files changed

+84
-67
lines changed

10 files changed

+84
-67
lines changed

Gemfile.lock

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ PATH
77
googleauth (~> 1.14)
88
httpx (~> 1.6)
99
jwt (>= 2)
10-
net-http (~> 0.6)
1110
railties (>= 8.0)
1211

1312
GEM

action_push_native.gemspec

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,4 @@ Gem::Specification.new do |spec|
3838
spec.add_dependency "httpx", "~> 1.6"
3939
spec.add_dependency "jwt", ">= 2"
4040
spec.add_dependency "googleauth", "~> 1.14"
41-
spec.add_dependency "net-http", "~> 0.6"
4241
end

lib/action_push_native.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
require "zeitwerk"
44
require "action_push_native/engine"
55
require "action_push_native/errors"
6-
require "net/http"
76
require "httpx"
87
require "googleauth"
98
require "jwt"

lib/action_push_native/service/apns.rb

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
module ActionPushNative
44
module Service
55
class Apns
6-
def initialize(config)
7-
@config = config
8-
end
6+
include NetworkErrorHandling
97

108
# Per-application HTTPX session
119
cattr_accessor :httpx_sessions
1210

11+
def initialize(config)
12+
@config = config
13+
end
14+
1315
def push(notification)
1416
notification.apple_data = ApnoticLegacyConverter.convert(notification.apple_data) if notification.apple_data.present?
1517

@@ -70,23 +72,6 @@ def handle_error(response)
7072
end
7173
end
7274

73-
def handle_network_error(error)
74-
case error
75-
when Errno::ETIMEDOUT, HTTPX::TimeoutError
76-
raise ActionPushNative::TimeoutError, error.message
77-
when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
78-
SocketError, IOError, EOFError, Errno::EPIPE, Errno::EINVAL, HTTPX::ConnectionError,
79-
HTTPX::TLSError, HTTPX::Connection::HTTP2::Error
80-
raise ActionPushNative::ConnectionError, error.message
81-
when OpenSSL::SSL::SSLError
82-
if error.message.include?("SSL_connect")
83-
raise ActionPushNative::ConnectionError, error.message
84-
else
85-
raise
86-
end
87-
end
88-
end
89-
9075
def handle_apns_error(response)
9176
status = response.status
9277
reason = JSON.parse(response.body.to_s)["reason"] unless response.body.empty?

lib/action_push_native/service/fcm.rb

Lines changed: 41 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,41 @@
33
module ActionPushNative
44
module Service
55
class Fcm
6-
# FCM suggests at least a 10s timeout for requests, we set 15 to add some buffer.
7-
# https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts
8-
DEFAULT_TIMEOUT = 15.seconds
6+
include NetworkErrorHandling
7+
8+
# Per-application HTTPX session
9+
cattr_accessor :httpx_sessions
910

1011
def initialize(config)
1112
@config = config
1213
end
1314

1415
def push(notification)
15-
response = post_request payload_from(notification)
16-
handle_error(response) unless response.code == "200"
16+
response = httpx_session.post("v1/projects/#{config.fetch(:project_id)}/messages:send", json: payload_from(notification), headers: { authorization: "Bearer #{access_token}" })
17+
handle_error(response) if response.error
1718
end
1819

1920
private
2021
attr_reader :config
2122

23+
def httpx_session
24+
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")
39+
end
40+
2241
def payload_from(notification)
2342
deep_compact({
2443
message: {
@@ -56,34 +75,6 @@ def stringify(hash)
5675
hash.compact.transform_values(&:to_s)
5776
end
5877

59-
def post_request(payload)
60-
uri = URI("https://fcm.googleapis.com/v1/projects/#{config.fetch(:project_id)}/messages:send")
61-
request = Net::HTTP::Post.new(uri)
62-
request["Authorization"] = "Bearer #{access_token}"
63-
request["Content-Type"] = "application/json"
64-
request.body = payload.to_json
65-
66-
rescue_and_reraise_network_errors do
67-
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: config[:request_timeout] || DEFAULT_TIMEOUT) do |http|
68-
http.request(request)
69-
end
70-
end
71-
end
72-
73-
def rescue_and_reraise_network_errors
74-
yield
75-
rescue Net::ReadTimeout, Net::OpenTimeout => e
76-
raise ActionPushNative::TimeoutError, e.message
77-
rescue Errno::ECONNRESET, SocketError => e
78-
raise ActionPushNative::ConnectionError, e.message
79-
rescue OpenSSL::SSL::SSLError => e
80-
if e.message.include?("SSL_connect")
81-
raise ActionPushNative::ConnectionError, e.message
82-
else
83-
raise
84-
end
85-
end
86-
8778
def access_token
8879
authorizer = Google::Auth::ServiceAccountCredentials.make_creds \
8980
json_key_io: StringIO.new(config.fetch(:encryption_key)),
@@ -92,28 +83,36 @@ def access_token
9283
end
9384

9485
def handle_error(response)
95-
code = response.code
86+
if response.is_a?(HTTPX::ErrorResponse)
87+
handle_network_error(response.error)
88+
else
89+
handle_fcm_error(response)
90+
end
91+
end
92+
93+
def handle_fcm_error(response)
94+
status = response.status
9695
reason = \
9796
begin
98-
JSON.parse(response.body).dig("error", "message")
97+
JSON.parse(response.body.to_s).dig("error", "message")
9998
rescue JSON::ParserError
100-
response.body
99+
response.body.to_s
101100
end
102101

103-
Rails.logger.error("FCM response error #{code}: #{reason}")
102+
Rails.logger.error("FCM response error #{status}: #{reason}")
104103

105104
case
106105
when reason =~ /message is too big/i
107106
raise ActionPushNative::PayloadTooLargeError, reason
108-
when code == "400"
107+
when status == 400
109108
raise ActionPushNative::BadRequestError, reason
110-
when code == "404"
109+
when status == 404
111110
raise ActionPushNative::TokenError, reason
112-
when code.in?([ "401", "403" ])
111+
when status.in?([ 401, 403 ])
113112
raise ActionPushNative::ForbiddenError, reason
114-
when code == "429"
113+
when status == 429
115114
raise ActionPushNative::TooManyRequestsError, reason
116-
when code == "503"
115+
when status == 503
117116
raise ActionPushNative::ServiceUnavailableError, reason
118117
else
119118
raise ActionPushNative::InternalServerError, reason
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
module ActionPushNative::Service::NetworkErrorHandling
2+
private
3+
4+
def handle_network_error(error)
5+
case error
6+
when Errno::ETIMEDOUT, HTTPX::TimeoutError
7+
raise ActionPushNative::TimeoutError, error.message
8+
when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
9+
SocketError, IOError, EOFError, Errno::EPIPE, Errno::EINVAL, HTTPX::ConnectionError,
10+
HTTPX::TLSError, HTTPX::Connection::HTTP2::Error
11+
raise ActionPushNative::ConnectionError, error.message
12+
when OpenSSL::SSL::SSLError
13+
if error.message.include?("SSL_connect")
14+
raise ActionPushNative::ConnectionError, error.message
15+
else
16+
raise
17+
end
18+
end
19+
end
20+
end

lib/generators/action_push_native/install/templates/config/push.yml.tt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,9 @@ shared:
3232
# Firebase project_id
3333
project_id: your_project_id
3434

35+
# Set this to the number of threads used to process notifications (default: 5).
36+
# When the pool size is too small a HTTPX::PoolTimeoutError error will be raised.
37+
# connection_pool_size: 5
38+
3539
# Change the request timeout (default: 15).
3640
# request_timeout: 30

test/dummy/config/push.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ shared:
2929
# See https://firebase.google.com/docs/cloud-messaging/auth-server
3030
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key) %>
3131

32+
# Set this to the number of threads used to process notifications (default: 5).
33+
# When the pool size is too small a HTTPX::PoolTimeoutError error will be raised.
34+
# connection_pool_size: 5
35+
3236
# Firebase project_id
3337
project_id: your_project_id
3438

test/jobs/action_push_native/notification_job_test.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ class NotificationJobTest < ActiveSupport::TestCase
3636

3737
test "Socket errors are retried" do
3838
device = action_push_native_devices(:pixel9)
39-
Net::HTTP.any_instance.stubs(:request).raises(SocketError)
39+
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send").
40+
to_raise(SocketError.new)
4041
ActionPushNative::Service::Fcm.any_instance.stubs(:access_token).returns("fake_access_token")
4142

4243
assert_enqueued_jobs 1, only: ActionPushNative::NotificationJob do

test/lib/action_push_native/service/fcm_test.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,14 @@ class FcmTest < ActiveSupport::TestCase
4949
@fcm.push(@notification)
5050
end
5151

52-
Net::HTTP.stubs(:start).raises(OpenSSL::SSL::SSLError.new("SSL_connect returned=1 errno=0 state=error"))
52+
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send").
53+
to_return(status: 500, body: "Not a JSON")
54+
assert_raises ActionPushNative::InternalServerError do
55+
@fcm.push(@notification)
56+
end
57+
58+
stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send").
59+
to_raise(OpenSSL::SSL::SSLError.new("SSL_connect returned=1 errno=0 state=error"))
5360
assert_raises ActionPushNative::ConnectionError do
5461
@fcm.push(@notification)
5562
end

0 commit comments

Comments
 (0)