Skip to content

Commit 387b6a0

Browse files
committed
Make the HTTP client threadsafe
1 parent b2e93d6 commit 387b6a0

File tree

7 files changed

+90
-34
lines changed

7 files changed

+90
-34
lines changed

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ PATH
22
remote: .
33
specs:
44
croatia (0.4.0)
5+
concurrent-ruby
6+
connection_pool
57
nokogiri
68
openssl
79
rexml

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ Croatia.configure do |config|
6565
# Fiscalization defaults
6666
config.fiscalization = {
6767
credential: "path/to/your/credential.p12", # or File.read("path/to/your/credential.p12")
68-
password: ENV["FISCALIZATION_CREDENTIAL_PASSWORD"]
68+
password: ENV["FISCALIZATION_CREDENTIAL_PASSWORD"],
69+
}
6970
}
7071
# You can also use separate private key and certificate files instead of a full credential (.p12)
7172
# config.fiscalization = {
@@ -76,6 +77,13 @@ Croatia.configure do |config|
7677
# },
7778
# password: "credential_password" # only needed for encrypted private keys
7879
# }
80+
#
81+
# Additional fiscalization options
82+
# config.fiscalization = {
83+
# pool_size: 5, # Number of threads to use for fiscalization requests; with Rails you'd want to set this to ENV.fetch("RAILS_MAX_THREADS", 5).to_i
84+
# pool_timeout: 5, # Timeout for each fiscalization request in seconds
85+
# keep_alive_timeout: 60, # For how long to keep a connection open before closing it - speeds up consecutive requests
86+
# }
7987
end
8088
```
8189

croatia.gemspec

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ Gem::Specification.new do |spec|
3636
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
3737
spec.require_paths = [ "lib" ]
3838

39+
spec.add_dependency "concurrent-ruby"
40+
spec.add_dependency "connection_pool"
41+
spec.add_dependency "nokogiri"
3942
spec.add_dependency "openssl"
4043
spec.add_dependency "rexml"
4144
spec.add_dependency "tzinfo"
42-
spec.add_dependency "nokogiri"
4345

4446
# For more information and examples about making a new gem, check out our
4547
# guide at: https://bundler.io/guides/creating_gem.html

lib/croatia/config.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ class Croatia::Config
1313
consumption_tax: Hash.new(0.0),
1414
other: Hash.new(0.0)
1515
}
16-
DEFAULT_FISCALIZATION = {}
16+
DEFAULT_FISCALIZATION = {
17+
pool_size: Croatia::Fiscalizer::DEFAULT_POOL_SIZE,
18+
pool_timeout: Croatia::Fiscalizer::DEFAULT_POOL_TIMEOUT,
19+
keep_alive_timeout: Croatia::Fiscalizer::DEFAULT_KEEP_ALIVE_TIMEOUT
20+
}
1721

1822
attr_accessor :tax_rates, :fiscalization
1923

lib/croatia/fiscalizer.rb

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "concurrent"
4+
require "connection_pool"
35
require "digest/md5"
46
require "net/http"
57
require "openssl"
@@ -13,57 +15,65 @@ class Croatia::Fiscalizer
1315
TZ = TZInfo::Timezone.get("Europe/Zagreb")
1416
QR_CODE_BASE_URL = "https://porezna.gov.hr/rn"
1517
DEFAULT_PORT = 443
16-
DEFAULT_TIMEOUT = 30
18+
DEFAULT_KEEP_ALIVE_TIMEOUT = 60
19+
DEFAULT_POOL_SIZE = 5
20+
DEFAULT_POOL_TIMEOUT = 5
1721
USER_AGENT = "Croatia/#{Croatia::VERSION} Ruby/#{RUBY_VERSION} (Fiscalization Client; +https://github.com/monorkin/croatia)"
1822

1923
class << self
20-
attr_accessor :http_clients
24+
def http_pools
25+
@http_pools ||= Concurrent::Map.new
26+
end
2127

2228
def with_http_client_for(**options, &block)
23-
client = http_client_for(**options)
24-
retrying = false
29+
pool = http_pool_for(**options)
30+
31+
pool.with do |client|
32+
retrying = false
2533

26-
begin
27-
block.call(client)
28-
rescue IOError, EOFError, Errno::ECONNRESET
29-
raise if retrying
34+
begin
35+
block.call(client)
36+
rescue IOError, EOFError, Errno::ECONNRESET
37+
raise if retrying
3038

31-
retrying = true
32-
client.finish rescue nil
33-
client.start
39+
retrying = true
40+
client.finish rescue nil
41+
client.start
3442

35-
retry
43+
retry
44+
end
3645
end
3746
end
3847

39-
def http_client_for(host:, credential:, port: DEFAULT_PORT, timeout: DEFAULT_TIMEOUT)
40-
self.http_clients ||= {}
48+
def http_pool_for(host:, credential:, port: DEFAULT_PORT, keep_alive_timeout: nil, size: nil, timeout: nil)
49+
size ||= Croatia.config.fiscalization.fetch(:pool_size, DEFAULT_POOL_SIZE)
50+
timeout ||= Croatia.config.fiscalization.fetch(:pool_timeout, DEFAULT_POOL_TIMEOUT)
51+
keep_alive_timeout ||= Croatia.config.fiscalization.fetch(:keep_alive_timeout, DEFAULT_KEEP_ALIVE_TIMEOUT)
4152

4253
# MD5 is fast, but not cryptographically secure.
4354
# So the fingerprint is computed on less sensitive information.
4455
fingerprint = Digest::MD5.hexdigest("#{credential.certificate.serial}:#{credential.certificate.subject}")
4556
key = "#{host}:#{port}/#{fingerprint}?timeout=#{timeout}"
4657

47-
client = http_clients[key]
48-
49-
if client&.active?
50-
return client
51-
end
52-
53-
client&.finish rescue nil
54-
http_clients[key] = Net::HTTP.new(host, port).tap do |client|
55-
client.use_ssl = true
56-
client.verify_mode = OpenSSL::SSL::VERIFY_PEER
57-
client.cert = credential.certificate
58-
client.key = credential.key
59-
client.keep_alive_timeout = timeout
60-
client.start
58+
http_pools.compute_if_absent(key) do
59+
ConnectionPool.new(size: pool_size, timeout: pool_timeout) do
60+
Net::HTTP.new(host, port).tap do |client|
61+
client.use_ssl = true
62+
client.verify_mode = OpenSSL::SSL::VERIFY_PEER
63+
client.cert = credential.certificate
64+
client.key = credential.key
65+
client.keep_alive_timeout = keep_alive_timeout if keep_alive_timeout
66+
client.start
67+
end
68+
end
6169
end
6270
end
6371

64-
def shutdown_all_clients
65-
http_clients&.each_value { |c| c.finish rescue nil }
66-
self.http_clients = {}
72+
def shutdown
73+
http_pools.each_value do |pool|
74+
pool.shutdown { |client| client.finish rescue nil }
75+
end
76+
http_pools.clear
6777
end
6878
end
6979

lib/croatia/fiscalizer/xml_builder.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ def sign(document:, credential:)
129129
document.root.add_element(signature, { "xmlns" => "http://www.w3.org/2000/09/xmldsig#" })
130130
end
131131

132+
def echo(message)
133+
REXML::Document.new.tap do |doc|
134+
doc.add_element("tns:EchoRequest", {
135+
"xmlns:tns" => TNS,
136+
"xmlns:xsi" => XSI,
137+
}).text = message
138+
end
139+
end
140+
132141
private
133142

134143
def build(envelope:, invoice:, message_id:, timezone: Croatia::Fiscalizer::TZ, **options)

test/croatia/fiscalizer/xml_builder_test.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,4 +777,25 @@ def test_sign_with_invoice_document
777777
end
778778
end
779779
end
780+
781+
def test_echo
782+
message = "Hello, World!"
783+
document = Croatia::Fiscalizer::XMLBuilder.echo(message)
784+
785+
expected_xml = <<~XML
786+
<tns:EchoRequest xmlns:tns='http://www.apis-it.hr/fin/2012/types/f73' xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'>Hello, World!</tns:EchoRequest>
787+
XML
788+
789+
assert_xml_equal expected_xml, document
790+
end
791+
792+
def test_echo_with_nil
793+
document = Croatia::Fiscalizer::XMLBuilder.echo(nil)
794+
795+
expected_xml = <<~XML
796+
<tns:EchoRequest xmlns:tns='http://www.apis-it.hr/fin/2012/types/f73' xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'></tns:EchoRequest>
797+
XML
798+
799+
assert_xml_equal expected_xml, document
800+
end
780801
end

0 commit comments

Comments
 (0)