melaya 0.1.2 → 0.2.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 +4 -4
- data/LICENSE +158 -0
- data/README.md +244 -163
- data/lib/melaya/account.rb +30 -30
- data/lib/melaya/accounts.rb +42 -0
- data/lib/melaya/assistant.rb +41 -0
- data/lib/melaya/auth.rb +137 -0
- data/lib/melaya/backtest.rb +139 -101
- data/lib/melaya/billing.rb +75 -0
- data/lib/melaya/bugs.rb +67 -0
- data/lib/melaya/connectors.rb +71 -0
- data/lib/melaya/credentials.rb +255 -0
- data/lib/melaya/errors.rb +49 -15
- data/lib/melaya/evals.rb +78 -0
- data/lib/melaya/events.rb +425 -0
- data/lib/melaya/hitl.rb +114 -0
- data/lib/melaya/http_client.rb +226 -97
- data/lib/melaya/market.rb +198 -156
- data/lib/melaya/namespaces.rb +115 -0
- data/lib/melaya/phone.rb +75 -0
- data/lib/melaya/pipelines.rb +342 -0
- data/lib/melaya/projects.rb +58 -0
- data/lib/melaya/runner.rb +44 -0
- data/lib/melaya/sim.rb +110 -110
- data/lib/melaya/strategies.rb +170 -155
- data/lib/melaya/stream.rb +338 -336
- data/lib/melaya/team.rb +102 -0
- data/lib/melaya/templates.rb +148 -0
- data/lib/melaya/trade.rb +168 -168
- data/lib/melaya/version.rb +1 -1
- data/lib/melaya.rb +310 -79
- data/melaya.gemspec +28 -23
- metadata +32 -8
data/lib/melaya/http_client.rb
CHANGED
|
@@ -1,97 +1,226 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "uri"
|
|
5
|
-
require "json"
|
|
6
|
-
require "openssl"
|
|
7
|
-
|
|
8
|
-
require_relative "errors"
|
|
9
|
-
|
|
10
|
-
module Melaya
|
|
11
|
-
# Internal HTTP client.
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
parse(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module Melaya
|
|
11
|
+
# Internal HTTP client. Supports Bearer JWT *and* mk_* platform API key.
|
|
12
|
+
# The credential is sent ONLY via the Authorization header — never in the URL
|
|
13
|
+
# query string, so it cannot leak into access logs or proxies. TLS is
|
|
14
|
+
# enforced by default; never log secrets.
|
|
15
|
+
#
|
|
16
|
+
# Retry policy: bounded exponential back-off with jitter on network errors,
|
|
17
|
+
# 429, and 5xx — but ONLY for idempotent GET requests (max 2 retries).
|
|
18
|
+
# POST/PUT/PATCH/DELETE are never retried. Retry-After header is honoured
|
|
19
|
+
# on 429. Per-request timeout default: 30 seconds (configurable).
|
|
20
|
+
class HttpClient
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.melaya.org"
|
|
22
|
+
DEFAULT_TIMEOUT_MS = 30_000 # milliseconds
|
|
23
|
+
|
|
24
|
+
# Maximum additional retries after the first attempt (2 retries = 3 total
|
|
25
|
+
# attempts) for idempotent GET requests only.
|
|
26
|
+
MAX_GET_RETRIES = 2
|
|
27
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
28
|
+
|
|
29
|
+
# @param api_key [String] mk_* platform key or Bearer JWT
|
|
30
|
+
# @param base_url [String]
|
|
31
|
+
# @param verify_ssl [Boolean]
|
|
32
|
+
# @param timeout_ms [Integer] per-request timeout in milliseconds (default 30 000)
|
|
33
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true,
|
|
34
|
+
timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
35
|
+
raise ArgumentError, "Melaya: TLS certificate verification cannot be disabled." unless verify_ssl
|
|
36
|
+
# Never store in a way that could leak to logs accidentally — keep as
|
|
37
|
+
# an opaque token string only accessible through the private accessor.
|
|
38
|
+
@_tok = api_key.freeze
|
|
39
|
+
@base_uri = URI.parse(base_url.chomp("/"))
|
|
40
|
+
@verify_ssl = true
|
|
41
|
+
@timeout_s = (timeout_ms / 1000.0).ceil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ── Public verb helpers ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def get(path, params = {})
|
|
47
|
+
request(:get, path, params: params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def post(path, body = nil)
|
|
51
|
+
request(:post, path, body: body)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def put(path, body = nil)
|
|
55
|
+
request(:put, path, body: body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def patch(path, body = nil)
|
|
59
|
+
request(:patch, path, body: body)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def delete(path, params = {})
|
|
63
|
+
request(:delete, path, params: params)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_uri(path, params = {})
|
|
69
|
+
uri = URI.parse("#{@base_uri}#{path}")
|
|
70
|
+
# SECURITY: the credential is never placed in the query string; it is
|
|
71
|
+
# sent only via the Authorization header (see make_request).
|
|
72
|
+
query = {}
|
|
73
|
+
params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
|
|
74
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
75
|
+
uri
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def make_request(method, uri, body)
|
|
79
|
+
req = case method
|
|
80
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
81
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
82
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
83
|
+
when :patch then Net::HTTP::Patch.new(uri)
|
|
84
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
85
|
+
else raise ArgumentError, "Unknown HTTP method: #{method}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Authorization: never expose token in error output below
|
|
89
|
+
req["Authorization"] = "Bearer #{@_tok}"
|
|
90
|
+
req["Accept"] = "application/json"
|
|
91
|
+
req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
|
|
92
|
+
|
|
93
|
+
if body
|
|
94
|
+
req["Content-Type"] = "application/json"
|
|
95
|
+
req.body = JSON.generate(body)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
req
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request(method, path, params: {}, body: nil)
|
|
102
|
+
uri = build_uri(path, params)
|
|
103
|
+
|
|
104
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
105
|
+
http.use_ssl = uri.scheme == "https"
|
|
106
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
107
|
+
http.open_timeout = @timeout_s
|
|
108
|
+
http.read_timeout = @timeout_s
|
|
109
|
+
|
|
110
|
+
# Only GET requests are retried (idempotent); all mutating verbs fail fast.
|
|
111
|
+
retryable = (method == :get)
|
|
112
|
+
attempt = 0
|
|
113
|
+
|
|
114
|
+
retry_after_hdr = nil
|
|
115
|
+
begin
|
|
116
|
+
attempt += 1
|
|
117
|
+
retry_after_hdr = nil # reset on each attempt
|
|
118
|
+
req = make_request(method, uri, body)
|
|
119
|
+
resp = http.request(req)
|
|
120
|
+
# Snapshot Retry-After before parse() consumes the response object,
|
|
121
|
+
# so we can honour the header even after the MelayaError is raised.
|
|
122
|
+
retry_after_hdr = resp["retry-after"] || resp["Retry-After"]
|
|
123
|
+
parse(resp)
|
|
124
|
+
rescue MelayaError => e
|
|
125
|
+
if retryable && RETRY_STATUSES.include?(e.status) && attempt <= MAX_GET_RETRIES
|
|
126
|
+
# Build a minimal resp-like object carrying only the header we need,
|
|
127
|
+
# so _backoff_delay can honour Retry-After without holding the socket.
|
|
128
|
+
hdr_carrier = { "retry-after" => retry_after_hdr }
|
|
129
|
+
delay = _backoff_delay(attempt, e, hdr_carrier)
|
|
130
|
+
sleep(delay)
|
|
131
|
+
retry
|
|
132
|
+
end
|
|
133
|
+
raise
|
|
134
|
+
rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
|
|
135
|
+
raise unless retryable && attempt <= MAX_GET_RETRIES
|
|
136
|
+
sleep(_backoff_delay(attempt, nil))
|
|
137
|
+
retry
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Exponential backoff with ±25 % jitter; honours Retry-After on 429.
|
|
142
|
+
# Base: 2^(attempt-1) seconds, capped at 16 s before jitter.
|
|
143
|
+
#
|
|
144
|
+
# Retry-After resolution order (first match wins):
|
|
145
|
+
# 1. HTTP `Retry-After` response header — seconds integer or HTTP-date
|
|
146
|
+
# 2. JSON body `retryAfter` / `retry_after` field (legacy fallback)
|
|
147
|
+
# 3. Exponential back-off
|
|
148
|
+
def _backoff_delay(attempt, err, resp = nil)
|
|
149
|
+
if err.is_a?(MelayaError) && err.status == 429
|
|
150
|
+
# 1. HTTP Retry-After header (preferred, RFC 7231)
|
|
151
|
+
if resp.respond_to?(:[]) && (ra_hdr = resp["retry-after"] || resp["Retry-After"])
|
|
152
|
+
secs = _parse_retry_after_header(ra_hdr)
|
|
153
|
+
return [secs, 0.5].max if secs
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 2. JSON body fallback ("retryAfter" or "retry_after")
|
|
157
|
+
if err.respond_to?(:body) && err.body.is_a?(Hash)
|
|
158
|
+
ra = err.body["retryAfter"] || err.body["retry_after"]
|
|
159
|
+
return [ra.to_f, 0.5].max if ra
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
base = [2**(attempt - 1), 16].min.to_f
|
|
163
|
+
jitter = base * 0.25 * (rand - 0.5) * 2 # ±25 %
|
|
164
|
+
[base + jitter, 0.1].max
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Parse an RFC 7231 Retry-After value: either a delay-seconds integer
|
|
168
|
+
# or an HTTP-date string. Returns seconds as Float, or nil if unparseable.
|
|
169
|
+
def _parse_retry_after_header(value)
|
|
170
|
+
str = value.to_s.strip
|
|
171
|
+
# Delay-seconds: plain non-negative integer
|
|
172
|
+
if str =~ /\A\d+\z/
|
|
173
|
+
return str.to_f
|
|
174
|
+
end
|
|
175
|
+
# HTTP-date (e.g. "Wed, 21 Oct 2099 07:28:00 GMT")
|
|
176
|
+
begin
|
|
177
|
+
require "time"
|
|
178
|
+
target = Time.httpdate(str)
|
|
179
|
+
delay = target - Time.now
|
|
180
|
+
return [delay, 0.0].max
|
|
181
|
+
rescue ArgumentError, TypeError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parse(resp)
|
|
187
|
+
text = resp.body.to_s.strip
|
|
188
|
+
data = begin
|
|
189
|
+
text.empty? ? nil : JSON.parse(text)
|
|
190
|
+
rescue JSON::ParserError
|
|
191
|
+
text
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
status = resp.code.to_i
|
|
195
|
+
if status >= 400
|
|
196
|
+
# Two error envelope shapes:
|
|
197
|
+
# 1. { error: 'tier_insufficient', tier: '...' } -> 403
|
|
198
|
+
# 2. { error: '...', message: '...', code: '...' }
|
|
199
|
+
# Extract error code safely — never echo raw body in message
|
|
200
|
+
err_code = data.is_a?(Hash) ? data["error"] : nil
|
|
201
|
+
|
|
202
|
+
if status == 403 && err_code == "tier_insufficient"
|
|
203
|
+
raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
|
|
204
|
+
end
|
|
205
|
+
if status == 429
|
|
206
|
+
# Raised here; the GET retry loop above may swallow-and-retry it —
|
|
207
|
+
# callers only see it once retries are exhausted.
|
|
208
|
+
ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
|
|
209
|
+
raise RateLimitError.new(retry_after: ra, body: data)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
|
|
213
|
+
raise MelayaError.new(msg, status: status, code: err_code, body: data)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The API may wrap payload in { "ok": false, ... } for request-level failures.
|
|
217
|
+
if data.is_a?(Hash) && data["ok"] == false
|
|
218
|
+
err_code = data["error"]
|
|
219
|
+
msg = "Melaya API request failed" + (err_code ? ": #{err_code}" : "")
|
|
220
|
+
raise MelayaError.new(msg, status: resp.code.to_i, code: err_code, body: data)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
data
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|