Skip to content

Commit bae58ca

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 244bec1 commit bae58ca

File tree

10 files changed

+110
-91
lines changed

10 files changed

+110
-91
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: 67 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,84 +3,77 @@
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

22-
def payload_from(notification)
23-
deep_compact({
24-
message: {
25-
token: notification.token,
26-
data: notification.data ? stringify(notification.data) : {},
27-
android: {
28-
notification: {
29-
title: notification.title,
30-
body: notification.body,
31-
notification_count: notification.badge,
32-
sound: notification.sound
33-
},
34-
collapse_key: notification.thread_id,
35-
priority: notification.high_priority == true ? "high" : "normal"
36-
}
37-
}.deep_merge(notification.google_data ? stringify_data(notification.google_data) : {})
38-
})
23+
def httpx_session
24+
self.class.httpx_sessions ||= {}
25+
self.class.httpx_sessions[config] ||= build_httpx_session
3926
end
4027

41-
def deep_compact(payload)
42-
payload.dig(:message, :android, :notification).try(&:compact!)
43-
payload.dig(:message, :android).try(&:compact!)
44-
payload[:message].compact!
45-
payload
46-
end
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
4732

48-
# FCM requires data values to be strings.
49-
def stringify_data(google_data)
50-
google_data.tap do |payload|
51-
payload[:data] = stringify(payload[:data]) if payload[:data]
52-
end
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")
5339
end
5440

55-
def stringify(hash)
56-
hash.compact.transform_values(&:to_s)
57-
end
41+
concerning :Payload do
42+
def payload_from(notification)
43+
deep_compact({
44+
message: {
45+
token: notification.token,
46+
data: notification.data ? stringify(notification.data) : {},
47+
android: {
48+
notification: {
49+
title: notification.title,
50+
body: notification.body,
51+
notification_count: notification.badge,
52+
sound: notification.sound
53+
},
54+
collapse_key: notification.thread_id,
55+
priority: notification.high_priority == true ? "high" : "normal"
56+
}
57+
}.deep_merge(notification.google_data ? stringify_data(notification.google_data) : {})
58+
})
59+
end
5860

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
61+
def deep_compact(payload)
62+
payload.dig(:message, :android, :notification).try(&:compact!)
63+
payload.dig(:message, :android).try(&:compact!)
64+
payload[:message].compact!
65+
payload
66+
end
6567

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)
68+
# FCM requires data values to be strings.
69+
def stringify_data(google_data)
70+
google_data.tap do |payload|
71+
payload[:data] = stringify(payload[:data]) if payload[:data]
6972
end
7073
end
71-
end
7274

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
75+
def stringify(hash)
76+
hash.compact.transform_values(&:to_s)
8477
end
8578
end
8679

@@ -92,28 +85,36 @@ def access_token
9285
end
9386

9487
def handle_error(response)
95-
code = response.code
88+
if response.is_a?(HTTPX::ErrorResponse)
89+
handle_network_error(response.error)
90+
else
91+
handle_fcm_error(response)
92+
end
93+
end
94+
95+
def handle_fcm_error(response)
96+
status = response.status
9697
reason = \
9798
begin
98-
JSON.parse(response.body).dig("error", "message")
99+
JSON.parse(response.body.to_s).dig("error", "message")
99100
rescue JSON::ParserError
100-
response.body
101+
response.body.to_s
101102
end
102103

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

105106
case
106107
when reason =~ /message is too big/i
107108
raise ActionPushNative::PayloadTooLargeError, reason
108-
when code == "400"
109+
when status == 400
109110
raise ActionPushNative::BadRequestError, reason
110-
when code == "404"
111+
when status == 404
111112
raise ActionPushNative::TokenError, reason
112-
when code.in?([ "401", "403" ])
113+
when status.in?([ 401, 403 ])
113114
raise ActionPushNative::ForbiddenError, reason
114-
when code == "429"
115+
when status == 429
115116
raise ActionPushNative::TooManyRequestsError, reason
116-
when code == "503"
117+
when status == 503
117118
raise ActionPushNative::ServiceUnavailableError, reason
118119
else
119120
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)