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,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
|