honeymaker 0.10.0 → 0.11.1

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: bbfab70631b45882e86a1f4af9ea69b9c32c98840a383183b69ebae133b2f3bf
4
- data.tar.gz: 3d65a28ae18b85f5de60959aac90e327471ff617488956538442b6841ddc4bd9
3
+ metadata.gz: c0d4a030c628dda5a597efb6ff65b552ce961cfb855e7f6affa668a8f0e6f492
4
+ data.tar.gz: 43372bdcc5a896dcf83e7f8fcd913d091a1b2c79b61e03e2b1e430dc20821ee0
5
5
  SHA512:
6
- metadata.gz: 00f3d5d33b5c1d231be4f1bafb5815083bc26cafae31a8cfe2c4403be217ece9120f7195bc6f5fa15d14286950c9f8817762767a4824f0c29396a6b679ca483e
7
- data.tar.gz: 3209e5756f1aab9c2af0537c533b3b2dbadf468c635c5f882d4f85362679597495d78f9ec77a2f7ca1b10a8c1c534687d19b95be73989ce88fe23fbf5f03120a
6
+ metadata.gz: aabd7c7f6859af5e36fb7bc301a227603683b9505e78a306b3944b8fba8ed6c5df45c05a9846557b18c59f461bfe3601f7492ede5a02c6125a87a1d9d58e76fe
7
+ data.tar.gz: ef8db9f4be0d5d60aa65a0d9baa41283b20cf44ddf6f624b4c0617cc5fab6449ac77878b190d0dff2ebca848876461a500b95d6b53f39f9b465fa817567ad923
@@ -70,8 +70,30 @@ module Honeymaker
70
70
  status = e.respond_to?(:response_status) ? e.response_status : nil
71
71
  Result::Failure.new(error_message, data: { status: status })
72
72
  rescue StandardError => e
73
+ # NOT an exchange error — a bug here, in a vendor gem, or in the caller, and it arrives in the
74
+ # same Result::Failure as a genuine venue rejection. Callers classify that text to decide "out
75
+ # of funds" / "bad key" / "safe to retry", so an unlabelled TypeError gets attributed to the
76
+ # exchange: a hyperliquid-rb signing crash sat in production order history for three months
77
+ # reading exactly like a venue rejection. Name the class so the true source is unmistakable,
78
+ # and flag it so a caller can refuse to classify it as the venue's at all.
79
+ #
80
+ # The class is a PREFIX, not a replacement: the network failures that also land here
81
+ # (Net::ReadTimeout, execution expired, connection refused) are matched by substring for
82
+ # transient retry, and those matches must keep working.
73
83
  msg = e.message
74
- Result::Failure.new((msg && !msg.empty?) ? msg : "Unknown error")
84
+ labelled = (msg && !msg.empty?) ? "#{e.class}: #{msg}" : e.class.to_s
85
+ Result::Failure.new(labelled, data: { client_error: true })
86
+ end
87
+
88
+ # A business error the exchange returned inside an HTTP-200 envelope (KuCoin's non-"200000"
89
+ # code, Bitget's non-"00000"). Keep the exchange's own code and message: callers classify on
90
+ # this text to tell insufficient funds from a bad key from a stale timestamp, so collapsing it
91
+ # to a bare constant silently disables every one of those checks and leaves the operator with
92
+ # an unactionable log line. Falls back to the constant only when the body carries no detail.
93
+ def api_error(exchange, body)
94
+ detail = body.is_a?(Hash) ? [body["code"], body["msg"] || body["message"]] : []
95
+ detail = detail.compact.map(&:to_s).reject(&:empty?).join(": ")
96
+ Result::Failure.new(detail.empty? ? "#{exchange} API error" : "#{exchange} API error #{detail}")
75
97
  end
76
98
 
77
99
  def connection
@@ -57,6 +57,11 @@ module Honeymaker
57
57
  return result if result.failure?
58
58
 
59
59
  raw = result.data
