The HTTPS adapter behind jev-client's ports: one HTTP/2 connection
per backend over rustls, driven by the executor, and a Runtime on
the executor's timers. It sends and classifies; retrying, budgets and
statuses are the client's (contract §2).
16use bytes::Bytes; 17use futures_rustls::TlsConnector; 18use http_body_util::{BodyExt, Full, LengthLimitError, Limited}; 19use hyper::client::conn::http2::SendRequest; 20use jev_client::{HttpRequest, HttpResponse, Runtime, Transport, TransportError, TransportErrorKind}; 21use rustls::pki_types::pem::PemObject; 22use rustls::pki_types::{CertificateDer, ServerName}; 23use rustls::{ClientConfig, RootCertStore}; 24 25use crate::Error; 26use crate::dns::{self, Servers}; 27use crate::executor::{self, random_unit}; 28use crate::net::{HyperIo, PeerSettings, TcpIo}; 29use crate::ratelimit::RateLimit;
Cloudflare closes idle connections at 400 s; reconnect well before.
32const IDLE_LIMIT: Duration = Duration::from_secs(300);
The response cap (pg_typesafe's 8 MB).
34const MAX_BODY: usize = 8 << 20;
Time for each address but the last, so one blackholed address (a broken IPv6 route, a dead IP) cannot spend a whole attempt.
37const CONNECT_PER_ADDRESS: Duration = Duration::from_secs(2);
Streams assumed before a peer has said: RFC 9113 §6.5.2 recommends no fewer than 100, and api.typesafe.ai advertises 100 (contract §2).
40const ASSUMED_STREAMS: u32 = 100;
No limit seen yet on this connection.
42const UNKNOWN: u64 = u64::MAX;
Where requests go: the URL, how its host is reached, which CA file (if any) is trusted besides the Mozilla roots, and how the connection is kept alive. A connection is reused only for an equal endpoint, so changing any of these opens a new one.
A ping after interval without a frame from the server, while a scan
runs the executor; the connection is closed if one goes unanswered for
timeout (contract §2: 20 s and 10 s).
67impl Default for KeepAlive { 68 fn default() -> Self { 69 KeepAlive { interval: Duration::from_secs(20), timeout: Duration::from_secs(10) } 70 } 71} 72 73#[derive(Clone, PartialEq)] 74enum Host { 75 Ip(IpAddr), 76 Name { name: String, servers: Servers }, 77} 78 79impl Endpoint { 80 pub fn parse(url: &str, ca_file: Option<&str>, dns_servers: Option<&str>) -> Result<Self, Error> { 81 let config = |e: String| Error::Config(e); 82 let uri: http::Uri = url.parse().map_err(|e| config(format!("jev.endpoint {url}: {e}")))?; 83 if uri.scheme() != Some(&http::uri::Scheme::HTTPS) { 84 return Err(config(format!("jev.endpoint must be https: {url}"))); 85 } 86 let host = uri.host().ok_or_else(|| config(format!("jev.endpoint has no host: {url}")))?; 87 let (host, server_name) = match host.trim_start_matches('[').trim_end_matches(']').parse::<IpAddr>() { 88 Ok(ip) => (Host::Ip(ip), ServerName::IpAddress(ip.into())), 89 Err(_) => ( 90 Host::Name { name: host.to_owned(), servers: Servers::parse(dns_servers)? }, 91 ServerName::try_from(host.to_owned()) 92 .map_err(|e| config(format!("jev.endpoint host {host}: {e}")))?, 93 ), 94 }; 95 Ok(Endpoint { 96 port: uri.port_u16().unwrap_or(443), 97 host, 98 server_name, 99 ca_file: ca_file.map(str::to_owned), 100 keep_alive: KeepAlive::default(), 101 uri, 102 }) 103 } 104 105 pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Self { 106 self.keep_alive = keep_alive; 107 self 108 }
Every address to try, in the resolver's order (IPv4 first).
The Transport port for one endpoint.
Every attempt first waits for the cluster's rate limit, and its outcome feeds the limit back (429s halve it, answers restore it and correct the token estimate).
140impl Transport for HttpsTransport { 141 async fn admit(&self, request: &HttpRequest) { 142 if let Some(limit) = &self.limit { 143 limit.admit(&request.body).await; 144 } 145 } 146 147 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> { 148 let limit = self.limit; 149 let sent = request.body.clone(); 150 let result = self.exchange(request).await; 151 if let Some(limit) = limit { 152 match &result { 153 Ok(response) => limit.settle(&sent, response.status, &response.body), 154 Err(e) if e.kind == TransportErrorKind::NotSent => limit.unsent(&sent), 155 Err(_) => {} 156 } 157 } 158 result 159 } 160 161 fn distrust_connection(&self) { 162 forget_connection(); 163 } 164} 165 166impl HttpsTransport { 167 async fn exchange(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> { 168 let mut sender = connection(&self.endpoint).await?; 169 let mut builder = http::Request::post(self.endpoint.uri.clone()); 170 *builder.headers_mut().expect("a fresh builder has headers") = request.headers; 171 let request = builder 172 .body(Full::new(request.body)) 173 .map_err(|e| TransportError::new(TransportErrorKind::Config, format!("building the request: {e}")))?; 174 175 // From here until the body is read, or this future is dropped. 176 let _stream = crate::stats::InFlight::open(); 177 let response = match sender.try_send_request(request).await { 178 Ok(response) => response, 179 Err(mut e) => { 180 forget_connection(); 181 // hyper hands the request back when it never left. 182 let kind = if e.take_message().is_some() || never_processed(e.error()) { 183 TransportErrorKind::NotSent 184 } else { 185 TransportErrorKind::Interrupted 186 }; 187 return Err(TransportError::new(kind, format!("sending to {}: {}", self.endpoint.uri, e.error()))); 188 } 189 }; 190 let (parts, body) = response.into_parts(); 191 let body = match Limited::new(body, MAX_BODY).collect().await { 192 Ok(collected) => collected.to_bytes(), 193 Err(e) if e.is::<LengthLimitError>() => { 194 return Err(TransportError::new( 195 TransportErrorKind::TooLarge, 196 format!("Jev's response exceeded {} MiB", MAX_BODY >> 20), 197 )); 198 } 199 Err(e) => { 200 forget_connection(); 201 return Err(TransportError::new(TransportErrorKind::Interrupted, format!("reading Jev's response: {e}"))); 202 } 203 }; 204 touch_connection(); 205 Ok(HttpResponse { status: parts.status.as_u16(), headers: parts.headers, body }) 206 } 207}
True when h2 says the server never processed the stream: refused (REFUSED_STREAM), or cut by a GOAWAY whose last stream id is below ours. Such a request can be resent without being billed twice.
212fn never_processed(error: &hyper::Error) -> bool { 213 let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(error); 214 while let Some(e) = source { 215 if let Some(h2) = e.downcast_ref::<h2::Error>() { 216 return h2.reason() == Some(h2::Reason::REFUSED_STREAM) || (h2.is_go_away() && h2.is_remote()); 217 } 218 source = e.source(); 219 } 220 false 221}
The Runtime port on the executor: its timers and Postgres's PRNG.
224pub struct Backend;
226impl Runtime for Backend { 227 fn now(&self) -> Instant { 228 Instant::now() 229 } 230 231 fn wall_clock(&self) -> SystemTime { 232 SystemTime::now() 233 } 234 235 fn sleep(&self, duration: Duration) -> impl Future<Output = ()> { 236 executor::sleep(duration) 237 } 238 239 fn timeout<F: Future>(&self, duration: Duration, future: F) -> impl Future<Output = Option<F::Output>> { 240 executor::timeout(duration, future) 241 } 242 243 fn jitter(&self) -> f64 { 244 random_unit() 245 } 246} 247 248struct Connection { 249 endpoint: Endpoint, 250 sender: SendRequest<Full<Bytes>>, 251 last_used: Instant, 252} 253 254thread_local! { 255 static CONNECTION: RefCell<Option<Connection>> = const { RefCell::new(None) }; 256 static DIALING: RefCell<Option<Rc<Dial>>> = const { RefCell::new(None) };
Keyed by the CA file's path and modification time, so a rotated file is picked up by the next connection (review #16).
259 static TLS: RefCell<Option<(CaSource, Arc<ClientConfig>)>> = const { RefCell::new(None) };
The newest connection's SETTINGS_MAX_CONCURRENT_STREAMS, written
by its reader as frames arrive. It outlives the connection, so a
reconnect to the same endpoint starts from the last value seen.
How many streams the peer at endpoint accepts at once: the last
SETTINGS_MAX_CONCURRENT_STREAMS it sent, or [ASSUMED_STREAMS]
before any. hyper queues streams past the limit without saying so, so
jev.concurrency is clamped to this (contract §2).
A handle on the backend's connection. The connection stays cached while requests run on it, so a cancelled request costs only its stream; it is replaced when closed, idle past the limit, or distrusted.
283async fn connection(endpoint: &Endpoint) -> Result<SendRequest<Full<Bytes>>, TransportError> { 284 // Between statements nothing polls hyper's connection driver, so a 285 // GOAWAY or a close the server sent meanwhile is still unread and 286 // `is_closed` would say the connection is fine. Two passes: one 287 // wakes the driver on the readable socket, the next lets it run. 288 if CONNECTION.with(|c| c.borrow().is_some()) { 289 executor::yield_now().await; 290 executor::yield_now().await; 291 } 292 let cached = CONNECTION.with(|c| { 293 let mut c = c.borrow_mut(); 294 match c.as_mut() { 295 Some(conn) 296 if conn.endpoint == *endpoint 297 && conn.last_used.elapsed() < IDLE_LIMIT 298 && !conn.sender.is_closed() => 299 { 300 conn.last_used = Instant::now(); 301 Some(conn.sender.clone()) 302 } 303 _ => { 304 *c = None; 305 None 306 } 307 } 308 }); 309 if let Some(sender) = cached { 310 return Ok(sender); 311 } 312 // One dial at a time: a scan starts a window of requests at once, and 313 // each must not open its own connection. 314 let joined = DIALING.with(|d| d.borrow().as_ref().filter(|dial| dial.endpoint == *endpoint).cloned()); 315 if let Some(dial) = joined { 316 return std::future::poll_fn(|cx| match dial.result.borrow().as_ref() { 317 Some(result) => std::task::Poll::Ready(result.clone()), 318 None => { 319 dial.waiters.borrow_mut().push(cx.waker().clone()); 320 std::task::Poll::Pending 321 } 322 }) 323 .await; 324 } 325 let dial = Rc::new(Dial { endpoint: endpoint.clone(), result: RefCell::new(None), waiters: RefCell::new(Vec::new()) }); 326 DIALING.with(|d| *d.borrow_mut() = Some(dial.clone())); 327 let mut publish = Publish(Some(dial)); 328 let result = connect(endpoint).await; 329 if let Ok(sender) = &result { 330 crate::stats::connected(&endpoint.uri); 331 CONNECTION.with(|c| { 332 *c.borrow_mut() = 333 Some(Connection { endpoint: endpoint.clone(), sender: sender.clone(), last_used: Instant::now() }) 334 }); 335 } 336 publish.finish(result.clone()); 337 result 338}
A dial in progress, which later senders wait on.
Publishes the dial's outcome to its waiters, exactly once. Dropped unfinished (the dialing request was cancelled), it tells them nothing was sent, so each resends and one of them dials again.
350struct Publish(Option<Rc<Dial>>);
352impl Publish { 353 fn finish(&mut self, result: Result<SendRequest<Full<Bytes>>, TransportError>) { 354 let Some(dial) = self.0.take() else { return }; 355 DIALING.with(|d| { 356 let mut d = d.borrow_mut(); 357 if d.as_ref().is_some_and(|current| Rc::ptr_eq(current, &dial)) { 358 *d = None; 359 } 360 }); 361 *dial.result.borrow_mut() = Some(result); 362 for waker in dial.waiters.borrow_mut().drain(..) { 363 waker.wake(); 364 } 365 } 366} 367 368impl Drop for Publish { 369 fn drop(&mut self) { 370 // try_with: this may run during thread-local teardown. 371 if self.0.is_some() && DIALING.try_with(|_| ()).is_ok() { 372 self.finish(Err(TransportError::new(TransportErrorKind::NotSent, "the connection was being opened by a cancelled request"))); 373 } 374 } 375} 376 377fn forget_connection() { 378 CONNECTION.with(|c| *c.borrow_mut() = None); 379} 380 381fn touch_connection() { 382 CONNECTION.with(|c| { 383 if let Some(conn) = c.borrow_mut().as_mut() { 384 conn.last_used = Instant::now(); 385 } 386 }); 387} 388 389async fn connect(endpoint: &Endpoint) -> Result<SendRequest<Full<Bytes>>, TransportError> { 390 use TransportErrorKind::{Config, Connect, Tls}; 391 let config = tls_config(endpoint.ca_file.as_deref()).map_err(|e| TransportError::new(Config, e.to_string()))?; 392 393 let addrs = endpoint.addrs().await?; 394 let mut failures = Vec::new(); 395 let mut tcp = None; 396 for (i, addr) in addrs.iter().enumerate() { 397 let attempt = TcpIo::connect(*addr); 398 let result = if i + 1 < addrs.len() { 399 executor::timeout(CONNECT_PER_ADDRESS, attempt).await.unwrap_or_else(|| { 400 Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "no answer within 2 s")) 401 }) 402 } else { 403 attempt.await 404 }; 405 match result { 406 Ok(io) => { 407 tcp = Some(io); 408 break; 409 } 410 Err(e) => failures.push(format!("{addr}: {e}")), 411 } 412 } 413 let tcp = tcp.ok_or_else(|| { 414 TransportError::new(Connect, format!("connecting to {}: {}", endpoint.uri, failures.join("; "))) 415 })?; 416 417 let tls = TlsConnector::from(config).connect(endpoint.server_name.clone(), tcp).await.map_err(|e| { 418 // rustls refusing (a certificate, a protocol alert) will refuse 419 // again; an I/O failure mid-handshake is the network. 420 let refused = e.get_ref().is_some_and(|inner| inner.is::<rustls::Error>()); 421 TransportError::new(if refused { Tls } else { Connect }, format!("TLS with {}: {e}", endpoint.uri)) 422 })?; 423 if tls.get_ref().1.alpn_protocol() != Some(b"h2") { 424 return Err(TransportError::new(Tls, format!("{} did not negotiate HTTP/2", endpoint.uri))); 425 } 426 let max_streams = PEER_STREAMS.with(|p| { 427 let seed = p.borrow().as_ref().filter(|(seen, _)| seen == endpoint).map_or(UNKNOWN, |(_, m)| m.load(Ordering::Relaxed)); 428 let max = Arc::new(AtomicU64::new(seed)); 429 *p.borrow_mut() = Some((endpoint.clone(), max.clone())); 430 max 431 }); 432 let tls = PeerSettings::new(tls, move |n| max_streams.store(u64::from(n), Ordering::Relaxed)); 433 // hyper's fixed receive windows (5 MB per connection, 2 MB per 434 // stream) are below 8 MB answers times the scan's window; BDP-based 435 // adaptive windows grow to what the path carries (contract §2). 436 let (sender, connection) = hyper::client::conn::http2::Builder::new(LocalExec) 437 .adaptive_window(true) 438 // Pings find a dead path before the attempt timeout would, and 439 // keep a long scan's quiet connection open (contract §2). They 440 // run only while a scan polls the executor. 441 .timer(BackendTimer) 442 .keep_alive_interval(endpoint.keep_alive.interval) 443 .keep_alive_timeout(endpoint.keep_alive.timeout) 444 .keep_alive_while_idle(true) 445 .handshake(HyperIo(tls)) 446 .await 447 .map_err(|e| TransportError::new(Connect, format!("HTTP/2 with {}: {e}", endpoint.uri)))?; 448 executor::spawn_local(async move { 449 let _ = connection.await; 450 }); 451 Ok(sender) 452}
Which extra CA file, as last seen on disk.
Built on first use, not in _PG_init, and rebuilt when jev.ca_file
names another file or the file changes.
463fn tls_config(ca_file: Option<&str>) -> Result<Arc<ClientConfig>, Error> { 464 let source = CaSource { 465 path: ca_file.map(str::to_owned), 466 modified: ca_file.and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()), 467 }; 468 if let Some(config) = 469 TLS.with(|t| t.borrow().as_ref().filter(|(s, _)| *s == source).map(|(_, c)| c.clone())) 470 { 471 return Ok(config); 472 } 473 let mut roots = RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() }; 474 if let Some(path) = ca_file { 475 for cert in CertificateDer::pem_file_iter(path).map_err(|e| Error::Config(format!("jev.ca_file {path}: {e}")))? { 476 let cert = cert.map_err(|e| Error::Config(format!("jev.ca_file {path}: {e}")))?; 477 roots.add(cert).map_err(|e| Error::Config(format!("jev.ca_file {path}: {e}")))?; 478 } 479 } 480 let mut config = ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) 481 .with_safe_default_protocol_versions() 482 .map_err(|e| Error::Config(e.to_string()))? 483 .with_root_certificates(roots) 484 .with_no_client_auth(); 485 config.alpn_protocols = vec![b"h2".to_vec()]; 486 let config = Arc::new(config); 487 TLS.with(|t| *t.borrow_mut() = Some((source, config.clone()))); 488 Ok(config) 489}
hyper's timers (keep-alive pings) on the executor's.
495impl hyper::rt::Timer for BackendTimer { 496 fn sleep(&self, duration: Duration) -> Pin<Box<dyn hyper::rt::Sleep>> { 497 Box::pin(executor::sleep(duration)) 498 } 499 500 fn sleep_until(&self, deadline: Instant) -> Pin<Box<dyn hyper::rt::Sleep>> { 501 Box::pin(executor::sleep_until(deadline)) 502 } 503}
Send + Sync: a deadline and a timer id; it is polled only on the backend thread, where its timer lives.
507impl hyper::rt::Sleep for executor::Sleep {}