honeymaker 0.11.5 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d279d1416ac845778e5bf48218fbacf23498cae10bc1ed589cae28752aa4af8c
4
- data.tar.gz: 2aebe8b36d740f1c09bb9ecf21ab205b911f6ce11fc41b0a7bc80898602d016f
3
+ metadata.gz: e6f44f3c6ed35e460dcb6b6d53a73bebec433c34ad1109ab8b1531f799ad93b0
4
+ data.tar.gz: cd55a0b081c84f712e67ba00ede7633764592b0ff97d2b47fa50f29fc7c0cdd0
5
5
  SHA512:
6
- metadata.gz: ae37fa13ff7ce2db5366a408a4d006f671fcbda1c8931c8bca9c8df8a3c13c1256101a73b364bbffc58971923090d5dd8b0e44b49fdc35f73a0ecee8979f67b6
7
- data.tar.gz: 0a00d2949aeba2b30b681a8021b59083de3498d201446e70ba88e1c2ccfa135f6137036620559f1e79cc2f216328639a3bf61d47f1082fa73040aa60389967c0
6
+ metadata.gz: 32e67280cabf17a5a7305660b92ad2ff2eda948fb947a50a771f6bbd102f7cd9cbbeefdda50b09c16d1087bdd637c4649ba25963046828c9dcbd73eec87c5a4b
7
+ data.tar.gz: 01b15492c985691a7fcde609a56f3f6e197febbf7fdaaace27186ecb66268a128f796e99ca2a28aef7700c7e56bedb753ceff557400c7f54e142b07a7cb36a20
@@ -61,6 +61,13 @@ module Honeymaker
61
61
  raise NotImplementedError, "#{self.class} must implement #validate_read_credentials"
62
62
  end
63
63
 
64
+ # No HTTP answer means the transport failed, and its text rarely says how ("end of file
65
+ # reached"). The chain of exception classes does — whether the request never left or may have
66
+ # landed — so it travels with the failure. Facts only: what they mean is the caller's call.
67
+ def transport_data(error, status)
68
+ status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
69
+ end
70
+
64
71
  def with_rescue
65
72
  Result::Success.new(yield)
66
73
  rescue Faraday::Error => e
@@ -68,7 +75,7 @@ module Honeymaker
68
75
  error_message = (body && !body.empty?) ? body : e.message.to_s
69
76
  error_message = "Unknown API error" if error_message.nil? || error_message.empty?
70
77
  status = e.respond_to?(:response_status) ? e.response_status : nil
71
- Result::Failure.new(error_message, data: { status: status })
78
+ Result::Failure.new(error_message, data: transport_data(e, status))
72
79
  rescue StandardError => e
73
80
  # NOT an exchange error — a bug here, in a vendor gem, or in the caller, and it arrives in the
74
81
  # same Result::Failure as a genuine venue rejection. Callers classify that text to decide "out
@@ -71,13 +71,21 @@ module Honeymaker
71
71
 
72
72
  private
73
73
 
74
+ # No HTTP answer means the transport failed, and its text rarely says how ("end of file
75
+ # reached"). The chain of exception classes does — whether the request never left or may have
76
+ # landed — so it travels with the failure. Facts only: what they mean is the caller's call.
77
+ def transport_data(error, status)
78
+ status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
79
+ end
80
+
74
81
  def with_rescue
75
82
  Result::Success.new(yield)
76
83
  rescue Faraday::Error => e
77
84
  body = e.respond_to?(:response_body) ? e.response_body : nil
78
85
  error_message = (body && !body.empty?) ? body : e.message.to_s
79
86
  error_message = "Unknown API error" if error_message.nil? || error_message.empty?
80
- Result::Failure.new(error_message)
87
+ status = e.respond_to?(:response_status) ? e.response_status : nil
88
+ Result::Failure.new(error_message, data: transport_data(e, status))
81
89
  rescue StandardError => e
82
90
  msg = e.message