60
+ # BingX rejects with HTTP 200 and a non-zero `code`. Without this the rejection fell
61
+ # through as a Success carrying order_id "#{symbol}-" (nil id): the bot recorded a trade
62
+ # that was never placed, then polled a malformed id forever.
63
+ return api_error("BingX", raw) unless raw.is_a?(Hash) && raw["code"].to_i.zero?
64
+
60
65
  order_id = raw.dig("data", "orderId") || raw.dig("data", "data", "orderId")
61
66
  Result::Success.new({ order_id: "#{symbol}-#{order_id}", raw: raw })
62
67
  end
@@ -66,8 +71,9 @@ module Honeymaker
66
71
  symbol: symbol, orderId: order_id, clientOrderID: client_order_id
67
72
  })
68
73
  return result if result.failure?
74
+ return api_error("BingX", result.data) unless result.data.is_a?(Hash) && result.data["code"].to_i.zero?
69
75
 
70
- raw = result.data.is_a?(Hash) && result.data.key?("data") ? result.data["data"] : result.data
76
+ raw = result.data.key?("data") ? result.data["data"] : result.data
71
77
  Result::Success.new(normalize_order("#{symbol}-#{raw['orderId']}", raw))
72
78
  end
73
79
 
@@ -44,7 +44,7 @@ module Honeymaker
44
44
  result = get_account_assets
45
45
  return result if result.failure?
46
46
 
47
- return Result::Failure.new("Bitget API error") unless result.data["code"] == "00000"
47
+ return api_error("Bitget", result.data) unless result.data["code"] == "00000"
48
48
 
49
49
  balances = {}
50
50
  (result.data["data"] || []).each do |asset|
@@ -64,7 +64,7 @@ module Honeymaker
64
64
  size: size, quoteSize: quote_size, price: price, force: force, clientOid: client_oid
65
65
  })
66
66
  return result if result.failure?
67
- return Result::Failure.new("Bitget API error") unless result.data["code"] == "00000"
67
+ return api_error("Bitget", result.data) unless result.data["code"] == "00000"
68
68
 
69
69
  order_id = result.data.dig("data", "orderId")
70
70
  Result::Success.new({ order_id: "#{symbol}-#{order_id}", raw: result.data })
@@ -73,7 +73,7 @@ module Honeymaker
73
73
  def get_order(order_id: nil, client_oid: nil)
74
74
  result = get_signed("/api/v2/spot/trade/orderInfo", { orderId: order_id, clientOid: client_oid })
75
75
  return result if result.failure?
76
- return Result::Failure.new("Bitget API error") unless result.data["code"] == "00000"
76
+ return api_error("Bitget", result.data) unless result.data["code"] == "00000"
77
77
 
78
78
  order_list = result.data["data"]
79
79
  raw = order_list.is_a?(Array) ? order_list.first : order_list
@@ -39,7 +39,7 @@ module Honeymaker
39
39
  result = get_accounts(type: type)
40
40
  return result if result.failure?
41
41
 
42
- return Result::Failure.new("KuCoin API error") unless result.data["code"] == "200000"
42
+ return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
43
43
 
44
44
  balances = {}
45
45
  (result.data["data"] || []).each do |account|
@@ -61,7 +61,7 @@ module Honeymaker
61
61
  timeInForce: time_in_force, stp: stp
62
62
  })
63
63
  return result if result.failure?
64
- return Result::Failure.new("KuCoin API error") unless result.data["code"] == "200000"
64
+ return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
65
65
 
66
66
  order_id = result.data.dig("data", "orderId")
67
67
  Result::Success.new({ order_id: order_id, raw: result.data })
@@ -70,7 +70,7 @@ module Honeymaker
70
70
  def get_order(order_id:)
71
71
  result = get_signed("/api/v1/orders/#{order_id}")
72
72
  return result if result.failure?
73
- return Result::Failure.new("KuCoin API error") unless result.data["code"] == "200000"
73
+ return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
74
74
 
75
75
  raw = result.data["data"]
76
76
  return Result::Failure.new("Order not found") unless raw
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.1"
5
5
  end
@@ -41,7 +41,44 @@ class Honeymaker::ClientTest < Minitest::Test
41
41
  client = Honeymaker::Client.new
42
42
  result = client.send(:with_rescue) { raise StandardError, "boom" }
