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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.github/workflows/release.yml +138 -0
  4. data/.rubocop.yml +56 -0
  5. data/AGENT.md +352 -0
  6. data/README.md +224 -0
  7. data/bin/console +59 -0
  8. data/docs/README.md +29 -0
  9. data/docs/coindcx_docs_gaps.md +3 -0
  10. data/docs/core.md +179 -0
  11. data/docs/rails_integration.md +151 -0
  12. data/docs/standalone_bot.md +159 -0
  13. data/lib/coindcx/auth/signer.rb +48 -0
  14. data/lib/coindcx/client.rb +44 -0
  15. data/lib/coindcx/configuration.rb +108 -0
  16. data/lib/coindcx/contracts/channel_name.rb +23 -0
  17. data/lib/coindcx/contracts/identifiers.rb +36 -0
  18. data/lib/coindcx/contracts/order_request.rb +120 -0
  19. data/lib/coindcx/contracts/socket_backend.rb +19 -0
  20. data/lib/coindcx/contracts/wallet_transfer_request.rb +46 -0
  21. data/lib/coindcx/errors/base_error.rb +54 -0
  22. data/lib/coindcx/logging/null_logger.rb +12 -0
  23. data/lib/coindcx/logging/structured_logger.rb +17 -0
  24. data/lib/coindcx/models/balance.rb +8 -0
  25. data/lib/coindcx/models/base_model.rb +31 -0
  26. data/lib/coindcx/models/instrument.rb +8 -0
  27. data/lib/coindcx/models/market.rb +8 -0
  28. data/lib/coindcx/models/order.rb +8 -0
  29. data/lib/coindcx/models/trade.rb +8 -0
  30. data/lib/coindcx/rest/base_resource.rb +35 -0
  31. data/lib/coindcx/rest/funding/facade.rb +18 -0
  32. data/lib/coindcx/rest/funding/orders.rb +46 -0
  33. data/lib/coindcx/rest/futures/facade.rb +29 -0
  34. data/lib/coindcx/rest/futures/market_data.rb +71 -0
  35. data/lib/coindcx/rest/futures/orders.rb +47 -0
  36. data/lib/coindcx/rest/futures/positions.rb +93 -0
  37. data/lib/coindcx/rest/futures/wallets.rb +44 -0
  38. data/lib/coindcx/rest/margin/facade.rb +17 -0
  39. data/lib/coindcx/rest/margin/orders.rb +57 -0
  40. data/lib/coindcx/rest/public/facade.rb +17 -0
  41. data/lib/coindcx/rest/public/market_data.rb +52 -0
  42. data/lib/coindcx/rest/spot/facade.rb +17 -0
  43. data/lib/coindcx/rest/spot/orders.rb +67 -0
  44. data/lib/coindcx/rest/transfers/facade.rb +17 -0
  45. data/lib/coindcx/rest/transfers/wallets.rb +40 -0
  46. data/lib/coindcx/rest/user/accounts.rb +17 -0
  47. data/lib/coindcx/rest/user/facade.rb +17 -0
  48. data/lib/coindcx/transport/circuit_breaker.rb +65 -0
  49. data/lib/coindcx/transport/http_client.rb +290 -0
  50. data/lib/coindcx/transport/rate_limit_registry.rb +65 -0
  51. data/lib/coindcx/transport/request_policy.rb +152 -0
  52. data/lib/coindcx/transport/response_normalizer.rb +40 -0
  53. data/lib/coindcx/transport/retry_policy.rb +79 -0
  54. data/lib/coindcx/utils/payload.rb +51 -0
  55. data/lib/coindcx/version.rb +5 -0
  56. data/lib/coindcx/ws/connection_manager.rb +423 -0
  57. data/lib/coindcx/ws/connection_state.rb +75 -0
  58. data/lib/coindcx/ws/parsers/order_book_snapshot.rb +42 -0
  59. data/lib/coindcx/ws/private_channels.rb +38 -0
  60. data/lib/coindcx/ws/public_channels.rb +92 -0
  61. data/lib/coindcx/ws/socket_io_client.rb +89 -0
  62. data/lib/coindcx/ws/socket_io_simple_backend.rb +63 -0
  63. data/lib/coindcx/ws/subscription_registry.rb +80 -0
  64. data/lib/coindcx/ws/uri_ruby3_compat.rb +13 -0
  65. data/lib/coindcx.rb +63 -0
  66. data/spec/auth_signer_spec.rb +22 -0
  67. data/spec/client_spec.rb +19 -0
  68. data/spec/contracts/order_request_spec.rb +136 -0
  69. data/spec/contracts/wallet_transfer_request_spec.rb +45 -0
  70. data/spec/models/base_model_spec.rb +18 -0
  71. data/spec/rest/funding/orders_spec.rb +43 -0
  72. data/spec/rest/futures/market_data_spec.rb +49 -0
  73. data/spec/rest/futures/orders_spec.rb +107 -0
  74. data/spec/rest/futures/positions_spec.rb +57 -0
  75. data/spec/rest/futures/wallets_spec.rb +44 -0
  76. data/spec/rest/margin/orders_spec.rb +87 -0
  77. data/spec/rest/public/market_data_spec.rb +31 -0
  78. data/spec/rest/spot/orders_spec.rb +152 -0
  79. data/spec/rest/transfers/wallets_spec.rb +33 -0
  80. data/spec/rest/user/accounts_spec.rb +21 -0
  81. data/spec/spec_helper.rb +11 -0
  82. data/spec/transport/http_client_spec.rb +232 -0
  83. data/spec/transport/rate_limit_registry_spec.rb +28 -0
  84. data/spec/transport/request_policy_spec.rb +67 -0
  85. data/spec/transport/response_normalizer_spec.rb +63 -0
  86. data/spec/ws/connection_manager_spec.rb +339 -0
  87. data/spec/ws/order_book_snapshot_spec.rb +25 -0
  88. data/spec/ws/private_channels_spec.rb +28 -0
  89. data/spec/ws/public_channels_spec.rb +89 -0
  90. data/spec/ws/socket_io_client_spec.rb +229 -0
  91. data/spec/ws/socket_io_simple_backend_spec.rb +41 -0
  92. data/spec/ws/uri_ruby3_compat_spec.rb +12 -0
  93. metadata +164 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module REST