83
91
  Result::Failure.new((msg && !msg.empty?) ? msg : "Unknown error")
@@ -5,13 +5,25 @@ module Honeymaker
5
5
  class Gemini < Exchange
6
6
  BASE_URL = "https://api.gemini.com"
7
7
 
8
+ # Gemini has no bulk symbol-details endpoint, so the catalogue is one request per symbol --
9
+ # ~350 of them against a public limit of 120/min. Unpaced they ran at ~208/min, and a single
10
+ # dropped connection failed the whole catalogue: the adapter retries nothing on its own.
11
+ REQUEST_INTERVAL = 0.5
12
+ REQUEST_ATTEMPTS = 3
13
+ TRANSIENT_ERRORS = [
14
+ Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::RequestTimeoutError,
15
+ Faraday::TooManyRequestsError, Faraday::ServerError
16
+ ].freeze
17
+
8
18
  def get_tickers_info
9
19
  with_rescue do
10
- symbols_response = connection.get("/v1/symbols")
11
- symbols = symbols_response.body
20
+ symbols = catalogue_get("/v1/symbols")
12
21
 
13
- symbols.filter_map do |symbol|
14
- detail = connection.get("/v1/symbols/details/#{symbol}").body
22
+ tickers = symbols.filter_map do |symbol|
23
+ # A symbol that still fails raises, failing the WHOLE catalogue. Never skip it: callers
24
+ # read a pair missing from the catalogue as delisted, and data-api revokes it.
25
+ detail = catalogue_get("/v1/symbols/details/#{symbol}")
26
+ next unless spot?(symbol, detail)
15
27
 
16
28
  tick_size = detail["tick_size"]&.to_s || "0.01"
17
29
  quote_increment = detail["quote_increment"]&.to_s || "0.01"
@@ -31,6 +43,7 @@ module Honeymaker
31
43
  trading_enabled: detail["status"] == "open"
32
44
  }
33
45
  end
46
+ reject_shared_pairs!(tickers)
34
47
  end
35
48
  end
36
49
 
@@ -50,6 +63,39 @@ module Honeymaker
50
63
  def connection
51
64
  @connection ||= build_connection(BASE_URL)
52
65
  end
66
+
67
+ # Gemini lists perpetual swaps beside spot, under the same base and quote (BTCGUSDPERP is
68
+ # BTC/GUSD, product_type "swap"), so only product_type tells them apart. An instrument without
69
+ # one is not assumed to be spot: the catalogue fails and callers keep the one they have.
70
+ def spot?(symbol, detail)
71
+ product_type = detail["product_type"]
72
+ raise Error, "Gemini #{symbol}: no product_type in its details" unless product_type.is_a?(String)
73
+
74
+ product_type == "spot"
75
+ end
76
+
77
+ # One pair, one instrument. Two would leave callers to pick one by list order.
78
+ def reject_shared_pairs!(tickers)
79
+ shared = tickers.group_by { |t| "#{t[:base]}/#{t[:quote]}" }.find { |_, group| group.size > 1 }
80
+ return tickers unless shared
81
+
82
+ pair, group = shared
83
+ raise Error, "Gemini #{pair} is claimed by #{group.map { |t| t[:ticker] }.join(' and ')}"
84
+ end
85
+
86
+ def catalogue_get(path)
87
+ attempt = 1
88
+ begin
89
+ sleep(REQUEST_INTERVAL)
90
+ connection.get(path).body
91
+ rescue *TRANSIENT_ERRORS
92
+ raise if attempt >= REQUEST_ATTEMPTS
93
+
94
+ sleep(2**attempt)
95
+ attempt += 1
96
+ retry
97
+ end
98
+ end
53
99
  end
54
100
  end
55
101
  end
@@ -9,6 +9,25 @@ module Honeymaker
9
9
  str.split(".").last.length
10
10
  end
11
11
 