43
43
  assert result.failure?
44
- assert_equal ["boom"], result.errors
44
+ assert_equal ["StandardError: boom"], result.errors
45
+ end
46
+
47
+ # A bug in this gem, in a vendor gem, or in the caller is NOT an exchange error, but it lands in
48
+ # the same Result::Failure. Callers classify failure text to decide "out of funds" / "bad key" /
49
+ # "retry me", so an unlabelled TypeError reads as something the venue said and gets filed and
50
+ # reported as one. Naming the class makes the true source unmistakable, and the flag lets a
51
+ # caller refuse to classify it at all.
52
+ def test_with_rescue_names_the_exception_class_for_non_api_errors
53
+ client = Honeymaker::Client.new
54
+ result = client.send(:with_rescue) { raise TypeError, "String can't be coerced into Float" }
55
+ assert result.failure?
56
+ assert_equal ["TypeError: String can't be coerced into Float"], result.errors
57
+ assert result.data[:client_error], "a local exception must be flagged, not passed off as the venue's"
58
+ end
59
+
60
+ def test_with_rescue_labels_an_empty_message_with_its_class
61
+ client = Honeymaker::Client.new
62
+ result = client.send(:with_rescue) { raise NoMethodError, "" }
63
+ assert result.failure?
64
+ assert_equal ["NoMethodError"], result.errors
65
+ end
66
+
67
+ # Network failures reach callers through this branch too, and the transient-retry matchers key
68
+ # off substrings of them. Prefixing must not break that.
69
+ def test_with_rescue_keeps_network_substrings_matchable
70
+ client = Honeymaker::Client.new
71
+ result = client.send(:with_rescue) { raise Net::ReadTimeout, "Net::ReadTimeout with #<TCPSocket:(closed)>" }
72
+ assert_includes result.errors.first, "Net::ReadTimeout"
73
+ end
74
+
75
+ # An exchange error is not ours: it must stay unflagged and unprefixed so the venue's own text
76
+ # reaches the classifiers verbatim.
77
+ def test_with_rescue_does_not_flag_api_errors
78
+ client = Honeymaker::Client.new
79
+ result = client.send(:with_rescue) { raise Faraday::TimeoutError, "timeout" }
80
+ assert result.failure?
81
+ assert_nil result.data[:client_error]
45
82
  end
46
83
 
47
84
  def test_hmac_sha256
@@ -41,6 +41,30 @@ class Honeymaker::Clients::BingXTest < Minitest::Test
41
41
  assert result.success?
42
42
  end
43
43
 
44
+ # BingX rejects with HTTP 200 and a non-zero `code`. Without a guard the rejection was read as
45
+ # a placed order and handed back as order_id "BTC-USDT-" (nil id), so a bot recorded a trade
46
+ # that does not exist and then polled a malformed id forever.
47
+ def test_place_order_fails_on_nonzero_code
48
+ stub_connection(:post, { "code" => 100004, "msg" => "insufficient balance" })
49
+ result = @client.place_order(symbol: "BTC-USDT", side: "BUY", type: "MARKET", quantity: "0.001")
50
+ assert result.failure?
51
+ assert_includes result.errors.first, "insufficient balance"
52
+ end
53
+
54
+ def test_get_order_fails_on_nonzero_code
55
+ stub_connection(:get, { "code" => 80016, "msg" => "Order does not exist" })
56
+ result = @client.get_order(symbol: "BTC-USDT", order_id: "123")
57
+ assert result.failure?
58
+ assert_includes result.errors.first, "Order does not exist"
59
+ end
60
+
61
+ def test_place_order_succeeds_on_zero_code
62
+ stub_connection(:post, { "code" => 0, "data" => { "orderId" => "123" } })
63
+ result = @client.place_order(symbol: "BTC-USDT", side: "BUY", type: "MARKET", quantity: "0.001")
64
+ assert result.success?
65
+ assert_equal "BTC-USDT-123", result.data[:order_id]
66
+ end
67
+
44
68
  def test_cancel_order
45
69
  stub_connection(:post, { "data" => { "orderId" => "123" } })
