honeymaker 0.12.3 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +19 -4
- data/Cargo.lock +1078 -0
- data/Cargo.toml +14 -0
- data/README.md +39 -0
- data/Rakefile +28 -16
- data/crates/honeymaker/Cargo.toml +18 -0
- data/crates/honeymaker/src/encode.rs +106 -0
- data/crates/honeymaker/src/kraken/lookup.rs +140 -0
- data/crates/honeymaker/src/kraken/mod.rs +5 -0
- data/crates/honeymaker/src/kraken/normalize.rs +536 -0
- data/crates/honeymaker/src/kraken/requests.rs +286 -0
- data/crates/honeymaker/src/kraken/sign.rs +166 -0
- data/crates/honeymaker/src/kraken/trades.rs +201 -0
- data/crates/honeymaker/src/lib.rs +10 -0
- data/crates/honeymaker/src/num.rs +115 -0
- data/crates/honeymaker/src/semantics.rs +100 -0
- data/crates/honeymaker/src/types.rs +85 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
- data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
- data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/crates/honeymaker-client/Cargo.toml +30 -0
- data/crates/honeymaker-client/examples/probe.rs +147 -0
- data/crates/honeymaker-client/src/kraken.rs +514 -0
- data/crates/honeymaker-client/src/lib.rs +4 -0
- data/crates/honeymaker-client/src/transport/connect.rs +583 -0
- data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
- data/crates/honeymaker-client/src/transport/response.rs +169 -0
- data/crates/honeymaker-client/src/transport.rs +467 -0
- data/crates/honeymaker-client/tests/classification.rs +203 -0
- data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
- data/crates/honeymaker-client/tests/response.rs +522 -0
- data/crates/honeymaker-client/tests/support/mod.rs +550 -0
- data/crates/honeymaker-client/tests/transport.rs +828 -0
- data/ext/honeymaker_native/Cargo.toml +16 -0
- data/ext/honeymaker_native/extconf.rb +6 -0
- data/ext/honeymaker_native/src/convert.rs +150 -0
- data/ext/honeymaker_native/src/kraken.rs +362 -0
- data/ext/honeymaker_native/src/lib.rs +90 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
- data/lib/honeymaker/clients/bybit.rb +19 -2
- data/lib/honeymaker/clients/coinbase.rb +3 -2
- data/lib/honeymaker/clients/kraken.rb +6 -1
- data/lib/honeymaker/exchanges/coinbase.rb +5 -4
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +12 -2
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/test/fixtures/coinbase_products.json +1 -1
- data/test/honeymaker/clients/bybit_client_test.rb +25 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
- data/test/honeymaker/clients/kraken_client_test.rb +15 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- 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
|
+
}
|