12
+ # The exception classes behind an error, outermost first, following both Faraday's
13
+ # #wrapped_exception and Ruby's #cause (adapters nest them differently), each named once.
14
+ # Reported on a transport failure so a caller can tell where it happened; see Client#with_rescue.
15
+ def self.error_chain(error, limit: 10)
16
+ names = []
17
+ seen = {}.compare_by_identity # exceptions, not classes: adapters re-wrap in the same class
18
+ queue = [error]
19
+ until queue.empty? || seen.size >= limit
20
+ node = queue.shift
21
+ next if seen.key?(node)
22
+
23
+ seen[node] = true
24
+ names << node.class.name unless names.include?(node.class.name)
25
+ queue << node.wrapped_exception if node.respond_to?(:wrapped_exception) && node.wrapped_exception
26
+ queue << node.cause if node.cause
27
+ end
28
+ names
29
+ end
30
+
12
31
  def self.parse_filters(filters)
13
32
  {
14
33
  price: filters.find { |f| f["filterType"] == "PRICE_FILTER" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.11.5"
4
+ VERSION = "0.11.9"
5
5
  end
@@ -5,5 +5,6 @@
5
5
  "tick_size": "0.00000001",
6
6
  "quote_increment": "0.01",
7
7
  "min_order_size": "0.00001",
8
- "status": "open"
8
+ "status": "open",
9
+ "product_type": "spot"
9
10
  }
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "socket"
5
+
6
+ # A transport failure — the request got no HTTP answer at all — carries the chain of exception
7
+ # classes behind it, outermost first. The text of such a failure rarely says what happened
8
+ # ("end of file reached"); the chain does, and it is what lets a caller tell a request that never
9
+ # left (DNS, a refused connection, a proxy refusing CONNECT) from one that may have landed (EOF,
10
+ # reset, read timeout). Honeymaker only reports it; deciding what it means is the caller's job.
11
+ class Honeymaker::ErrorChainTest < Minitest::Test
12
+ def test_walks_wrapped_exceptions_and_causes_outermost_first
13
+ error = Faraday::ConnectionFailed.new(EOFError.new("end of file reached"))
14
+
15
+ assert_equal %w[Faraday::ConnectionFailed EOFError], Honeymaker::Utils.error_chain(error)
16
+ end
17
+
18
+ def test_follows_ruby_causes_and_names_each_class_once
19
+ error = begin
20
+ begin
21
+ raise Errno::ECONNREFUSED
22
+ rescue Errno::ECONNREFUSED
23
+ raise Faraday::ConnectionFailed, "connection refused"
24
+ end
25
+ rescue Faraday::ConnectionFailed => e
26
+ e
27
+ end
28
+
29
+ assert_equal %w[Faraday::ConnectionFailed Errno::ECONNREFUSED], Honeymaker::Utils.error_chain(error)
30
+ end
31
+
32
+ # Adapters re-wrap: a class seen once must not hide what sits beneath its second instance.
33
+ def test_a_repeated_class_does_not_cut_the_chain_short
34
+ inner = Faraday::ConnectionFailed.new(Errno::ECONNREFUSED.new)
35
+ error = Faraday::ConnectionFailed.new(inner)
36
+
37
+ assert_equal %w[Faraday::ConnectionFailed Errno::ECONNREFUSED], Honeymaker::Utils.error_chain(error)
38
+ end
39
+
40
+ def test_client_failure_carries_the_chain_and_keeps_its_text
41
+ result = Honeymaker::Client.new.send(:with_rescue) do
42
+ raise Faraday::ConnectionFailed, EOFError.new("end of file reached")
43
+ end
44
+
45
+ assert_equal ["end of file reached"], result.errors
46
+ assert_nil result.data[:status]
47
+ assert_equal %w[Faraday::ConnectionFailed EOFError], result.data[:error_chain]
48
+ end
49
+
50
+ def test_exchange_failure_carries_the_chain_and_keeps_its_text
51
+ result = Honeymaker::Exchange.new.send(:with_rescue) do
52
+ raise Faraday::ConnectionFailed, EOFError.new("end of file reached")
53
+ end
54
+
55
+ assert_equal ["end of file reached"], result.errors
56
+ assert_equal %w[Faraday::ConnectionFailed EOFError], result.data[:error_chain]
57
+ end
58
+
59
+ # An HTTP answer is not a transport failure: its status says what happened.
60
+ def test_a_failure_with_an_http_answer_has_no_chain
61
+ error = Faraday::ServerError.new("boom", { status: 503, body: "unavailable" })
62
+ [Honeymaker::Client.new, Honeymaker::Exchange.new].each do |receiver|
63
+ result = receiver.send(:with_rescue) { raise error }
64
+
65
+ assert_nil result.data[:error_chain], receiver.class.name
66
+ end
67
+ end
68
+
69
+ # --- The chains the real adapter produces, against local sockets -------------------------------
70
+
71
+ def setup
72
+ @servers = []
73
+ end
74
+
75
+ # Closing a server makes its blocked #accept raise, which ends the serving thread; joining it
76
+ # keeps that thread from outliving the test.
77
+ def teardown
78
+ @servers.each do |server, thread|
79
+ server.close
80
+ thread.join(1)
81
+ end
82
+ end
83
+
84
+ def test_real_chain_for_a_connection_closed_before_the_answer
85
+ port = local_server
86
+
87
+ assert_equal %w[Faraday::ConnectionFailed EOFError], chain_of("http://127.0.0.1:#{port}")
88
+ end
89
+
90
+ def test_real_chain_for_a_refused_connection
91
+ port = TCPServer.new("127.0.0.1", 0).then { |s| s.addr[1].tap { s.close } }
92
+
93
+ assert_includes chain_of("http://127.0.0.1:#{port}"), "Errno::ECONNREFUSED"
94
+ end
95
+
96
+ def test_real_chain_for_a_proxy_refusing_connect
97
+ port = local_server("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n")
98
+ client = Honeymaker::Client.new(proxy: "http://127.0.0.1:#{port}")
99
+ result = client.send(:with_rescue) { client.send(:build_client_connection, "https://example.com").get("/") }
100
+
101
+ assert_equal %w[Faraday::ConnectionFailed Net::HTTPClientException], result.data[:error_chain]
102
+ end
103
+
104
+ private
105
+
106
+ # A local server that reads each request and answers with `reply`, or hangs up without an answer
107
+ # when there is none. Returns its port; teardown closes it.
108
+ def local_server(reply = nil)
109
+ server = TCPServer.new("127.0.0.1", 0)
110
+ thread = Thread.new do
111
+ loop do
112
+ client = server.accept
113
+ begin
114
+ client.readpartial(4096)
115
+ client.write(reply) if reply
116
+ rescue EOFError, SystemCallError
117
+ nil
118
+ ensure
119
+ client.close
120
+ end
121
+ end
122
+ rescue IOError, SystemCallError
123
+ nil # the server was closed in teardown
124
+ end
125
+ @servers << [server, thread]
126
+ server.addr[1]
127
+ end
128
+
129
+ def chain_of(url)
130
+ client = Honeymaker::Client.new
131
+ client.send(:with_rescue) { client.send(:build_client_connection, url).get("/") }.data[:error_chain]
132
+ end
133
+ end
@@ -7,18 +7,13 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
7
7
 
8
8
  def setup
9
9
  @exchange = Honeymaker::Exchanges::Gemini.new
10
+ # Recorded, not slept: the pacing and the backoff are asserted as a sequence.
11
+ sleeps = @sleeps = []
12
+ @exchange.define_singleton_method(:sleep) { |seconds| sleeps << seconds }
10
13
  end
11
14
 
12
15
  def test_get_tickers_info_parses_response
13
- symbols_body = load_fixture("gemini_symbols.json")
14
- detail_body = load_fixture("gemini_symbol_detail.json")
15
-
16
- symbols_response = stub(body: symbols_body)
17
- detail_response = stub(body: detail_body)
18
- connection = stub
19
- connection.stubs(:get).with("/v1/symbols").returns(symbols_response)
20
- connection.stubs(:get).with { |path| path.start_with?("/v1/symbols/details/") }.returns(detail_response)
21
- @exchange.instance_variable_set(:@connection, connection)
16
+ stub_catalogue(load_fixture("gemini_symbols.json"))
22
17
 
23
18
  result = @exchange.get_tickers_info
24
19
 
@@ -38,6 +33,111 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
38
33
  assert ticker[:trading_enabled]
39
34
  end
40
35
 
36
+ def test_get_tickers_info_paces_every_request
37
+ stub_catalogue(%w[btcusd ethusd])
38
+
39
+ assert @exchange.get_tickers_info.success?
40
+ assert_equal [0.5, 0.5, 0.5], @sleeps
41
+ end
42
+
43
+ def test_get_tickers_info_retries_a_dropped_detail_request
44
+ connection = stub_catalogue(%w[btcusd ethusd])
45
+ connection.stubs(:get).with("/v1/symbols/details/ethusd")
46
+ .raises(Faraday::ConnectionFailed, "end of file reached")
47
+ .then.returns(stub(body: detail_for("ethusd")))
48
+
49
+ result = @exchange.get_tickers_info
50
+
51
+ assert result.success?
52
+ assert_equal 2, result.data.size
53
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5], @sleeps
54
+ end
55
+
56
+ def test_get_tickers_info_retries_the_symbols_request
57
+ connection = stub_catalogue(%w[btcusd])
58
+ connection.stubs(:get).with("/v1/symbols")
59
+ .raises(Faraday::TooManyRequestsError, "429")
60
+ .then.returns(stub(body: %w[btcusd]))
61
+
62
+ result = @exchange.get_tickers_info
63
+
64
+ assert result.success?
65
+ assert_equal 1, result.data.size
66
+ end
67
+
68
+ def test_get_tickers_info_retries_every_transient_error_class
69
+ Honeymaker::Exchanges::Gemini::TRANSIENT_ERRORS.each do |error_class|
70
+ setup
71
+ connection = stub_catalogue(%w[btcusd])
72
+ connection.stubs(:get).with("/v1/symbols/details/btcusd")
73
+ .raises(error_class, "transient").then.returns(detail_response)
74
+
75
+ assert @exchange.get_tickers_info.success?, "#{error_class} should be retried"
76
+ end
77
+ end
78
+
79
+ # All or nothing: data-api reads a pair missing from the catalogue as delisted, so a symbol that
80
+ # keeps failing must fail the whole catalogue, and nothing after it is fetched.
81
+ def test_get_tickers_info_fails_whole_catalogue_when_a_symbol_keeps_failing
82
+ connection = stub_catalogue(%w[btcusd ethusd solusd])
83
+ connection.expects(:get).with("/v1/symbols/details/ethusd").times(3)
84
+ .raises(Faraday::ConnectionFailed, "end of file reached")
85
+ connection.expects(:get).with("/v1/symbols/details/solusd").never
86
+
87
+ result = @exchange.get_tickers_info
88
+
89
+ assert result.failure?
90
+ refute_kind_of Array, result.data, "no partial catalogue"
91
+ assert_equal ["end of file reached"], result.errors
92
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5, 4, 0.5], @sleeps
93
+ end
94
+
95
+ def test_get_tickers_info_does_not_retry_a_non_transient_error
96
+ connection = stub_catalogue(%w[btcusd])
97
+ connection.expects(:get).with("/v1/symbols/details/btcusd").once
98
+ .raises(Faraday::ResourceNotFound, "404")
99
+
100
+ assert @exchange.get_tickers_info.failure?
101
+ end
102
+
103
+ # Gemini lists perpetual swaps in /v1/symbols with the same base and quote as the spot pair
104
+ # (BTCGUSDPERP is BTC/GUSD). They are not spot instruments, and would take the spot pair's place.
105
+ def test_get_tickers_info_keeps_only_spot_instruments
106
+ connection = stub_catalogue(%w[btcusd btcusdperp])
107
+ connection.stubs(:get).with("/v1/symbols/details/btcusdperp")
108
+ .returns(stub(body: detail_body.merge("symbol" => "BTCUSDPERP", "product_type" => "swap")))
109
+
110
+ result = @exchange.get_tickers_info
111
+
112
+ assert result.success?
113
+ assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
114
+ end
115
+
116
+ # An unclassified instrument is not assumed to be spot: the whole catalogue fails, and callers keep
117
+ # the one they have.
118
+ def test_get_tickers_info_fails_when_a_symbol_has_no_product_type
119
+ connection = stub_catalogue(%w[btcusd ethusd])
120
+ connection.stubs(:get).with("/v1/symbols/details/ethusd")
121
+ .returns(stub(body: detail_body.except("product_type").merge("symbol" => "ETHUSD")))
122
+
123
+ result = @exchange.get_tickers_info
124
+
125
+ assert result.failure?
126
+ assert_nil result.data
127
+ assert_match(/ethusd/, result.errors.first)
128
+ end
129
+
130
+ def test_get_tickers_info_fails_when_two_instruments_claim_one_pair
131
+ connection = stub_catalogue(%w[btcusd btcusd2])
132
+ connection.stubs(:get).with("/v1/symbols/details/btcusd2")
133
+ .returns(stub(body: detail_body.merge("symbol" => "BTCUSD2")))
134
+
135
+ result = @exchange.get_tickers_info
136
+
137
+ assert result.failure?
138
+ assert_match(%r{BTC/USD}, result.errors.first)
139
+ end
140
+
41
141
  def test_get_bid_ask_parses_response
