coindcx-client 0.1.0
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 +7 -0
- data/.github/workflows/ci.yml +55 -0
- data/.github/workflows/release.yml +138 -0
- data/.rubocop.yml +56 -0
- data/AGENT.md +352 -0
- data/README.md +224 -0
- data/bin/console +59 -0
- data/docs/README.md +29 -0
- data/docs/coindcx_docs_gaps.md +3 -0
- data/docs/core.md +179 -0
- data/docs/rails_integration.md +151 -0
- data/docs/standalone_bot.md +159 -0
- data/lib/coindcx/auth/signer.rb +48 -0
- data/lib/coindcx/client.rb +44 -0
- data/lib/coindcx/configuration.rb +108 -0
- data/lib/coindcx/contracts/channel_name.rb +23 -0
- data/lib/coindcx/contracts/identifiers.rb +36 -0
- data/lib/coindcx/contracts/order_request.rb +120 -0
- data/lib/coindcx/contracts/socket_backend.rb +19 -0
- data/lib/coindcx/contracts/wallet_transfer_request.rb +46 -0
- data/lib/coindcx/errors/base_error.rb +54 -0
- data/lib/coindcx/logging/null_logger.rb +12 -0
- data/lib/coindcx/logging/structured_logger.rb +17 -0
- data/lib/coindcx/models/balance.rb +8 -0
- data/lib/coindcx/models/base_model.rb +31 -0
- data/lib/coindcx/models/instrument.rb +8 -0
- data/lib/coindcx/models/market.rb +8 -0
- data/lib/coindcx/models/order.rb +8 -0
- data/lib/coindcx/models/trade.rb +8 -0
- data/lib/coindcx/rest/base_resource.rb +35 -0
- data/lib/coindcx/rest/funding/facade.rb +18 -0
- data/lib/coindcx/rest/funding/orders.rb +46 -0
- data/lib/coindcx/rest/futures/facade.rb +29 -0
- data/lib/coindcx/rest/futures/market_data.rb +71 -0
- data/lib/coindcx/rest/futures/orders.rb +47 -0
- data/lib/coindcx/rest/futures/positions.rb +93 -0
- data/lib/coindcx/rest/futures/wallets.rb +44 -0
- data/lib/coindcx/rest/margin/facade.rb +17 -0
- data/lib/coindcx/rest/margin/orders.rb +57 -0
- data/lib/coindcx/rest/public/facade.rb +17 -0
- data/lib/coindcx/rest/public/market_data.rb +52 -0
- data/lib/coindcx/rest/spot/facade.rb +17 -0
- data/lib/coindcx/rest/spot/orders.rb +67 -0
- data/lib/coindcx/rest/transfers/facade.rb +17 -0
- data/lib/coindcx/rest/transfers/wallets.rb +40 -0
- data/lib/coindcx/rest/user/accounts.rb +17 -0
- data/lib/coindcx/rest/user/facade.rb +17 -0
- data/lib/coindcx/transport/circuit_breaker.rb +65 -0
- data/lib/coindcx/transport/http_client.rb +290 -0
- data/lib/coindcx/transport/rate_limit_registry.rb +65 -0
- data/lib/coindcx/transport/request_policy.rb +152 -0
- data/lib/coindcx/transport/response_normalizer.rb +40 -0
- data/lib/coindcx/transport/retry_policy.rb +79 -0
- data/lib/coindcx/utils/payload.rb +51 -0
- data/lib/coindcx/version.rb +5 -0
- data/lib/coindcx/ws/connection_manager.rb +423 -0
- data/lib/coindcx/ws/connection_state.rb +75 -0
- data/lib/coindcx/ws/parsers/order_book_snapshot.rb +42 -0
- data/lib/coindcx/ws/private_channels.rb +38 -0
- data/lib/coindcx/ws/public_channels.rb +92 -0
- data/lib/coindcx/ws/socket_io_client.rb +89 -0
- data/lib/coindcx/ws/socket_io_simple_backend.rb +63 -0
- data/lib/coindcx/ws/subscription_registry.rb +80 -0
- data/lib/coindcx/ws/uri_ruby3_compat.rb +13 -0
- data/lib/coindcx.rb +63 -0
- data/spec/auth_signer_spec.rb +22 -0
- data/spec/client_spec.rb +19 -0
- data/spec/contracts/order_request_spec.rb +136 -0
- data/spec/contracts/wallet_transfer_request_spec.rb +45 -0
- data/spec/models/base_model_spec.rb +18 -0
- data/spec/rest/funding/orders_spec.rb +43 -0
- data/spec/rest/futures/market_data_spec.rb +49 -0
- data/spec/rest/futures/orders_spec.rb +107 -0
- data/spec/rest/futures/positions_spec.rb +57 -0
- data/spec/rest/futures/wallets_spec.rb +44 -0
- data/spec/rest/margin/orders_spec.rb +87 -0
- data/spec/rest/public/market_data_spec.rb +31 -0
- data/spec/rest/spot/orders_spec.rb +152 -0
- data/spec/rest/transfers/wallets_spec.rb +33 -0
- data/spec/rest/user/accounts_spec.rb +21 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/transport/http_client_spec.rb +232 -0
- data/spec/transport/rate_limit_registry_spec.rb +28 -0
- data/spec/transport/request_policy_spec.rb +67 -0
- data/spec/transport/response_normalizer_spec.rb +63 -0
- data/spec/ws/connection_manager_spec.rb +339 -0
- data/spec/ws/order_book_snapshot_spec.rb +25 -0
- data/spec/ws/private_channels_spec.rb +28 -0
- data/spec/ws/public_channels_spec.rb +89 -0
- data/spec/ws/socket_io_client_spec.rb +229 -0
- data/spec/ws/socket_io_simple_backend_spec.rb +41 -0
- data/spec/ws/uri_ruby3_compat_spec.rb +12 -0
- metadata +164 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "spec_helper"
|
|
5
|
+
|
|
6
|
+
RSpec.describe CoinDCX::Transport::HttpClient do
|
|
7
|
+
let(:configuration) do
|
|
8
|
+
CoinDCX::Configuration.new.tap do |config|
|
|
9
|
+
config.api_key = "api-key"
|
|
10
|
+
config.api_secret = "api-secret"
|
|
11
|
+
config.logger = logger
|
|
12
|
+
config.retry_base_interval = 0.01
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:logger) { instance_double("Logger", info: nil, warn: nil, error: nil) }
|
|
17
|
+
let(:sleeper) { class_double(Kernel, sleep: nil) }
|
|
18
|
+
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
|
|
19
|
+
subject(:http_client) { described_class.new(configuration: configuration, stubs: stubs, sleeper: sleeper) }
|
|
20
|
+
|
|
21
|
+
describe "#get" do
|
|
22
|
+
context "when authenticated with a JSON body (CoinDCX futures wallet-style GET)" do
|
|
23
|
+
it "sends signed JSON in the request body and query params on the URL" do
|
|
24
|
+
stubs.get("/exchange/v1/derivatives/futures/wallets/transactions") do |env|
|
|
25
|
+
expect(env.request_headers["X-AUTH-APIKEY"]).to eq("api-key")
|
|
26
|
+
expect(env.request_headers["X-AUTH-SIGNATURE"]).not_to be_nil
|
|
27
|
+
expect(env.body).to match(/"timestamp":\d+/)
|
|
28
|
+
expect(env.url.query).to eq("page=1&size=10")
|
|
29
|
+
[200, { "Content-Type" => "application/json" }, "[]"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
response_body = http_client.get(
|
|
33
|
+
"/exchange/v1/derivatives/futures/wallets/transactions",
|
|
34
|
+
params: { page: 1, size: 10 },
|
|
35
|
+
body: {},
|
|
36
|
+
auth: true,
|
|
37
|
+
bucket: :futures_wallet_transactions
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(response_body).to eq([])
|
|
41
|
+
stubs.verify_stubbed_calls
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe "#post" do
|
|
47
|
+
context "when the response is successful" do
|
|
48
|
+
it "returns the parsed response data and logs the request metadata" do
|
|
49
|
+
stubs.post("/exchange/v1/orders/create") do |env|
|
|
50
|
+
expect(env.request_headers["X-AUTH-APIKEY"]).to eq("api-key")
|
|
51
|
+
expect(env.request_headers["X-AUTH-SIGNATURE"]).not_to be_nil
|
|
52
|
+
expect(env.body).to include('"market":"SNTBTC"')
|
|
53
|
+
[200, { "Content-Type" => "application/json" }, '{"id":"123"}']
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
response_body = http_client.post(
|
|
57
|
+
"/exchange/v1/orders/create",
|
|
58
|
+
auth: true,
|
|
59
|
+
bucket: :spot_create_order,
|
|
60
|
+
body: { market: "SNTBTC", client_order_id: "spot-create-1" }
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(response_body).to eq("id" => "123")
|
|
64
|
+
expect(logger).to have_received(:info).with(
|
|
65
|
+
hash_including(
|
|
66
|
+
event: "api_call",
|
|
67
|
+
endpoint: "/exchange/v1/orders/create",
|
|
68
|
+
request_id: a_string_matching(/\A[0-9a-f-]{36}\z/),
|
|
69
|
+
retries: 0
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
stubs.verify_stubbed_calls
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
context "when CoinDCX responds with a rate limit error" do
|
|
77
|
+
it "raises a rate limit error" do
|
|
78
|
+
stubs.post("/exchange/v1/orders/create") do
|
|
79
|
+
[429, { "Content-Type" => "application/json" }, '{"message":"too many requests"}']
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
request_call = lambda do
|
|
83
|
+
http_client.post(
|
|
84
|
+
"/exchange/v1/orders/create",
|
|
85
|
+
auth: true,
|
|
86
|
+
bucket: :spot_create_order,
|
|
87
|
+
body: { market: "SNTBTC", client_order_id: "spot-create-2" }
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
expect(&request_call).to raise_error(CoinDCX::Errors::RateLimitError) do |error|
|
|
92
|
+
expect(error.status).to eq(429)
|
|
93
|
+
expect(error.body).to include(success: false, data: {})
|
|
94
|
+
expect(error.body[:error]).to include(
|
|
95
|
+
category: :rate_limit,
|
|
96
|
+
code: 429,
|
|
97
|
+
message: "too many requests",
|
|
98
|
+
retryable: false
|
|
99
|
+
)
|
|
100
|
+
expect(error.body.dig(:error, :request_context)).to include(
|
|
101
|
+
base: :api,
|
|
102
|
+
endpoint: "/exchange/v1/orders/create",
|
|
103
|
+
method: "POST",
|
|
104
|
+
operation: "post_exchange_v1_orders_create"
|
|
105
|
+
)
|
|
106
|
+
expect(error.body.dig(:error, :request_context, :request_id)).to match(/\A[0-9a-f-]{36}\z/)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
context "when CoinDCX responds with a retryable rate limit error" do
|
|
112
|
+
it "retries when Retry-After is present and the endpoint budget allows it" do
|
|
113
|
+
attempts = 0
|
|
114
|
+
stubs.post("/exchange/v1/orders/status") do
|
|
115
|
+
attempts += 1
|
|
116
|
+
if attempts == 1
|
|
117
|
+
[429, { "Content-Type" => "application/json", "Retry-After" => "0.5" }, '{"message":"slow down"}']
|
|
118
|
+
else
|
|
119
|
+
[200, { "Content-Type" => "application/json" }, '{"id":"123"}']
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
response_body = http_client.post("/exchange/v1/orders/status", auth: true, body: { id: "123" }, bucket: :spot_order_status)
|
|
124
|
+
|
|
125
|
+
expect(response_body).to eq("id" => "123")
|
|
126
|
+
expect(attempts).to eq(2)
|
|
127
|
+
expect(sleeper).to have_received(:sleep).with(0.5).once
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
context "when CoinDCX responds with a retryable upstream error" do
|
|
132
|
+
it "retries a private read endpoint within its retry budget" do
|
|
133
|
+
attempts = 0
|
|
134
|
+
stubs.post("/exchange/v1/orders/status") do
|
|
135
|
+
attempts += 1
|
|
136
|
+
if attempts == 1
|
|
137
|
+
[503, { "Content-Type" => "application/json" }, '{"message":"temporary outage"}']
|
|
138
|
+
else
|
|
139
|
+
[200, { "Content-Type" => "application/json" }, '{"id":"123"}']
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
response_body = http_client.post("/exchange/v1/orders/status", auth: true, body: { id: "123" }, bucket: :spot_order_status)
|
|
144
|
+
|
|
145
|
+
expect(response_body).to eq("id" => "123")
|
|
146
|
+
expect(attempts).to eq(2)
|
|
147
|
+
expect(logger).to have_received(:warn).with(
|
|
148
|
+
hash_including(
|
|
149
|
+
event: "api_retry",
|
|
150
|
+
endpoint: "/exchange/v1/orders/status",
|
|
151
|
+
retries: 1
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
context "when an order create request does not include client_order_id" do
|
|
158
|
+
it "rejects the request before transport execution" do
|
|
159
|
+
expect do
|
|
160
|
+
http_client.post(
|
|
161
|
+
"/exchange/v1/orders/create",
|
|
162
|
+
auth: true,
|
|
163
|
+
bucket: :spot_create_order,
|
|
164
|
+
body: { market: "SNTBTC" }
|
|
165
|
+
)
|
|
166
|
+
end.to raise_error(CoinDCX::Errors::ValidationError, /client_order_id/)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
context "when an order create request times out with an idempotency key" do
|
|
171
|
+
it "retries the request" do
|
|
172
|
+
attempts = 0
|
|
173
|
+
stubs.post("/exchange/v1/orders/create") do
|
|
174
|
+
attempts += 1
|
|
175
|
+
raise Faraday::TimeoutError, "timed out" if attempts == 1
|
|
176
|
+
|
|
177
|
+
[200, { "Content-Type" => "application/json" }, '{"id":"123"}']
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
response_body = http_client.post(
|
|
181
|
+
"/exchange/v1/orders/create",
|
|
182
|
+
auth: true,
|
|
183
|
+
bucket: :spot_create_order,
|
|
184
|
+
body: { market: "SNTBTC", client_order_id: "client-123" }
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(response_body).to eq("id" => "123")
|
|
188
|
+
expect(attempts).to eq(2)
|
|
189
|
+
expect(sleeper).to have_received(:sleep).with(0.01).once
|
|
190
|
+
expect(logger).to have_received(:warn).with(
|
|
191
|
+
hash_including(
|
|
192
|
+
event: "api_retry",
|
|
193
|
+
endpoint: "/exchange/v1/orders/create",
|
|
194
|
+
retries: 1
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
context "when an upstream error repeatedly hits a critical order endpoint" do
|
|
201
|
+
it "opens the circuit breaker for subsequent calls" do
|
|
202
|
+
configuration.circuit_breaker_threshold = 2
|
|
203
|
+
configuration.idempotent_order_retry_budget = 0
|
|
204
|
+
|
|
205
|
+
failing_client = described_class.new(configuration: configuration, stubs: stubs, sleeper: sleeper)
|
|
206
|
+
stubs.post("/exchange/v1/orders/create") do
|
|
207
|
+
[503, { "Content-Type" => "application/json" }, '{"message":"temporary outage"}']
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
2.times do
|
|
211
|
+
expect do
|
|
212
|
+
failing_client.post(
|
|
213
|
+
"/exchange/v1/orders/create",
|
|
214
|
+
auth: true,
|
|
215
|
+
bucket: :spot_create_order,
|
|
216
|
+
body: { market: "SNTBTC", client_order_id: "client-123" }
|
|
217
|
+
)
|
|
218
|
+
end.to raise_error(CoinDCX::Errors::UpstreamServerError)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
expect do
|
|
222
|
+
failing_client.post(
|
|
223
|
+
"/exchange/v1/orders/create",
|
|
224
|
+
auth: true,
|
|
225
|
+
bucket: :spot_create_order,
|
|
226
|
+
body: { market: "SNTBTC", client_order_id: "client-123" }
|
|
227
|
+
)
|
|
228
|
+
end.to raise_error(CoinDCX::Errors::CircuitOpenError)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CoinDCX::Transport::RateLimitRegistry do
|
|
6
|
+
let(:definitions) { { spot_create_order: { limit: 1, period: 60 } } }
|
|
7
|
+
subject(:registry) { described_class.new(definitions) }
|
|
8
|
+
|
|
9
|
+
it 'returns immediately for unknown buckets when throttling is optional' do
|
|
10
|
+
expect { registry.throttle(:unknown_bucket) }.not_to raise_error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'raises when a required bucket has no rate limit definition' do
|
|
14
|
+
expect { registry.throttle(:unknown_bucket, required: true) }
|
|
15
|
+
.to raise_error(CoinDCX::Errors::ConfigurationError, 'missing rate limit definition for unknown_bucket')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'sleeps when a bucket is exhausted' do
|
|
19
|
+
allow(registry).to receive(:sleep)
|
|
20
|
+
monotonic_times = [0.0, 5.0, 61.0]
|
|
21
|
+
allow(registry).to receive(:monotonic_time) { monotonic_times.shift }
|
|
22
|
+
|
|
23
|
+
registry.throttle(:spot_create_order)
|
|
24
|
+
registry.throttle(:spot_create_order)
|
|
25
|
+
|
|
26
|
+
expect(registry).to have_received(:sleep).with(55.0).once
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CoinDCX::Transport::RequestPolicy do
|
|
6
|
+
let(:configuration) { CoinDCX::Configuration.new }
|
|
7
|
+
|
|
8
|
+
describe ".build" do
|
|
9
|
+
it "marks order create endpoints as idempotency-required" do
|
|
10
|
+
policy = described_class.build(
|
|
11
|
+
configuration: configuration,
|
|
12
|
+
method: :post,
|
|
13
|
+
path: "/exchange/v1/orders/create",
|
|
14
|
+
body: { market: "SNTBTC", client_order_id: "spot-1" },
|
|
15
|
+
auth: true,
|
|
16
|
+
bucket: :spot_create_order
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
expect(policy.requires_idempotency?).to be(true)
|
|
20
|
+
expect(policy.idempotency_satisfied?).to be(true)
|
|
21
|
+
expect(policy.retry_budget).to eq(configuration.idempotent_order_retry_budget)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "disables retries when idempotency is missing on unsafe endpoints" do
|
|
25
|
+
policy = described_class.build(
|
|
26
|
+
configuration: configuration,
|
|
27
|
+
method: :post,
|
|
28
|
+
path: "/exchange/v1/orders/create",
|
|
29
|
+
body: { market: "SNTBTC" },
|
|
30
|
+
auth: true,
|
|
31
|
+
bucket: :spot_create_order
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(policy.requires_idempotency?).to be(true)
|
|
35
|
+
expect(policy.idempotency_satisfied?).to be(false)
|
|
36
|
+
expect(policy.retry_budget).to eq(0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "requires every batch order to carry an idempotency key" do
|
|
40
|
+
policy = described_class.build(
|
|
41
|
+
configuration: configuration,
|
|
42
|
+
method: :post,
|
|
43
|
+
path: "/exchange/v1/orders/create_multiple",
|
|
44
|
+
body: { orders: [{ market: "SNTBTC", client_order_id: "spot-1" }, { market: "ETHBTC" }] },
|
|
45
|
+
auth: true,
|
|
46
|
+
bucket: :spot_create_order_multiple
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
expect(policy.requires_idempotency?).to be(true)
|
|
50
|
+
expect(policy.idempotency_satisfied?).to be(false)
|
|
51
|
+
expect(policy.retry_budget).to eq(0)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "uses the private read retry budget for authenticated futures instrument GET" do
|
|
55
|
+
policy = described_class.build(
|
|
56
|
+
configuration: configuration,
|
|
57
|
+
method: :get,
|
|
58
|
+
path: "/exchange/v1/derivatives/futures/data/instrument",
|
|
59
|
+
body: {},
|
|
60
|
+
auth: true,
|
|
61
|
+
bucket: :futures_instrument_detail
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(policy.retry_budget).to eq(configuration.private_read_retry_budget)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CoinDCX::Transport::ResponseNormalizer do
|
|
6
|
+
describe ".success" do
|
|
7
|
+
it "wraps response data in a consistent success shape" do
|
|
8
|
+
expect(described_class.success("id" => "123")).to eq(
|
|
9
|
+
success: true,
|
|
10
|
+
data: { "id" => "123" },
|
|
11
|
+
error: nil
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe ".failure" do
|
|
17
|
+
it "extracts normalized error details from a hash body" do
|
|
18
|
+
normalized_error = described_class.failure(
|
|
19
|
+
status: 429,
|
|
20
|
+
body: { "message" => "too many requests", "code" => "rate_limit" },
|
|
21
|
+
fallback_message: "request failed",
|
|
22
|
+
category: :rate_limit,
|
|
23
|
+
request_context: { endpoint: "/exchange/v1/orders/create", request_id: "request-123" },
|
|
24
|
+
retryable: true
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(normalized_error).to eq(
|
|
28
|
+
success: false,
|
|
29
|
+
data: {},
|
|
30
|
+
error: {
|
|
31
|
+
category: :rate_limit,
|
|
32
|
+
code: "rate_limit",
|
|
33
|
+
message: "too many requests",
|
|
34
|
+
request_context: { endpoint: "/exchange/v1/orders/create", request_id: "request-123" },
|
|
35
|
+
retryable: true
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "falls back to a readable message for string bodies" do
|
|
41
|
+
normalized_error = described_class.failure(
|
|
42
|
+
status: 500,
|
|
43
|
+
body: "upstream failure",
|
|
44
|
+
fallback_message: "request failed",
|
|
45
|
+
category: :upstream,
|
|
46
|
+
request_context: { endpoint: "/exchange/v1/orders/create" },
|
|
47
|
+
retryable: false
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expect(normalized_error).to eq(
|
|
51
|
+
success: false,
|
|
52
|
+
data: {},
|
|
53
|
+
error: {
|
|
54
|
+
category: :upstream,
|
|
55
|
+
code: 500,
|
|
56
|
+
message: "upstream failure",
|
|
57
|
+
request_context: { endpoint: "/exchange/v1/orders/create" },
|
|
58
|
+
retryable: false
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|