5
+ module Spot
6
+ class Orders < BaseResource
7
+ def create(attributes)
8
+ validated_attributes = Contracts::OrderRequest.validate_spot_create!(attributes)
9
+ build_model(Models::Order, post("/exchange/v1/orders/create", auth: true, bucket: :spot_create_order, body: validated_attributes))
10
+ end
11
+
12
+ def create_many(orders:)
13
+ validated_orders = Contracts::OrderRequest.validate_spot_create_many!(orders)
14
+ build_models(
15
+ Models::Order,
16
+ post("/exchange/v1/orders/create_multiple", auth: true, bucket: :spot_create_order_multiple, body: { orders: validated_orders })
17
+ )
18
+ end
19
+
20
+ def fetch_status(attributes)
21
+ build_model(Models::Order, post("/exchange/v1/orders/status", auth: true, bucket: :spot_order_status, body: attributes))
22
+ end
23
+
24
+ def fetch_statuses(attributes)
25
+ build_models(
26
+ Models::Order,
27
+ post("/exchange/v1/orders/status_multiple", auth: true, bucket: :spot_order_status_multiple, body: attributes)
28
+ )
29
+ end
30
+
31
+ def list_active(attributes = {})
32
+ build_models(
33
+ Models::Order,
34
+ post("/exchange/v1/orders/active_orders", auth: true, bucket: :spot_active_order, body: attributes)
35
+ )
36
+ end
37
+
38
+ def count_active(attributes = {})
39
+ post("/exchange/v1/orders/active_orders_count", auth: true, bucket: :spot_active_order_count, body: attributes)
40
+ end
41
+
42
+ def list_trade_history(attributes = {})
43
+ build_models(Models::Trade, post("/exchange/v1/orders/trade_history", auth: true, bucket: :spot_trade_history, body: attributes))
44
+ end
45
+
46
+ def cancel(attributes)
47
+ build_model(Models::Order, post("/exchange/v1/orders/cancel", auth: true, bucket: :spot_cancel_order, body: attributes))
48
+ end
49
+
50
+ def cancel_many(attributes)
51
+ build_models(
52
+ Models::Order,
53
+ post("/exchange/v1/orders/cancel_by_ids", auth: true, bucket: :spot_cancel_multiple_by_id, body: attributes)
54
+ )
55
+ end
56
+
57
+ def cancel_all(attributes = {})
58
+ post("/exchange/v1/orders/cancel_all", auth: true, bucket: :spot_cancel_all, body: attributes)
59
+ end
60
+
61
+ def edit_price(attributes)
62
+ build_model(Models::Order, post("/exchange/v1/orders/edit", auth: true, bucket: :spot_edit_price, body: attributes))
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module REST
5
+ module Transfers
6
+ class Facade
7
+ def initialize(http_client:)
8
+ @http_client = http_client
9
+ end
10
+
11
+ def wallets
12
+ @wallets ||= Wallets.new(http_client: @http_client)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module REST
5
+ module Transfers
6
+ class Wallets < BaseResource
7
+ def transfer(source_wallet_type:, destination_wallet_type:, currency_short_name:, amount:, timestamp: nil)
8
+ validated_attributes = Contracts::WalletTransferRequest.validate_transfer!(
9
+ source_wallet_type: source_wallet_type,
10
+ destination_wallet_type: destination_wallet_type,
11
+ currency_short_name: currency_short_name,
12
+ amount: amount,
13
+ timestamp: timestamp
14
+ )
15
+ post(
16
+ "/exchange/v1/wallets/transfer",
17
+ auth: true,
18
+ bucket: :wallets_transfer,
19
+ body: validated_attributes
20
+ )
21
+ end
22
+
23
+ def sub_account_transfer(from_account_id:, to_account_id:, currency_short_name:, amount:, timestamp: nil)
24
+ post(
25
+ "/exchange/v1/wallets/sub_account_transfer",
26
+ auth: true,
27
+ bucket: :wallets_sub_account_transfer,
28
+ body: {
29
+ from_account_id: from_account_id,
30
+ to_account_id: to_account_id,
31
+ currency_short_name: currency_short_name,
32
+ amount: amount,
33
+ timestamp: timestamp
34
+ }
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module REST
5
+ module User
6
+ class Accounts < BaseResource
7
+ def list_balances(attributes = {})
8
+ build_models(Models::Balance, post("/exchange/v1/users/balances", auth: true, bucket: :user_balances, body: attributes))
9
+ end
10
+
11
+ def fetch_info(attributes = {})
12
+ post("/exchange/v1/users/info", auth: true, bucket: :user_info, body: attributes)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module REST
5
+ module User
6
+ class Facade
7
+ def initialize(http_client:)
8
+ @http_client = http_client
9
+ end
10
+
11
+ def accounts
12
+ @accounts ||= Accounts.new(http_client: @http_client)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Transport
5
+ class CircuitBreaker
6
+ def initialize(threshold:, cooldown:, monotonic_clock: nil)
7
+ @threshold = threshold
8
+ @cooldown = cooldown
9
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
10
+ @states = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def guard(key, request_context:)
15
+ return yield if key.nil?
16
+
17
+ raise open_error(key, request_context) if open?(key)
18
+
19
+ yield.tap { record_success(key) }
20
+ rescue Errors::TransportError, Errors::UpstreamServerError, Errors::RetryableRateLimitError => e
21
+ record_failure(key)
22
+ raise e
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :threshold, :cooldown, :monotonic_clock
28
+
29
+ def open?(key)
30
+ state = @mutex.synchronize { @states[key] }
31
+ return false if state.nil?
32
+ return false if state.fetch(:failures) < threshold
33
+
34
+ monotonic_time < state.fetch(:opened_at) + cooldown
35
+ end
36
+
37
+ def record_success(key)
38
+ @mutex.synchronize { @states.delete(key) }
39
+ end
40
+
41
+ def record_failure(key)
42
+ @mutex.synchronize do
43
+ state = @states[key] || { failures: 0, opened_at: monotonic_time }
44
+ state[:failures] += 1
45
+ state[:opened_at] = monotonic_time if state[:failures] >= threshold
46
+ @states[key] = state
47
+ end
48
+ end
49
+
50
+ def open_error(key, request_context)
51
+ Errors::CircuitOpenError.new(
52
+ "CoinDCX circuit is open for #{key}",
53
+ category: :circuit_open,
54
+ code: "circuit_open",
55
+ request_context: request_context,
56
+ retryable: false
57
+ )
58
+ end
59
+
60
+ def monotonic_time
61
+ monotonic_clock.call
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module CoinDCX
8
+ module Transport
9
+ # rubocop:disable Metrics/ClassLength
10
+ class HttpClient
11
+ # rubocop:disable Metrics/MethodLength
12
+ def initialize(configuration:, stubs: nil, sleeper: Kernel, monotonic_clock: nil)
13
+ @configuration = configuration
14
+ @rate_limits = RateLimitRegistry.new(configuration.endpoint_rate_limits)
15
+ @logger = configuration.logger || Logging::NullLogger.new
16
+ @retry_policy = RetryPolicy.new(
17
+ max_retries: configuration.max_retries,
18
+ base_interval: configuration.retry_base_interval,
19
+ logger: @logger,
20
+ sleeper: sleeper
21
+ )
22
+ @circuit_breaker = CircuitBreaker.new(
23
+ threshold: configuration.circuit_breaker_threshold,
24
+ cooldown: configuration.circuit_breaker_cooldown,
25
+ monotonic_clock: monotonic_clock
26
+ )
27
+ @connections = {
28
+ api: build_connection(configuration.api_base_url, stubs),
29
+ public: build_connection(configuration.public_base_url, stubs)
30
+ }
31
+ end
32
+ # rubocop:enable Metrics/MethodLength
33
+
34
+ attr_reader :configuration
35
+
36
+ def get(path, params: {}, body: {}, auth: false, base: :api, bucket: nil)
37
+ request(:get, path, params: params, body: body, auth: auth, base: base, bucket: bucket)
38
+ end
39
+
40
+ def post(path, body: {}, auth: false, base: :api, bucket: nil)
41
+ request(:post, path, params: {}, body: body, auth: auth, base: base, bucket: bucket)
42
+ end
43
+
44
+ def delete(path, body: {}, auth: false, base: :api, bucket: nil)
45
+ request(:delete, path, params: {}, body: body, auth: auth, base: base, bucket: bucket)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :logger, :rate_limits, :retry_policy, :circuit_breaker
51
+
52
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
53
+ def request(method, path, params:, body:, auth:, base:, bucket:)
54
+ policy = RequestPolicy.build(
55
+ configuration: configuration,
56
+ method: method,
57
+ path: path,
58
+ body: body,
59
+ auth: auth,
60
+ bucket: bucket
61
+ )
62
+ request_id = SecureRandom.uuid
63
+ started_at = monotonic_time
64
+ retries = 0
65
+ response_status = nil
66
+ context = request_context(
67
+ method: method,
68
+ path: path,
69
+ base: base,
70
+ request_id: request_id,
71
+ operation_name: policy.operation_name
72
+ )
73
+
74
+ enforce_idempotency_contract!(policy: policy, path: path, request_context: context)
75
+ rate_limits.throttle(policy.bucket, required: auth)
76
+
77
+ normalized_response = circuit_breaker.guard(policy.circuit_breaker_key, request_context: context) do
78
+ retry_policy.with_retries(context, policy: policy) do |attempts|
79
+ retries = attempts - 1
80
+ response = connection_for(base).public_send(method, path) do |request|
81
+ request.headers["Accept"] = "application/json"
82
+ request.headers["User-Agent"] = configuration.user_agent
83
+ request.options.timeout = configuration.read_timeout
84
+ request.options.open_timeout = configuration.open_timeout
85
+ apply_payload(request, method: method, params: params, body: body, auth: auth)
86
+ end
87
+
88
+ response_status = response.status.to_i
89
+ parse_response(
90
+ response,
91
+ path,
92
+ request_context: context,
93
+ policy: policy
94
+ )
95
+ end
96
+ end
97
+
98
+ log(
99
+ :info,
100
+ event: "api_call",
101
+ endpoint: path,
102
+ operation: policy.operation_name,
103
+ request_id: request_id,
104
+ latency: elapsed_since(started_at),
105
+ retries: retries,
106
+ response_status: response_status
107
+ )
108
+ normalized_response.fetch(:data)
109
+ rescue Errors::ApiError => e
110
+ log(
111
+ :error,
112
+ event: "api_call_failed",
113
+ endpoint: path,
114
+ operation: policy.operation_name,
115
+ request_id: request_id,
116
+ latency: elapsed_since(started_at),
117
+ retries: retries,
118
+ response_status: e.status,
119
+ category: e.category,
120
+ error_code: normalized_error_code(e.body),
121
+ error_message: normalized_error_message(e.body)
122
+ )
123
+ raise
124
+ end
125
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
126
+
127
+ def apply_payload(request, method:, params:, body:, auth:)
128
+ request.params.update(encode_query(params)) unless params.empty?
129
+ return if method == :get && !auth
130
+
131
+ normalized_body = auth ? authenticated_body(request, body) : plain_body(request, body)
132
+ request.body = JSON.generate(Utils::Payload.stringify_keys(normalized_body)) unless normalized_body.empty?
133
+ end
134
+
135
+ def enforce_idempotency_contract!(policy:, path:, request_context:)
136
+ return unless policy.requires_idempotency?
137
+ return if policy.idempotency_satisfied?
138
+
139
+ raise Errors::ValidationError.new(
140
+ "client_order_id is required for #{path}",
141
+ category: :validation,
142
+ code: "missing_client_order_id",
143
+ request_context: request_context,
144
+ retryable: false
145
+ )
146
+ end
147
+
148
+ def authenticated_body(request, body)
149
+ signer = Auth::Signer.new(api_key: fetch_api_key, api_secret: fetch_api_secret)
150
+ normalized_body, headers = signer.authenticated_request(body)
151
+ request.headers["Content-Type"] = "application/json"
152
+ headers.each { |header_name, value| request.headers[header_name] = value }
153
+ normalized_body
154
+ end
155
+
156
+ def plain_body(request, body)
157
+ normalized_body = Utils::Payload.compact_hash(body)
158
+ request.headers["Content-Type"] = "application/json"
159
+ normalized_body
160
+ end
161
+
162
+ def parse_response(response, path, request_context:, policy:)
163
+ parsed_body = parse_body(response.body)
164
+ status = response.status.to_i
165
+ return ResponseNormalizer.success(parsed_body) if status.between?(200, 299)
166
+
167
+ raise classify_error(
168
+ status,
169
+ path,
170
+ parsed_body,
171
+ headers: response.headers,
172
+ request_context: request_context,
173
+ policy: policy
174
+ )
175
+ end
176
+
177
+ # rubocop:disable Metrics/MethodLength
178
+ def classify_error(status, path, body, headers:, request_context:, policy:)
179
+ message = "CoinDCX request failed for #{path}"
180
+ error_class, category, retryable = error_details_for(status: status, headers: headers, policy: policy)
181
+ normalized_body = ResponseNormalizer.failure(
182
+ status: status,
183
+ body: body,
184
+ fallback_message: message,
185
+ category: category,
186
+ request_context: request_context,
187
+ retryable: retryable
188
+ )
189
+ error_class.new(
190
+ message,
191
+ status: status,
192
+ body: normalized_body,
193
+ category: category,
194
+ code: normalized_body.dig(:error, :code),
195
+ request_context: request_context,
196
+ retryable: retryable,
197
+ retry_after: policy.retry_after(headers)
198
+ )
199
+ end
200
+ # rubocop:enable Metrics/MethodLength
201
+
202
+ def parse_body(body)
203
+ return {} if body.nil? || body.strip.empty?
204
+
205
+ JSON.parse(body)
206
+ rescue JSON::ParserError
207
+ body
208
+ end
209
+
210
+ def encode_query(params)
211
+ Utils::Payload.compact_hash(params).each_with_object({}) do |(key, value), result|
212
+ result[key.to_s] = value
213
+ end
214
+ end
215
+
216
+ def request_context(method:, path:, base:, request_id:, operation_name:)
217
+ {
218
+ endpoint: path,
219
+ request_id: request_id,
220
+ method: method.to_s.upcase,
221
+ base: base,
222
+ operation: operation_name
223
+ }
224
+ end
225
+
226
+ def normalized_error_code(body)
227
+ return nil unless body.respond_to?(:dig)
228
+
229
+ body.dig(:error, :code)
230
+ end
231
+
232
+ def normalized_error_message(body)
233
+ return body.message if body.is_a?(StandardError)
234
+ return body.to_s unless body.respond_to?(:dig)
235
+
236
+ body.dig(:error, :message)
237
+ end
238
+
239
+ def error_details_for(status:, headers:, policy:)
240
+ return [Errors::AuthError, :auth, false] if status == 401
241
+ if status == 429 && policy.retryable_response?(status: status, headers: headers)
242
+ return [Errors::RetryableRateLimitError, :rate_limit, true]
243
+ end
244
+ return [Errors::RateLimitError, :rate_limit, false] if status == 429
245
+ return [Errors::UpstreamServerError, :upstream, policy.retryable_response?(status: status, headers: headers)] if status >= 500
246
+ return [Errors::RemoteValidationError, :validation, false] if policy.validation_status?(status)
247
+
248
+ [Errors::RequestError, :request, false]
249
+ end
250
+
251
+ def connection_for(base)
252
+ @connections.fetch(base) do
253
+ raise Errors::ConfigurationError, "unknown base url: #{base.inspect}"
254
+ end
255
+ end
256
+
257
+ def build_connection(base_url, stubs)
258
+ Faraday.new(url: base_url) do |connection|
259
+ connection.request :url_encoded
260
+ connection.adapter(stubs ? :test : Faraday.default_adapter, stubs)
261
+ end
262
+ end
263
+
264
+ def fetch_api_key
265
+ return configuration.api_key if configuration.api_key
266
+
267
+ raise Errors::AuthError, "api_key is required for authenticated endpoints"
268
+ end
269
+
270
+ def fetch_api_secret
271
+ return configuration.api_secret if configuration.api_secret
272
+
273
+ raise Errors::AuthError, "api_secret is required for authenticated endpoints"
274
+ end
275
+
276
+ def elapsed_since(started_at)
277
+ monotonic_time - started_at
278
+ end
279
+
280
+ def monotonic_time
281
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
282
+ end
283
+
284
+ def log(level, payload)
285
+ Logging::StructuredLogger.log(logger, level, payload)
286
+ end
287
+ end
288
+ # rubocop:enable Metrics/ClassLength
289
+ end
290
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Transport
5
+ class RateLimitRegistry
6
+ def initialize(definitions = {})
7
+ @definitions = definitions.transform_keys(&:to_sym)
8
+ @timestamps = Hash.new { |hash, key| hash[key] = [] }
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def throttle(bucket_name, required: false)
13
+ definition = definition_for(bucket_name, required: required)
14
+ return if definition.nil?
15
+
16
+ loop do
17
+ wait_time = @mutex.synchronize do
18
+ reserve_slot(bucket_name, definition)
19
+ end
20
+
21
+ return if wait_time.nil? || wait_time <= 0
22
+
23
+ sleep(wait_time)
24
+ end
25
+ end
26
+
27
+ alias acquire throttle
28
+
29
+ private
30
+
31
+ def definition_for(bucket_name, required:)
32
+ if bucket_name.nil?
33
+ return nil unless required
34
+
35
+ raise Errors::ConfigurationError, "missing rate limit definition for authenticated request"
36
+ end
37
+
38
+ definition = @definitions[bucket_name.to_sym]
39
+ return definition unless definition.nil?
40
+ return unless required
41
+
42
+ raise Errors::ConfigurationError, "missing rate limit definition for #{bucket_name}"
43
+ end
44
+
45
+ def reserve_slot(bucket_name, definition)
46
+ bucket_key = bucket_name.to_sym
47
+ now = monotonic_time
48
+ cutoff = now - definition.fetch(:period).to_f
49
+ bucket = @timestamps[bucket_key]
50
+ bucket.reject! { |timestamp| timestamp <= cutoff }
51
+
52
+ if bucket.length < definition.fetch(:limit)
53
+ bucket << now
54
+ return nil
55
+ end
56
+
57
+ definition.fetch(:period).to_f - (now - bucket.first)
58
+ end
59
+
60
+ def monotonic_time
61
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ end
63
+ end
64
+ end
65
+ end