42
142
  body = load_fixture("gemini_pubticker.json")
43
143
  stub_connection(body)
@@ -51,6 +151,31 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
51
151
 
52
152
  private
53
153
 
154
+ def detail_body
155
+ load_fixture("gemini_symbol_detail.json")
156
+ end
157
+
158
+ def detail_response
159
+ stub(body: detail_body)
160
+ end
161
+
162
+ # Every symbol resolves to the one detail fixture; a test overrides a path to make it fail.
163
+ # Each symbol answers with the fixture's details for its own pair ("ethusd" is ETH/USD), since a
164
+ # catalogue may not hold two instruments for one pair.
165
+ def stub_catalogue(symbols)
166
+ connection = stub
167
+ connection.stubs(:get).with("/v1/symbols").returns(stub(body: symbols))
168
+ symbols.each do |symbol|
169
+ connection.stubs(:get).with("/v1/symbols/details/#{symbol}").returns(stub(body: detail_for(symbol)))
170
+ end
171
+ @exchange.instance_variable_set(:@connection, connection)
172
+ connection
173
+ end
174
+
175
+ def detail_for(symbol)
176
+ detail_body.merge("symbol" => symbol.upcase, "base_currency" => symbol[0, 3], "quote_currency" => symbol[3..])
177
+ end
178
+
54
179
  def stub_connection(body)
55
180
  response = stub(body: body)
56
181
  connection = stub
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: honeymaker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.5
4
+ version: 0.11.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger
@@ -170,6 +170,7 @@ files:
170
170
  - test/honeymaker/clients/kucoin_client_test.rb
171
171
  - test/honeymaker/clients/mexc_client_test.rb
172
172
  - test/honeymaker/clients/validation_test.rb
173
+ - test/honeymaker/error_chain_test.rb
173
174
  - test/honeymaker/exchange_test.rb
174
175
  - test/honeymaker/exchanges/binance_test.rb
175
176
  - test/honeymaker/exchanges/binance_us_test.rb