46
70
  result = @client.cancel_order(symbol: "BTC-USDT", order_id: "123")
@@ -104,6 +104,37 @@ class Honeymaker::Clients::BitgetTest < Minitest::Test
104
104
  assert headers.key?(:"ACCESS-SIGN")
105
105
  end
106
106
 
107
+ # Bitget signals business errors in an HTTP-200 envelope. Callers classify on this text
108
+ # (insufficient funds, bad key, stale timestamp), so the code and msg must survive.
109
+ def test_place_order_keeps_api_code_and_message
110
+ stub_connection(:post, { "code" => "43012", "msg" => "Insufficient balance" })
111
+ result = @client.place_order(symbol: "BTCUSDT", side: "buy", order_type: "market", quote_size: "100")
112
+ assert result.failure?
113
+ assert_includes result.errors.first, "Insufficient balance"
114
+ assert_includes result.errors.first, "43012"
115
+ end
116
+
117
+ def test_get_order_keeps_api_code_and_message
118
+ stub_connection(:get, { "code" => "40008", "msg" => "Request timestamp expired" })
119
+ result = @client.get_order(order_id: "BTCUSDT-123")
120
+ assert result.failure?
121
+ assert_includes result.errors.first, "Request timestamp expired"
122
+ end
123
+
124
+ def test_get_balances_keeps_api_code_and_message
125
+ stub_connection(:get, { "code" => "40012", "msg" => "Apikey does not exist" })
126
+ result = @client.get_balances
127
+ assert result.failure?
128
+ assert_includes result.errors.first, "Apikey does not exist"
129
+ end
130
+
131
+ def test_api_error_falls_back_when_body_carries_no_detail
132
+ stub_connection(:post, { "code" => nil })
133
+ result = @client.place_order(symbol: "BTCUSDT", side: "buy", order_type: "market", quote_size: "100")
134
+ assert result.failure?
135
+ assert_equal "Bitget API error", result.errors.first
136
+ end
137
+
107
138
  private
108
139
 
109
140
  def stub_connection(method, body)
@@ -107,6 +107,37 @@ class Honeymaker::Clients::KucoinTest < Minitest::Test
107
107
  assert_equal expected, headers[:"KC-API-PASSPHRASE"]
108
108
  end
109
109
 
110
+ # KuCoin signals business errors in an HTTP-200 envelope. Callers classify on this text
111
+ # (insufficient funds, bad key, stale timestamp), so the code and msg must survive.
112
+ def test_place_order_keeps_api_code_and_message
113
+ stub_connection(:post, { "code" => "200004", "msg" => "Balance insufficient!" })
114
+ result = @client.place_order(client_oid: "c1", side: "buy", symbol: "BTC-USDT", type: "market", funds: "100")
115
+ assert result.failure?
116
+ assert_includes result.errors.first, "Balance insufficient!"
117
+ assert_includes result.errors.first, "200004"
118
+ end
119
+
120
+ def test_get_order_keeps_api_code_and_message
121
+ stub_connection(:get, { "code" => "400002", "msg" => "Invalid KC-API-TIMESTAMP." })
122
+ result = @client.get_order(order_id: "123")
123
+ assert result.failure?
124
+ assert_includes result.errors.first, "Invalid KC-API-TIMESTAMP."
125
+ end
126
+
127
+ def test_get_balances_keeps_api_code_and_message
128
+ stub_connection(:get, { "code" => "400003", "msg" => "KC-API-KEY not exists" })
129
+ result = @client.get_balances
130
+ assert result.failure?
131
+ assert_includes result.errors.first, "KC-API-KEY not exists"
132
+ end
133
+
134
+ def test_api_error_falls_back_when_body_carries_no_detail
135
+ stub_connection(:post, { "code" => nil })
136
+ result = @client.place_order(client_oid: "c1", side: "buy", symbol: "BTC-USDT", type: "market", funds: "100")
137
+ assert result.failure?
138
+ assert_equal "KuCoin API error", result.errors.first
139
+ end
140
+
110
141
  private
111
142
 
112
143
  def stub_connection(method, body)
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.10.0
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger