honeymaker 0.12.2 → 0.12.4

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +19 -4
  5. data/Cargo.lock +1078 -0
  6. data/Cargo.toml +14 -0
  7. data/README.md +39 -0
  8. data/Rakefile +28 -16
  9. data/crates/honeymaker/Cargo.toml +18 -0
  10. data/crates/honeymaker/src/encode.rs +106 -0
  11. data/crates/honeymaker/src/kraken/lookup.rs +140 -0
  12. data/crates/honeymaker/src/kraken/mod.rs +5 -0
  13. data/crates/honeymaker/src/kraken/normalize.rs +536 -0
  14. data/crates/honeymaker/src/kraken/requests.rs +286 -0
  15. data/crates/honeymaker/src/kraken/sign.rs +166 -0
  16. data/crates/honeymaker/src/kraken/trades.rs +201 -0
  17. data/crates/honeymaker/src/lib.rs +10 -0
  18. data/crates/honeymaker/src/num.rs +115 -0
  19. data/crates/honeymaker/src/semantics.rs +100 -0
  20. data/crates/honeymaker/src/types.rs +85 -0
  21. data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
  22. data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
  23. data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
  24. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  25. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  26. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  27. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  28. data/crates/honeymaker-client/Cargo.toml +30 -0
  29. data/crates/honeymaker-client/examples/probe.rs +147 -0
  30. data/crates/honeymaker-client/src/kraken.rs +514 -0
  31. data/crates/honeymaker-client/src/lib.rs +4 -0
  32. data/crates/honeymaker-client/src/transport/connect.rs +583 -0
  33. data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
  34. data/crates/honeymaker-client/src/transport/response.rs +169 -0
  35. data/crates/honeymaker-client/src/transport.rs +467 -0
  36. data/crates/honeymaker-client/tests/classification.rs +203 -0
  37. data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
  38. data/crates/honeymaker-client/tests/response.rs +522 -0
  39. data/crates/honeymaker-client/tests/support/mod.rs +550 -0
  40. data/crates/honeymaker-client/tests/transport.rs +828 -0
  41. data/ext/honeymaker_native/Cargo.toml +16 -0
  42. data/ext/honeymaker_native/extconf.rb +6 -0
  43. data/ext/honeymaker_native/src/convert.rs +150 -0
  44. data/ext/honeymaker_native/src/kraken.rs +362 -0
  45. data/ext/honeymaker_native/src/lib.rs +90 -0
  46. data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
  47. data/lib/honeymaker/clients/bybit.rb +19 -2
  48. data/lib/honeymaker/clients/coinbase.rb +3 -2
  49. data/lib/honeymaker/clients/kraken.rb +17 -1
  50. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  51. data/lib/honeymaker/native/kraken.rb +263 -0
  52. data/lib/honeymaker/native.rb +42 -0
  53. data/lib/honeymaker/version.rb +1 -1
  54. data/lib/honeymaker.rb +12 -2
  55. data/script/parity/kraken_client_live.rb +191 -0
  56. data/script/parity/kraken_live.rb +70 -0
  57. data/script/parity/kraken_transport.rb +604 -0
  58. data/script/parity/resolve.c +49 -0
  59. data/script/release/push_gems.sh +28 -0
  60. data/script/vectors/kraken.rb +50 -0
  61. data/script/vectors/kraken_stage2.rb +146 -0
  62. data/test/fixtures/coinbase_products.json +1 -1
  63. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  64. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  65. data/test/honeymaker/clients/kraken_client_test.rb +27 -0
  66. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  67. data/test/native/backend_switch_test.rb +53 -0
  68. data/test/native/convert_test.rb +137 -0
  69. data/test/native/gem_isolation_test.rb +17 -0
  70. data/test/native/loader_test.rb +29 -0
  71. data/test/native/lockstep_test.rb +23 -0
  72. data/test/native/signing_smoke_test.rb +28 -0
  73. data/test/parity/kraken_client_parity_test.rb +398 -0
  74. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  75. data/test/release/branch_hardening_test.rb +34 -0
  76. data/test/release/push_gems_test.rb +79 -0
  77. data/test/smoke.rb +24 -0
  78. data/test/support/kraken_signing_smoke.rb +21 -0
  79. data/test/support/parity_helper.rb +62 -0
  80. data/test/support/venue_double.rb +100 -0
  81. metadata +84 -4
@@ -0,0 +1,467 @@
1
+ //! One HTTP request over its own connection, with every failure tagged by the phase it happened
2
+ //! in. The tags are the causes Ruby's exception chain would name, because deltabadger decides
3
+ //! "did the request leave?" from those (Client.pre_transmission?).
4
+
5
+ /// The most specific cause, in Client::PREFERRED_CAUSE_PATTERNS' terms.
6
+ #[derive(Clone, Debug, PartialEq, Eq)]
7
+ pub enum Cause {
8
+ /// Socket::ResolutionError: the name did not resolve.
9
+ Dns,
10
+ /// Errno::ECONNREFUSED (the target, or the proxy).
11
+ Refused,
12
+ /// Errno::EHOSTUNREACH / ENETUNREACH / ENETDOWN / EADDRNOTAVAIL.
13
+ Unreachable,
14
+ /// Net::OpenTimeout: TCP connect, TLS handshake, or (Ruling R2) proxy CONNECT.
15
+ OpenTimeout,
16
+ /// Net::HTTPClientException: the proxy answered CONNECT with 4xx.
17
+ ProxyRefused(u16),
18
+ /// Any other CONNECT answer that is not 2xx, or a malformed one (Net::HTTPFatalError, …).
19
+ ProxyFailed,
20
+ /// OpenSSL::SSL::SSLError: a TLS failure, in the handshake or later.
21
+ Tls,
22
+ /// Net::ReadTimeout.
23
+ ReadTimeout,
24
+ /// Net::WriteTimeout.
25
+ WriteTimeout,
26
+ /// EOFError.
27
+ Eof,
28
+ /// Errno::ECONNRESET.
29
+ Reset,
30
+ /// Anything else: provenance unknown, so assume it may have landed.
31
+ Other,
32
+ }
33
+
34
+ #[derive(Clone, Debug, PartialEq, Eq)]
35
+ pub struct Failure {
36
+ pub cause: Cause,
37
+ pub message: String,
38
+ }
39
+
40
+ impl Failure {
41
+ pub fn new(cause: Cause, message: impl Into<String>) -> Self {
42
+ Self {
43
+ cause,
44
+ message: message.into(),
45
+ }
46
+ }
47
+
48
+ /// Client.pre_transmission?: the cause is one Client::PRE_TRANSMISSION_ERRORS names, or the
49
+ /// text says the connection was refused. Only then did the request provably never leave.
50
+ pub fn pre_transmission(&self) -> bool {
51
+ matches!(
52
+ self.cause,
53
+ Cause::Dns
54
+ | Cause::Refused
55
+ | Cause::Unreachable
56
+ | Cause::OpenTimeout
57
+ | Cause::ProxyRefused(_)
58
+ ) || self
59
+ .message
60
+ .to_ascii_lowercase()
61
+ .contains("connection refused")
62
+ }
63
+ }
64
+
65
+ /// What the venue answered, shaped as the gem's Faraday stack shapes it (Task 3).
66
+ #[derive(Clone, Debug, PartialEq)]
67
+ pub enum Reply {
68
+ /// A JSON body (any JSON value) under a JSON content type.
69
+ Parsed(serde_json::Value),
70
+ /// A body Faraday would not parse: another content type, or blank.
71
+ NotJson,
72
+ /// Faraday raised with an HTTP answer: 400–599, or an unparsable JSON body at any status.
73
+ Status { status: u16, message: String },
74
+ }
75
+
76
+ mod connect;
77
+ mod io_timeout;
78
+ mod response;
79
+ pub use response::{decode, shape};
80
+
81
+ use bytes::Bytes;
82
+ use http_body_util::{BodyExt, Full};
83
+ use hyper_util::rt::TokioIo;
84
+ use rustls::pki_types::ServerName;
85
+ use std::sync::Arc;
86
+ use std::time::Duration;
87
+
88
+ /// A single data frame: once handed to hyper, its next completed flush includes
89
+ /// the entire body. Empty requests still require a header write and flush.
90
+ struct RequestBody {
91
+ inner: Full<Bytes>,
92
+ progress: Arc<io_timeout::RequestProgress>,
93
+ }
94
+ impl hyper::body::Body for RequestBody {
95
+ type Data = Bytes;
96
+ type Error = std::convert::Infallible;
97
+ fn poll_frame(
98
+ self: std::pin::Pin<&mut Self>,
99
+ cx: &mut std::task::Context<'_>,
100
+ ) -> std::task::Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
101
+ let this = self.get_mut();
102
+ let result = std::pin::Pin::new(&mut this.inner).poll_frame(cx);
103
+ if result.is_ready() {
104
+ this.progress.body_queued();
105
+ }
106
+ result
107
+ }
108
+ fn is_end_stream(&self) -> bool {
109
+ self.inner.is_end_stream()
110
+ }
111
+ fn size_hint(&self) -> hyper::body::SizeHint {
112
+ self.inner.size_hint()
113
+ }
114
+ }
115
+
116
+ /// Honeymaker::Client::OPTIONS. Open bounds each connect phase; read and write bound each wait
117
+ /// on the socket, as Net::HTTP's timeouts do, never the whole exchange.
118
+ #[derive(Clone, Copy, Debug)]
119
+ pub struct Timeouts {
120
+ pub open: Duration,
121
+ pub read: Duration,
122
+ pub write: Duration,
123
+ }
124
+ impl Default for Timeouts {
125
+ fn default() -> Self {
126
+ Self {
127
+ open: Duration::from_secs(5),
128
+ read: Duration::from_secs(30),
129
+ write: Duration::from_secs(10),
130
+ }
131
+ }
132
+ }
133
+
134
+ #[derive(Debug)]
135
+ pub struct ConfigError(pub String);
136
+ impl std::fmt::Display for ConfigError {
137
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138
+ f.write_str(&self.0)
139
+ }
140
+ }
141
+ impl std::error::Error for ConfigError {}
142
+
143
+ pub struct Request {
144
+ pub method: &'static str,
145
+ pub path_and_query: String,
146
+ pub headers: Vec<(String, String)>,
147
+ pub body: Option<String>,
148
+ }
149
+
150
+ /// How Net::HTTP terminates the body read, which determines whether inflater finish errors escape.
151
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
152
+ pub enum BodyFraming {
153
+ ContentLength,
154
+ Chunked,
155
+ CloseDelimited,
156
+ }
157
+
158
+ #[derive(Debug)]
159
+ pub struct Raw {
160
+ pub status: u16,
161
+ pub content_type: Option<String>,
162
+ /// As received; Task 3 inflates gzip/deflate before shaping.
163
+ pub content_encoding: Option<String>,
164
+ /// Net::HTTP leaves a Content-Range body encoded.
165
+ pub content_range: bool,
166
+ pub framing: BodyFraming,
167
+ pub body: Bytes,
168
+ }
169
+
170
+ #[derive(Clone, Debug, PartialEq, Eq)]
171
+ pub(crate) struct Endpoint {
172
+ pub tls: bool,
173
+ pub host: String,
174
+ pub port: u16,
175
+ }
176
+ impl Endpoint {
177
+ /// host:port for CONNECT, and Host without the scheme's default port.
178
+ pub fn authority(&self) -> String {
179
+ let h = if self.host.contains(':') {
180
+ format!("[{}]", self.host)
181
+ } else {
182
+ self.host.clone()
183
+ };
184
+ format!("{h}:{}", self.port)
185
+ }
186
+ fn host_header(&self) -> String {
187
+ let default = if self.tls { 443 } else { 80 };
188
+ if self.port == default {
189
+ if self.host.contains(':') {
190
+ format!("[{}]", self.host)
191
+ } else {
192
+ self.host.clone()
193
+ }
194
+ } else {
195
+ self.authority()
196
+ }
197
+ }
198
+ }
199
+
200
+ #[derive(Clone, Debug)]
201
+ pub(crate) struct Proxy {
202
+ pub host: String,
203
+ pub port: u16,
204
+ pub authorization: Option<String>,
205
+ }
206
+
207
+ pub struct Transport {
208
+ pub(crate) base: String,
209
+ pub(crate) target: Endpoint,
210
+ pub(crate) server_name: ServerName<'static>,
211
+ pub(crate) proxy: Option<Proxy>,
212
+ pub(crate) timeouts: Timeouts,
213
+ pub(crate) tls: Arc<rustls::ClientConfig>,
214
+ }
215
+
216
+ fn split_host_port(authority: &str, default: u16) -> Result<(String, u16), ConfigError> {
217
+ let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
218
+ let (h, tail) = rest
219
+ .split_once(']')
220
+ .ok_or_else(|| ConfigError("unterminated IPv6 literal".into()))?;
221
+ h.parse::<std::net::Ipv6Addr>()
222
+ .map_err(|_| ConfigError("invalid IPv6 literal".into()))?;
223
+ let port = if tail.is_empty() {
224
+ None
225
+ } else {
226
+ Some(
227
+ tail.strip_prefix(':')
228
+ .ok_or_else(|| ConfigError("invalid authority".into()))?,
229
+ )
230
+ };
231
+ (h, port)
232
+ } else {
233
+ match authority.rsplit_once(':') {
234
+ Some((h, p)) if !h.contains(':') => (h, Some(p)),
235
+ Some(_) => return Err(ConfigError("IPv6 literals must be bracketed".into())),
236
+ None => (authority, None),
237
+ }
238
+ };
239
+ let port = match port {
240
+ Some(p) => p.parse().map_err(|_| ConfigError("bad port".into()))?,
241
+ None => default,
242
+ };
243
+ if host.is_empty() {
244
+ return Err(ConfigError("missing host".into()));
245
+ }
246
+ ServerName::try_from(host.to_string()).map_err(|_| ConfigError("invalid host name".into()))?;
247
+ Ok((host.to_string(), port))
248
+ }
249
+
250
+ fn endpoint(url: &str) -> Result<Endpoint, ConfigError> {
251
+ let (tls, rest) = if let Some(r) = url.strip_prefix("https://") {
252
+ (true, r)
253
+ } else if let Some(r) = url.strip_prefix("http://") {
254
+ (false, r)
255
+ } else {
256
+ return Err(ConfigError("base URL must be http:// or https://".into()));
257
+ };
258
+ let authority = rest.strip_suffix('/').unwrap_or(rest);
259
+ if authority.contains(['/', '@', '?', '#']) {
260
+ return Err(ConfigError("base URL must be scheme://host[:port]".into()));
261
+ }
262
+ let (host, port) = split_host_port(authority, if tls { 443 } else { 80 })?;
263
+ Ok(Endpoint { tls, host, port })
264
+ }
265
+
266
+ /// Faraday/URI: userinfo is percent-decoded before it becomes Basic credentials.
267
+ fn unescape(s: &str) -> Vec<u8> {
268
+ let b = s.as_bytes();
269
+ let mut out = Vec::with_capacity(b.len());
270
+ let mut i = 0;
271
+ while i < b.len() {
272
+ let hex = |c: u8| (c as char).to_digit(16);
273
+ if b[i] == b'%'
274
+ && i + 2 < b.len()
275
+ && let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2]))
276
+ {
277
+ out.push((h * 16 + l) as u8);
278
+ i += 3;
279
+ continue;
280
+ }
281
+ out.push(b[i]);
282
+ i += 1;
283
+ }
284
+ out
285
+ }
286
+
287
+ /// Ruling R5: http:// only, userinfo → Basic, never echo the URL (it may carry a password).
288
+ fn proxy(url: &str) -> Result<Proxy, ConfigError> {
289
+ let rest = url
290
+ .strip_prefix("http://")
291
+ .ok_or_else(|| ConfigError("proxy must be an http:// URL".into()))?;
292
+ let authority = rest.strip_suffix('/').unwrap_or(rest);
293
+ if authority.contains(['/', '?', '#']) {
294
+ return Err(ConfigError(
295
+ "proxy URL must be http://[userinfo@]host[:port]".into(),
296
+ ));
297
+ }
298
+ let (userinfo, hostport) = match authority.rsplit_once('@') {
299
+ Some((u, h)) => (Some(u), h),
300
+ None => (None, authority),
301
+ };
302
+ let (host, port) = split_host_port(hostport, 80)?;
303
+ let authorization = userinfo.map(|u| {
304
+ let (user, pass) = u.split_once(':').unwrap_or((u, ""));
305
+ let mut creds = unescape(user);
306
+ creds.push(b':');
307
+ creds.extend(unescape(pass));
308
+ use base64::Engine;
309
+ format!(
310
+ "Basic {}",
311
+ base64::engine::general_purpose::STANDARD.encode(creds)
312
+ )
313
+ });
314
+ Ok(Proxy {
315
+ host,
316
+ port,
317
+ authorization,
318
+ })
319
+ }
320
+
321
+ /// Ruling R4: rustls with ring, webpki roots unless the caller supplies a store (tests).
322
+ fn tls_config(
323
+ roots: Option<Arc<rustls::RootCertStore>>,
324
+ ) -> Result<Arc<rustls::ClientConfig>, ConfigError> {
325
+ let roots = roots.unwrap_or_else(|| {
326
+ Arc::new(rustls::RootCertStore {
327
+ roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
328
+ })
329
+ });
330
+ let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(
331
+ rustls::crypto::ring::default_provider(),
332
+ ))
333
+ .with_safe_default_protocol_versions()
334
+ .map_err(|e| ConfigError(e.to_string()))?
335
+ .with_root_certificates(roots)
336
+ .with_no_client_auth();
337
+ Ok(Arc::new(cfg))
338
+ }
339
+
340
+ impl Transport {
341
+ pub fn new(
342
+ base_url: &str,
343
+ proxy_url: Option<&str>,
344
+ timeouts: Timeouts,
345
+ roots: Option<Arc<rustls::RootCertStore>>,
346
+ ) -> Result<Self, ConfigError> {
347
+ let target = endpoint(base_url)?;
348
+ let server_name = ServerName::try_from(target.host.clone())
349
+ .map_err(|_| ConfigError("invalid host name".into()))?;
350
+ let proxy = proxy_url.map(proxy).transpose()?;
351
+ let base = base_url.strip_suffix('/').unwrap_or(base_url).to_string();
352
+ Ok(Self {
353
+ base,
354
+ target,
355
+ server_name,
356
+ proxy,
357
+ timeouts,
358
+ tls: tls_config(roots)?,
359
+ })
360
+ }
361
+
362
+ pub fn url(&self, path_and_query: &str) -> String {
363
+ format!("{}{path_and_query}", self.base)
364
+ }
365
+
366
+ /// Send, inflate, and shape a reply as the gem's Net::HTTP + Faraday stack does.
367
+ pub async fn request(&self, req: &Request) -> Result<Reply, Failure> {
368
+ let raw = decode(self.send(req).await?)?;
369
+ Ok(shape(req.method, &self.url(&req.path_and_query), raw))
370
+ }
371
+
372
+ /// One request on its own connection (Ruling R3). Never retried.
373
+ pub async fn send(&self, req: &Request) -> Result<Raw, Failure> {
374
+ // Built before connecting: a header Ruby would refuse (a pasted key with a newline) fails
375
+ // like legacy's client_error — ambiguous by Rails' rule, though nothing is sent.
376
+ let mut b = hyper::Request::builder()
377
+ .method(req.method)
378
+ .uri(req.path_and_query.as_str())
379
+ .header(hyper::header::HOST, self.target.host_header())
380
+ .header(hyper::header::CONNECTION, "close")
381
+ // What Net::HTTP adds to every request that sets neither Accept-Encoding nor Range.
382
+ .header(
383
+ hyper::header::ACCEPT_ENCODING,
384
+ "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
385
+ );
386
+ for (k, v) in &req.headers {
387
+ if k.eq_ignore_ascii_case("content-length")
388
+ && v.trim().parse::<usize>().ok() != Some(req.body.as_ref().map_or(0, String::len))
389
+ {
390
+ return Err(Failure::new(
391
+ Cause::Other,
392
+ "ArgumentError: Content-Length does not match request body",
393
+ ));
394
+ }
395
+ b = b.header(k.as_str(), v.as_str());
396
+ }
397
+ let progress = Arc::new(io_timeout::RequestProgress::default());
398
+ let body = Bytes::from(req.body.clone().unwrap_or_default());
399
+ if body.is_empty() {
400
+ progress.body_queued();
401
+ }
402
+ let http_req = b
403
+ .body(RequestBody {
404
+ inner: Full::new(body),
405
+ progress: progress.clone(),
406
+ })
407
+ .map_err(|e| Failure::new(Cause::Other, format!("ArgumentError: {e}")))?;
408
+
409
+ // The read/write timeouts already sit under TLS, on the raw socket (Ruling R21).
410
+ let io = connect::connect(self, progress).await?;
411
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
412
+ .await
413
+ .map_err(|e| connect::hyper_failure(&e))?;
414
+ let exchange = async move {
415
+ let resp = sender.send_request(http_req).await?;
416
+ let status = resp.status().as_u16();
417
+ // Net::HTTPHeader#[] joins every field value, preserving non-ASCII bytes.
418
+ // Raw stores Strings, so retain those values with lossy UTF-8 decoding.
419
+ let header = |name: hyper::header::HeaderName| {
420
+ let mut values = resp.headers().get_all(name).iter();
421
+ let mut joined = String::from_utf8_lossy(values.next()?.as_bytes()).into_owned();
422
+ for value in values {
423
+ joined.push_str(", ");
424
+ joined.push_str(&String::from_utf8_lossy(value.as_bytes()));
425
+ }
426
+ Some(joined)
427
+ };
428
+ let content_type = header(hyper::header::CONTENT_TYPE);
429
+ let content_encoding = header(hyper::header::CONTENT_ENCODING);
430
+ let content_range = resp.headers().contains_key(hyper::header::CONTENT_RANGE);
431
+ let chunked = resp
432
+ .headers()
433
+ .get_all(hyper::header::TRANSFER_ENCODING)
434
+ .iter()
435
+ .any(|v| {
436
+ v.to_str().is_ok_and(|v| {
437
+ v.split(',')
438
+ .any(|coding| coding.trim().eq_ignore_ascii_case("chunked"))
439
+ })
440
+ });
441
+ let framing = if chunked {
442
+ BodyFraming::Chunked
443
+ } else if resp.headers().contains_key(hyper::header::CONTENT_LENGTH) {
444
+ BodyFraming::ContentLength
445
+ } else {
446
+ BodyFraming::CloseDelimited
447
+ };
448
+ let body = resp.into_body().collect().await?.to_bytes();
449
+ Ok::<_, hyper::Error>(Raw {
450
+ status,
451
+ content_type,
452
+ content_encoding,
453
+ content_range,
454
+ framing,
455
+ body,
456
+ })
457
+ };
458
+ // Driven together on this task: no spawn, so a current-thread runtime is enough.
459
+ let (conn_result, result) = tokio::join!(conn, exchange);
460
+ match (result, conn_result) {
461
+ (Ok(raw), _) => Ok(raw),
462
+ // The connection's error names the socket failure; the request's only says "closed".
463
+ (Err(_), Err(conn_err)) => Err(connect::hyper_failure(&conn_err)),
464
+ (Err(e), Ok(())) => Err(connect::hyper_failure(&e)),
465
+ }
466
+ }
467
+ }
@@ -0,0 +1,203 @@
1
+ //! Spec §4's legacy transport modes, each with the class deltabadger's Rails code gives the chain
2
+ //! the row records: Exchange#ambiguous_placement_error? → Client.most_specific_cause →
3
+ //! Client.pre_transmission? (PRE_TRANSMISSION_ERRORS, or /connection refused/i in the text).
4
+ use honeymaker_client::kraken::{VenueError, outcome};
5
+ use honeymaker_client::transport::{Cause, Failure, Reply};
6
+ use serde_json::json;
7
+
8
+ fn fail(cause: Cause, message: &str) -> Result<Reply, Failure> {
9
+ Err(Failure::new(cause, message))
10
+ }
11
+ fn status(status: u16, message: &str) -> Result<Reply, Failure> {
12
+ Ok(Reply::Status {
13
+ status,
14
+ message: message.into(),
15
+ })
16
+ }
17
+ fn class(r: Result<Reply, Failure>) -> &'static str {
18
+ match outcome(r) {
19
+ Ok(_) => "ok",
20
+ Err(VenueError::Rejected(_)) => "rejected",
21
+ Err(VenueError::Ambiguous(_)) => "ambiguous",
22
+ Err(VenueError::Transient(_)) => "transient",
23
+ }
24
+ }
25
+
26
+ #[test]
27
+ fn every_legacy_transport_mode_gets_the_class_rails_gives_it() {
28
+ let rows = vec![
29
+ // [ConnectionFailed, Net::HTTP::Persistent::Error, Errno::ECONNREFUSED] → ECONNREFUSED ∈ PRE
30
+ (
31
+ "refused",
32
+ fail(Cause::Refused, "connection refused: 127.0.0.1:9"),
33
+ "transient",
34
+ ),
35
+ // [ConnectionFailed, Socket::ResolutionError] ∈ PRE
36
+ (
37
+ "dns",
38
+ fail(
39
+ Cause::Dns,
40
+ "Failed to open TCP connection to x.invalid:443 (getaddrinfo)",
41
+ ),
42
+ "transient",
43
+ ),
44
+ // [TimeoutError, Net::OpenTimeout, IO::TimeoutError] → Net::OpenTimeout (tier 1) ∈ PRE
45
+ (
46
+ "tcp connect timeout",
47
+ fail(
48
+ Cause::OpenTimeout,
49
+ "Failed to open TCP connection to 10.255.255.1:81 (user specified timeout for 10.255.255.1:81)",
50
+ ),
51
+ "transient",
52
+ ),
53
+ (
54
+ "tls handshake stall",
55
+ fail(Cause::OpenTimeout, "Net::OpenTimeout"),
56
+ "transient",
57
+ ),
58
+ // EHOSTUNREACH / ENETUNREACH / ENETDOWN / EADDRNOTAVAIL ∈ PRE
59
+ (
60
+ "unreachable",
61
+ fail(
62
+ Cause::Unreachable,
63
+ "Failed to open TCP connection to 10.0.0.1:443 (No route to host)",
64
+ ),
65
+ "transient",
66
+ ),
67
+ // [SSLError, OpenSSL::SSL::SSLError] → tier 4, NOT in PRE (Ruling R1)
68
+ (
69
+ "tls not-tls / untrusted",
70
+ fail(
71
+ Cause::Tls,
72
+ "SSL_connect: invalid peer certificate: UnknownIssuer",
73
+ ),
74
+ "ambiguous",
75
+ ),
76
+ // [TimeoutError, Net::ReadTimeout] → tier 1, not in PRE
77
+ (
78
+ "read timeout",
79
+ fail(Cause::ReadTimeout, "Net::ReadTimeout"),
80
+ "ambiguous",
81
+ ),
82
+ (
83
+ "write timeout",
84
+ fail(Cause::WriteTimeout, "Net::WriteTimeout"),
85
+ "ambiguous",
86
+ ),
87
+ // [ConnectionFailed, EOFError]
88
+ (
89
+ "eof after request",
90
+ fail(Cause::Eof, "end of file reached"),
91
+ "ambiguous",
92
+ ),
93
+ (
94
+ "abrupt tls close",
95
+ fail(Cause::Tls, "SSL_read: unexpected eof while reading"),
96
+ "ambiguous",
97
+ ),
98
+ // [ConnectionFailed, Errno::ECONNRESET] → tier 2, not in PRE
99
+ (
100
+ "rst after request",
101
+ fail(Cause::Reset, "Connection reset by peer"),
102
+ "ambiguous",
103
+ ),
104
+ (
105
+ "proxy refused",
106
+ fail(Cause::Refused, "connection refused: 127.0.0.1:8100"),
107
+ "transient",
108
+ ),
109
+ // Ruling R2: a bounded CONNECT is an open-phase timeout, as Net::OpenTimeout
110
+ (
111
+ "proxy CONNECT stall",
112
+ fail(Cause::OpenTimeout, "Net::OpenTimeout (proxy CONNECT)"),
113
+ "transient",
114
+ ),
115
+ (
116
+ "proxy CONNECT eof",
117
+ fail(Cause::Eof, "end of file reached"),
118
+ "ambiguous",
119
+ ),
120
+ // [ConnectionFailed, Net::HTTPClientException] ∈ PRE
121
+ (
122
+ "proxy CONNECT 403",
123
+ fail(Cause::ProxyRefused(403), "403 \"Filtered\""),
124
+ "transient",
125
+ ),
126
+ (
127
+ "proxy CONNECT 407",
128
+ fail(
129
+ Cause::ProxyRefused(407),
130
+ "407 \"Proxy Authentication Required\"",
131
+ ),
132
+ "transient",
133
+ ),
134
+ // [ConnectionFailed, Net::HTTPFatalError] → falls back to Faraday::ConnectionFailed (Ruling R1)
135
+ (
136
+ "proxy CONNECT 500",
137
+ fail(Cause::ProxyFailed, "500 \"Unable to connect\""),
138
+ "ambiguous",
139
+ ),
140
+ // …unless the text says the connection was refused
141
+ (
142
+ "proxy CONNECT 502 refused",
143
+ fail(Cause::ProxyFailed, "502 \"Connection refused\""),
144
+ "transient",
145
+ ),
146
+ (
147
+ "other errno on connect",
148
+ fail(
149
+ Cause::Other,
150
+ "Failed to open TCP connection to h:1 (Permission denied)",
151
+ ),
152
+ "ambiguous",
153
+ ),
154
+ (
155
+ "http 500",
156
+ status(500, r#"{"error":["EService:Unavailable"]}"#),
157
+ "ambiguous",
158
+ ),
159
+ (
160
+ "http 500 empty",
161
+ status(
162
+ 500,
163
+ "the server responded with status 500 for POST https://api.kraken.com/0/private/AddOrder",
164
+ ),
165
+ "ambiguous",
166
+ ),
167
+ ("http 404 html", status(404, "<h1>nf</h1> é"), "rejected"),
168
+ ("http 429", status(429, "no"), "rejected"),
169
+ ("http 302 unparsable json", status(302, "{"), "rejected"),
170
+ ("200 invalid json", status(200, "{not json"), "ambiguous"),
171
+ ("200 html or blank", Ok(Reply::NotJson), "ambiguous"),
172
+ (
173
+ "200 top-level array",
174
+ Ok(Reply::Parsed(json!([1, 2.5, "x"]))),
175
+ "ambiguous",
176
+ ),
177
+ ("200 null", Ok(Reply::Parsed(json!(null))), "ambiguous"),
178
+ (
179
+ "200 object",
180
+ Ok(Reply::Parsed(json!({"error": [], "result": {}}))),
181
+ "ok",
182
+ ),
183
+ ];
184
+ for (name, reply, want) in rows {
185
+ assert_eq!(class(reply), want, "{name}");
186
+ }
187
+ }
188
+
189
+ #[test]
190
+ fn messages_travel_verbatim() {
191
+ assert_eq!(
192
+ outcome(status(404, "<h1>nf</h1> é")),
193
+ Err(VenueError::Rejected(vec!["<h1>nf</h1> é".into()]))
194
+ );
195
+ assert_eq!(
196
+ outcome(fail(Cause::Refused, "connection refused: h:1")),
197
+ Err(VenueError::Transient("connection refused: h:1".into()))
198
+ );
199
+ assert_eq!(
200
+ outcome(Ok(Reply::NotJson)),
201
+ Err(VenueError::Ambiguous("Kraken: unreadable response".into()))
202
+ );
203
+ }