hyperliquid-rb 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.
@@ -0,0 +1,486 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperliquid
4
+ class Info
5
+ attr_reader :transport
6
+
7
+ def initialize(base_url: MAINNET_URL, skip_ws: false)
8
+ @transport = Transport.new(base_url: base_url)
9
+ @coin_to_asset = nil
10
+ @spot_coin_to_asset = nil
11
+ @name_to_coin = nil
12
+ @asset_to_sz_decimals = nil
13
+ @ws_manager = nil
14
+ return if skip_ws
15
+
16
+ @ws_manager = WebsocketManager.new(base_url)
17
+ @ws_manager.start
18
+ end
19
+
20
+ # ---- Market Data ----
21
+
22
+ # Get perpetual metadata (universe, asset info).
23
+ # @param dex [String, nil] optional dex identifier for builder perp dexs
24
+ def meta(dex: nil)
25
+ if dex && !dex.empty?
26
+ post("meta", dex: dex)
27
+ else
28
+ post("meta")
29
+ end
30
+ end
31
+
32
+ # Get spot metadata.
33
+ def spot_meta
34
+ post("spotMeta")
35
+ end
36
+
37
+ # Get perpetual metadata with live asset contexts (funding, open interest, etc).
38
+ def meta_and_asset_ctxs
39
+ post("metaAndAssetCtxs")
40
+ end
41
+
42
+ # Get spot metadata with live asset contexts.
43
+ def spot_meta_and_asset_ctxs
44
+ post("spotMetaAndAssetCtxs")
45
+ end
46
+
47
+ # Get all mid prices. Returns Hash of coin => mid price string.
48
+ # @param dex [String, nil] optional dex identifier for builder perp dexs
49
+ def all_mids(dex: nil)
50
+ if dex && !dex.empty?
51
+ post("allMids", dex: dex)
52
+ else
53
+ post("allMids")
54
+ end
55
+ end
56
+
57
+ # Get L2 order book snapshot.
58
+ # @param coin [String] e.g. "ETH"
59
+ # @param n_levels [Integer] number of price levels (default: 10)
60
+ def l2_snapshot(coin, n_levels: 10)
61
+ post("l2Book", coin: coin, nSigFigs: n_levels)
62
+ end
63
+
64
+ # Get candle data.
65
+ # @param coin [String] e.g. "ETH"
66
+ # @param interval [String] e.g. "1h", "1d", "15m"
67
+ # @param start_time [Integer] start timestamp in ms
68
+ # @param end_time [Integer] end timestamp in ms
69
+ def candles_snapshot(coin, interval:, start_time:, end_time:)
70
+ post("candleSnapshot", req: { coin: coin, interval: interval, startTime: start_time, endTime: end_time })
71
+ end
72
+
73
+ # Get list of perp dexes.
74
+ def perp_dexs
75
+ post("perpDexs")
76
+ end
77
+
78
+ # ---- User State ----
79
+
80
+ # Get user's perpetual account state (positions, margin, etc).
81
+ # @param address [String] user address
82
+ # @param dex [String, nil] optional dex identifier
83
+ def user_state(address, dex: nil)
84
+ if dex && !dex.empty?
85
+ post("clearinghouseState", user: address, dex: dex)
86
+ else
87
+ post("clearinghouseState", user: address)
88
+ end
89
+ end
90
+
91
+ # Get user's spot account state.
92
+ # @param address [String] user address
93
+ def spot_user_state(address)
94
+ post("spotClearinghouseState", user: address)
95
+ end
96
+
97
+ # Get user's open orders.
98
+ # @param address [String] user address
99
+ # @param dex [String, nil] optional dex identifier
100
+ def open_orders(address, dex: nil)
101
+ if dex && !dex.empty?
102
+ post("openOrders", user: address, dex: dex)
103
+ else
104
+ post("openOrders", user: address)
105
+ end
106
+ end
107
+
108
+ # Get user's open orders with additional frontend info.
109
+ # @param address [String] user address
110
+ # @param dex [String, nil] optional dex identifier
111
+ def frontend_open_orders(address, dex: nil)
112
+ if dex && !dex.empty?
113
+ post("frontendOpenOrders", user: address, dex: dex)
114
+ else
115
+ post("frontendOpenOrders", user: address)
116
+ end
117
+ end
118
+
119
+ # Get user's trade fills.
120
+ # @param address [String] user address
121
+ def user_fills(address)
122
+ post("userFills", user: address)
123
+ end
124
+
125
+ # Get user's trade fills in a time range.
126
+ # @param address [String] user address
127
+ # @param start_time [Integer] start timestamp in ms
128
+ # @param end_time [Integer] end timestamp in ms (optional)
129
+ # @param aggregate_by_time [Boolean] aggregate fills by time (optional)
130
+ def user_fills_by_time(address, start_time:, end_time: nil, aggregate_by_time: nil)
131
+ req = { user: address, startTime: start_time }
132
+ req[:endTime] = end_time if end_time
133
+ req[:aggregateByTime] = aggregate_by_time unless aggregate_by_time.nil?
134
+ post("userFillsByTime", **req)
135
+ end
136
+
137
+ # Get user's fee rates.
138
+ # @param address [String] user address
139
+ def user_fees(address)
140
+ post("userFees", user: address)
141
+ end
142
+
143
+ # Get user's funding history.
144
+ # @param address [String] user address
145
+ # @param start_time [Integer] start timestamp in ms
146
+ # @param end_time [Integer] end timestamp in ms (optional)
147
+ def user_funding(address, start_time:, end_time: nil)
148
+ req = { user: address, startTime: start_time }
149
+ req[:endTime] = end_time if end_time
150
+ post("userFunding", **req)
151
+ end
152
+
153
+ # Get user's non-funding ledger updates.
154
+ # @param address [String] user address
155
+ # @param start_time [Integer] start timestamp in ms
156
+ # @param end_time [Integer] end timestamp in ms (optional)
157
+ def user_non_funding_ledger_updates(address, start_time:, end_time: nil)
158
+ req = { user: address, startTime: start_time }
159
+ req[:endTime] = end_time if end_time
160
+ post("userNonFundingLedgerUpdates", **req)
161
+ end
162
+
163
+ # Get user's rate limits.
164
+ # @param address [String] user address
165
+ def user_rate_limit(address)
166
+ post("userRateLimit", user: address)
167
+ end
168
+
169
+ # ---- Orders ----
170
+
171
+ # Query status of an order by oid or cloid.
172
+ # @param address [String] user address
173
+ # @param oid [Integer, String] order id (Integer) or cloid (String)
174
+ def order_status(address, oid)
175
+ post("orderStatus", user: address, oid: oid)
176
+ end
177
+
178
+ # Get user's historical orders (up to 2000 recent).
179
+ # @param address [String] user address
180
+ def historical_orders(address)
181
+ post("historicalOrders", user: address)
182
+ end
183
+
184
+ # ---- Funding ----
185
+
186
+ # Get funding history for a coin.
187
+ # @param coin [String] e.g. "ETH"
188
+ # @param start_time [Integer] start timestamp in ms
189
+ # @param end_time [Integer] end timestamp in ms (optional)
190
+ def funding_history(coin, start_time:, end_time: nil)
191
+ req = { coin: coin, startTime: start_time }
192
+ req[:endTime] = end_time if end_time
193
+ post("fundingHistory", **req)
194
+ end
195
+
196
+ # Get predicted funding rates.
197
+ def predicted_fundings
198
+ post("predictedFundings")
199
+ end
200
+
201
+ # ---- Sub-accounts ----
202
+
203
+ # Get sub-accounts for an address.
204
+ # @param address [String] user address
205
+ def sub_accounts(address)
206
+ post("subAccounts", user: address)
207
+ end
208
+
209
+ # ---- Staking ----
210
+
211
+ # Get user's staking summary (delegated, undelegated, pending).
212
+ # @param address [String] user address
213
+ def user_staking_summary(address)
214
+ post("delegatorSummary", user: address)
215
+ end
216
+
217
+ # Get user's staking delegations per validator.
218
+ # @param address [String] user address
219
+ def user_staking_delegations(address)
220
+ post("delegations", user: address)
221
+ end
222
+
223
+ # Get user's historic staking rewards.
224
+ # @param address [String] user address
225
+ def user_staking_rewards(address)
226
+ post("delegatorRewards", user: address)
227
+ end
228
+
229
+ # Get comprehensive delegator history.
230
+ # @param address [String] user address
231
+ def delegator_history(address)
232
+ post("delegatorHistory", user: address)
233
+ end
234
+
235
+ # ---- Referrals ----
236
+
237
+ # Get referral state for an address.
238
+ # @param address [String] user address
239
+ def referral(address)
240
+ post("referral", user: address)
241
+ end
242
+
243
+ # ---- Multi-sig ----
244
+
245
+ # Get multi-sig signers for a user.
246
+ # @param address [String] multi-sig user address
247
+ def query_user_to_multi_sig_signers(address)
248
+ post("userToMultiSigSigners", user: address)
249
+ end
250
+
251
+ # ---- Deploy Auctions ----
252
+
253
+ # Get perp deploy auction status.
254
+ def query_perp_deploy_auction_status
255
+ post("perpDeployAuctionStatus")
256
+ end
257
+
258
+ # Get spot deploy state for a user.
259
+ # @param address [String] user address
260
+ def query_spot_deploy_auction_status(address)
261
+ post("spotDeployState", user: address)
262
+ end
263
+
264
+ # ---- Dex Abstraction ----
265
+
266
+ # Get dex abstraction state for a user.
267
+ # @param address [String] user address
268
+ def query_user_dex_abstraction_state(address)
269
+ post("userDexAbstraction", user: address)
270
+ end
271
+
272
+ # Get user abstraction state.
273
+ # @param address [String] user address
274
+ def query_user_abstraction_state(address)
275
+ post("userAbstraction", user: address)
276
+ end
277
+
278
+ # ---- Portfolio / TWAP / Vault ----
279
+
280
+ # Get portfolio performance data.
281
+ # @param address [String] user address
282
+ def portfolio(address)
283
+ post("portfolio", user: address)
284
+ end
285
+
286
+ # Get user's TWAP slice fills.
287
+ # @param address [String] user address
288
+ def user_twap_slice_fills(address)
289
+ post("userTwapSliceFills", user: address)
290
+ end
291
+
292
+ # Get user's vault equity positions.
293
+ # @param address [String] user address
294
+ def user_vault_equities(address)
295
+ post("userVaultEquities", user: address)
296
+ end
297
+
298
+ # Get user's role and account type.
299
+ # @param address [String] user address
300
+ def user_role(address)
301
+ post("userRole", user: address)
302
+ end
303
+
304
+ # Get extra agents for a user.
305
+ # @param address [String] user address
306
+ def extra_agents(address)
307
+ post("extraAgents", user: address)
308
+ end
309
+
310
+ # ---- WebSocket ----
311
+
312
+ # Subscribe to a WebSocket channel.
313
+ # @param subscription [Hash] e.g. { "type" => "allMids" } or { "type" => "l2Book", "coin" => "ETH" }
314
+ # @param callback [Proc] called with each message hash
315
+ # @return [Integer] subscription_id for unsubscribing
316
+ def subscribe(subscription, callback)
317
+ remap_coin_subscription(subscription)
318
+ raise "Cannot call subscribe since skip_ws was used" if @ws_manager.nil?
319
+
320
+ @ws_manager.subscribe(subscription, callback)
321
+ end
322
+
323
+ # Unsubscribe from a WebSocket channel.
324
+ # @param subscription [Hash] same hash used to subscribe
325
+ # @param subscription_id [Integer] returned by subscribe
326
+ # @return [Boolean] true if subscription was found and removed
327
+ def unsubscribe(subscription, subscription_id)
328
+ remap_coin_subscription(subscription)
329
+ raise "Cannot call unsubscribe since skip_ws was used" if @ws_manager.nil?
330
+
331
+ @ws_manager.unsubscribe(subscription, subscription_id)
332
+ end
333
+
334
+ # Disconnect the WebSocket connection and stop the ping thread.
335
+ def disconnect_websocket
336
+ raise "Cannot call disconnect_websocket since skip_ws was used" if @ws_manager.nil?
337
+
338
+ @ws_manager.stop
339
+ end
340
+
341
+ # ---- Coin-to-asset mapping (lazy loaded) ----
342
+
343
+ # Map a coin name to its asset index (handles both perp and spot).
344
+ # This is the primary method used by Exchange for resolving coin names.
345
+ # @param name [String] e.g. "ETH", "BTC", "PURR/USDC"
346
+ # @return [Integer] asset index
347
+ def name_to_asset(name)
348
+ load_all_mappings! unless @name_to_coin
349
+ coin = @name_to_coin[name]
350
+ raise Error, "Unknown coin name: #{name}" unless coin
351
+
352
+ asset = @coin_to_asset[coin]
353
+ raise Error, "Unknown coin: #{coin}" unless asset
354
+
355
+ asset
356
+ end
357
+
358
+ # Map a coin name to its perpetual asset index.
359
+ # @param coin [String] e.g. "ETH"
360
+ # @return [Integer] asset index
361
+ def coin_to_asset(coin)
362
+ load_coin_mapping! unless @coin_to_asset
363
+ @coin_to_asset[coin] || raise(Error, "Unknown perpetual coin: #{coin}")
364
+ end
365
+
366
+ # Map a coin name to its spot asset index (10000 + index).
367
+ # @param coin [String] e.g. "PURR/USDC"
368
+ # @return [Integer] asset index
369
+ def spot_coin_to_asset(coin)
370
+ load_spot_coin_mapping! unless @spot_coin_to_asset
371
+ @spot_coin_to_asset[coin] || raise(Error, "Unknown spot coin: #{coin}")
372
+ end
373
+
374
+ # Get sz decimals for an asset index.
375
+ # @param asset [Integer] asset index
376
+ # @return [Integer] sz decimals
377
+ def asset_to_sz_decimals(asset)
378
+ load_all_mappings! unless @asset_to_sz_decimals
379
+ @asset_to_sz_decimals[asset]
380
+ end
381
+
382
+ # Get the full coin name (with dex prefix if applicable) for a display name.
383
+ # @param name [String] e.g. "ETH"
384
+ # @return [String] coin name
385
+ def name_to_coin(name)
386
+ load_all_mappings! unless @name_to_coin
387
+ @name_to_coin[name] || raise(Error, "Unknown name: #{name}")
388
+ end
389
+
390
+ # Reset cached coin mappings (e.g. after new listings).
391
+ def refresh_coin_mappings!
392
+ @coin_to_asset = nil
393
+ @spot_coin_to_asset = nil
394
+ @name_to_coin = nil
395
+ @asset_to_sz_decimals = nil
396
+ end
397
+
398
+ private
399
+
400
+ def remap_coin_subscription(subscription)
401
+ type = subscription["type"]
402
+ return unless %w[l2Book trades candle bbo activeAssetCtx].include?(type)
403
+
404
+ load_all_mappings! unless @name_to_coin
405
+ coin = @name_to_coin[subscription["coin"]]
406
+ subscription["coin"] = coin if coin
407
+ end
408
+
409
+ def post(type, **params)
410
+ body = { type: type }.merge(params)
411
+ @transport.post_info(body)
412
+ end
413
+
414
+ def load_coin_mapping!
415
+ m = meta
416
+ @coin_to_asset = {}
417
+ m["universe"].each_with_index do |asset_info, index|
418
+ @coin_to_asset[asset_info["name"]] = index
419
+ end
420
+ end
421
+
422
+ def load_spot_coin_mapping!
423
+ m = spot_meta
424
+ @spot_coin_to_asset = {}
425
+ m["universe"].each_with_index do |token_info, index|
426
+ name = token_info["name"]
427
+ @spot_coin_to_asset[name] = 10_000 + index
428
+ end
429
+ end
430
+
431
+ def load_all_mappings!
432
+ @coin_to_asset = {}
433
+ @name_to_coin = {}
434
+ @asset_to_sz_decimals = {}
435
+
436
+ # Load perp metadata
437
+ m = meta
438
+ set_perp_meta(m, 0)
439
+
440
+ # Load spot metadata
441
+ begin
442
+ sm = spot_meta
443
+ sm["universe"].each_with_index do |token_info, index|
444
+ name = token_info["name"]
445
+ asset = 10_000 + index
446
+ @coin_to_asset[name] = asset
447
+ @name_to_coin[name] = name
448
+ end
449
+ sm["tokens"]&.each do |token|
450
+ @asset_to_sz_decimals[token["index"]] = token["szDecimals"] if token["index"]
451
+ end
452
+ rescue Error
453
+ # spot_meta may not be available
454
+ end
455
+
456
+ # Load builder perp dexes
457
+ begin
458
+ dexes = perp_dexs
459
+ dexes&.each_with_index do |dex, i|
460
+ offset = 110_000 + (i * 10_000)
461
+ begin
462
+ dex_meta = meta(dex: dex)
463
+ set_perp_meta(dex_meta, offset, dex: dex)
464
+ rescue Error
465
+ # dex meta may not be available
466
+ end
467
+ end
468
+ rescue Error
469
+ # perp_dexs may not be available
470
+ end
471
+
472
+ @spot_coin_to_asset = @coin_to_asset.select { |_k, v| v >= 10_000 && v < 110_000 }
473
+ end
474
+
475
+ def set_perp_meta(m, offset, dex: nil)
476
+ m["universe"].each_with_index do |asset_info, index|
477
+ name = asset_info["name"]
478
+ coin = dex && !dex.empty? ? "#{dex}:#{name}" : name
479
+ asset = offset + index
480
+ @coin_to_asset[coin] = asset
481
+ @name_to_coin[name] = coin
482
+ @asset_to_sz_decimals[asset] = asset_info["szDecimals"]
483
+ end
484
+ end
485
+ end
486
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "eth"
4
+ require "msgpack"
5
+
6
+ module Hyperliquid
7
+ class Signer
8
+ attr_reader :key, :is_mainnet
9
+
10
+ # @param private_key [String] hex private key (with or without 0x prefix)
11
+ # @param base_url [String] API URL to determine mainnet vs testnet
12
+ def initialize(private_key:, base_url: MAINNET_URL)
13
+ hex = private_key.delete_prefix("0x")
14
+ @key = Eth::Key.new(priv: hex)
15
+ @is_mainnet = base_url == MAINNET_URL
16
+ end
17
+
18
+ def address
19
+ @key.address.to_s
20
+ end
21
+
22
+ # Sign an L1 action (orders, cancels, leverage, etc.)
23
+ # Returns { r: "0x...", s: "0x...", v: Integer }
24
+ def sign_l1_action(action, nonce:, vault_address: nil, expires_after: nil)
25
+ hash = action_hash(action, nonce: nonce, vault_address: vault_address, expires_after: expires_after)
26
+ phantom = { source: (@is_mainnet ? "a" : "b"), connectionId: hash }
27
+ typed_data = build_agent_typed_data(phantom)
28
+ sign_typed_data(typed_data)
29
+ end
30
+
31
+ # Sign a user-signed action (transfers, withdrawals, approvals, etc.)
32
+ # @param action [Hash] the action payload (string keys, will be modified)
33
+ # @param primary_type [String] e.g. "UsdSend", "Withdraw"
34
+ # @param payload_types [Array] EIP-712 field definitions for the primary type
35
+ def sign_user_signed_action(action, primary_type:, payload_types:)
36
+ action["signatureChainId"] = "0x66eee"
37
+ action["hyperliquidChain"] = @is_mainnet ? "Mainnet" : "Testnet"
38
+
39
+ typed_data = build_user_signed_typed_data(action, primary_type: primary_type, payload_types: payload_types)
40
+ sign_typed_data(typed_data)
41
+ end
42
+
43
+ # Sign a multi-sig action envelope.
44
+ # The inner action hash is used with the SendMultiSig type.
45
+ def sign_multi_sig_action(action, nonce:, vault_address: nil, expires_after: nil)
46
+ inner_action = action["payload"]["action"]
47
+ inner_hash = action_hash(inner_action, nonce: nonce, vault_address: vault_address, expires_after: expires_after)
48
+
49
+ phantom = { source: (@is_mainnet ? "a" : "b"), connectionId: inner_hash }
50
+ typed_data = build_agent_typed_data(phantom)
51
+ sign_typed_data(typed_data)
52
+ end
53
+
54
+ # Compute the action hash for L1 actions.
55
+ # msgpack(action) + nonce(8B) + vault_flag(1B) + [vault_addr(20B)] + [0x00 + expires(8B)]
56
+ def action_hash(action, nonce:, vault_address: nil, expires_after: nil)
57
+ data = MessagePack.pack(action)
58
+ data += [nonce].pack("Q>")
59
+
60
+ if vault_address.nil?
61
+ data += "\x00".b
62
+ else
63
+ data += "\x01".b
64
+ data += Utils.address_to_bytes(vault_address)
65
+ end
66
+
67
+ if expires_after
68
+ data += "\x00".b
69
+ data += [expires_after].pack("Q>")
70
+ end
71
+
72
+ Eth::Util.keccak256(data)
73
+ end
74
+
75
+ private
76
+
77
+ def build_agent_typed_data(phantom_agent)
78
+ {
79
+ types: {
80
+ EIP712Domain: [
81
+ { name: "name", type: "string" },
82
+ { name: "version", type: "string" },
83
+ { name: "chainId", type: "uint256" },
84
+ { name: "verifyingContract", type: "address" }
85
+ ],
86
+ Agent: [
87
+ { name: "source", type: "string" },
88
+ { name: "connectionId", type: "bytes32" }
89
+ ]
90
+ },
91
+ primaryType: "Agent",
92
+ domain: {
93
+ name: "Exchange",
94
+ version: "1",
95
+ chainId: 1337,
96
+ verifyingContract: "0x0000000000000000000000000000000000000000"
97
+ },
98
+ message: phantom_agent
99
+ }
100
+ end
101
+
102
+ def build_user_signed_typed_data(action, primary_type:, payload_types:)
103
+ chain_id = action["signatureChainId"].to_i(16)
104
+
105
+ # Build message from action, using only the fields defined in payload_types
106
+ message = {}
107
+ payload_types.each do |field|
108
+ name = field[:name]
109
+ message[name.to_sym] = action[name]
110
+ end
111
+
112
+ type_name = "HyperliquidTransaction:#{primary_type}"
113
+
114
+ {
115
+ types: {
116
+ EIP712Domain: [
117
+ { name: "name", type: "string" },
118
+ { name: "version", type: "string" },
119
+ { name: "chainId", type: "uint256" },
120
+ { name: "verifyingContract", type: "address" }
121
+ ],
122
+ type_name.to_sym => payload_types
123
+ },
124
+ primaryType: type_name,
125
+ domain: {
126
+ name: "HyperliquidSignTransaction",
127
+ version: "1",
128
+ chainId: chain_id,
129
+ verifyingContract: "0x0000000000000000000000000000000000000000"
130
+ },
131
+ message: message
132
+ }
133
+ end
134
+
135
+ def sign_typed_data(typed_data)
136
+ hash = Eth::Eip712.hash(typed_data)
137
+ sig_hex = @key.sign(hash)
138
+ sig_bytes = [sig_hex].pack("H*")
139
+
140
+ r_int = sig_bytes[0, 32].unpack1("H*").to_i(16)
141
+ s_int = sig_bytes[32, 32].unpack1("H*").to_i(16)
142
+ v = sig_bytes[64].unpack1("C")
143
+
144
+ { r: "0x#{r_int.to_s(16)}", s: "0x#{s_int.to_s(16)}", v: v }
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Hyperliquid
7
+ class Transport
8
+ attr_reader :base_url
9
+
10
+ def initialize(base_url: MAINNET_URL)
11
+ @base_url = base_url
12
+ @connection = Faraday.new(url: base_url) do |f|
13
+ f.request :json
14
+ f.response :json
15
+ f.adapter Faraday.default_adapter
16
+ f.options.timeout = 30
17
+ f.options.open_timeout = 10
18
+ end
19
+ end
20
+
21
+ def post_info(body)
22
+ post("/info", body)
23
+ end
24
+
25
+ def post_exchange(body)
26
+ post("/exchange", body)
27
+ end
28
+
29
+ private
30
+
31
+ def post(path, body)
32
+ response = @connection.post(path) do |req|
33
+ req.body = body
34
+ end
35
+ handle_response(response)
36
+ rescue Faraday::Error => e
37
+ raise Error, "Network error: #{e.message}"
38
+ end
39
+
40
+ def handle_response(response)
41
+ case response.status
42
+ when 200
43
+ response.body
44
+ when 400..499
45
+ raise ClientError.new(
46
+ "HTTP #{response.status}: #{error_message(response.body)}",
47
+ status: response.status,
48
+ body: response.body
49
+ )
50
+ when 500..599
51
+ raise ServerError.new(
52
+ "HTTP #{response.status}: #{error_message(response.body)}",
53
+ status: response.status,
54
+ body: response.body
55
+ )
56
+ else
57
+ raise Error, "Unexpected HTTP #{response.status}"
58
+ end
59
+ end
60
+
61
+ def error_message(body)
62
+ case body
63
+ when String then body
64
+ when Hash then body["error"] || body.to_s
65
+ else body.to_s
66
+ end
67
+ end
68
+ end
69
+ end