use std::{sync::Arc, time::Duration};
use wyrd_tonic::tonic::transport::{Channel, ClientTlsConfig, Endpoint};
use crate::{auth::AuthMiddleware, error::WyrdClientError, transport::config::GrpcConfig};
#[derive(Clone)]
pub struct GrpcConnection {
channel: Channel,
auth: Arc<AuthMiddleware>,
max_message_bytes: usize,
call_timeout: Duration,
}
impl std::fmt::Debug for GrpcConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GrpcConnection")
.field("auth", &self.auth)
.field("max_message_bytes", &self.max_message_bytes)
.field("call_timeout", &self.call_timeout)
.finish_non_exhaustive()
}
}
impl GrpcConnection {
pub async fn connect(
config: &GrpcConfig,
auth: Arc<AuthMiddleware>,
) -> Result<Self, WyrdClientError> {
let endpoint = build_endpoint(config)?;
let max_attempts = 1u32.saturating_add(config.connect_retries);
let mut last_err = None;
for attempt in 0..max_attempts {
match endpoint.connect().await {
Ok(channel) => {
return Ok(Self {
channel,
auth,
max_message_bytes: config.max_message_bytes,
call_timeout: Duration::from_millis(config.timeout_ms),
});
}
Err(err) => {
last_err = Some(err);
if attempt + 1 < max_attempts {
tokio::time::sleep(Duration::from_millis(crate::transport::backoff_ms(
attempt,
)))
.await;
}
}
}
}
Err(WyrdClientError::TransportDown {
transport: "grpc".to_owned(),
message: last_err.expect("at least one attempt was made").to_string(),
})
}
#[must_use]
pub fn channel(&self) -> Channel {
self.channel.clone()
}
#[must_use]
pub fn auth(&self) -> Arc<AuthMiddleware> {
Arc::clone(&self.auth)
}
#[must_use]
pub fn max_message_bytes(&self) -> usize {
self.max_message_bytes
}
#[must_use]
pub fn call_timeout(&self) -> Duration {
self.call_timeout
}
}
fn build_endpoint(config: &GrpcConfig) -> Result<Endpoint, WyrdClientError> {
wyrd_tls::install_crypto_provider().map_err(|error| WyrdClientError::TransportDown {
transport: "grpc".to_owned(),
message: error.to_string(),
})?;
let endpoint = Endpoint::from_shared(config.endpoint.clone()).map_err(|err| {
WyrdClientError::TransportDown {
transport: "grpc".to_owned(),
message: format!("invalid endpoint URI: {err}"),
}
})?;
let timeout = Duration::from_millis(config.timeout_ms);
let endpoint = endpoint.timeout(timeout).connect_timeout(timeout);
let endpoint = if config.keepalive_interval_ms > 0 {
endpoint
.http2_keep_alive_interval(Duration::from_millis(config.keepalive_interval_ms))
.keep_alive_timeout(Duration::from_millis(config.keepalive_timeout_ms))
.keep_alive_while_idle(true)
} else {
endpoint
};
if config.endpoint.starts_with("https://") {
endpoint
.tls_config(ClientTlsConfig::new().with_native_roots())
.map_err(|err| WyrdClientError::TransportDown {
transport: "grpc".to_owned(),
message: format!("TLS config error: {err}"),
})
} else {
Ok(endpoint)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use secrecy::SecretString;
use tokio::io::AsyncWriteExt as _;
use tokio::net::TcpListener;
use super::GrpcConnection;
use crate::auth::AuthMiddleware;
use crate::config::ClientConfig;
use crate::error::WyrdClientError;
use crate::transport::config::GrpcConfig;
use crate::transport::credential::ResolvedCredential;
fn auth() -> Arc<AuthMiddleware> {
let credential = ResolvedCredential::BearerToken(SecretString::from("test-token"));
AuthMiddleware::new(&ClientConfig::default(), credential).expect("auth client builds")
}
async fn h2_stub() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("stub binds");
let addr = listener.local_addr().expect("stub has an address");
tokio::spawn(async move {
if let Ok((mut stream, _)) = listener.accept().await {
let settings = [0x00_u8, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00];
let _ = stream.write_all(&settings).await;
std::future::pending::<()>().await;
}
});
format!("http://{addr}")
}
fn config(endpoint: String) -> GrpcConfig {
GrpcConfig {
endpoint,
timeout_ms: 2_000,
connect_retries: 0,
..GrpcConfig::default()
}
}
#[tokio::test]
async fn invalid_endpoint_fails_as_grpc_transport_down() {
let error = GrpcConnection::connect(&config("not a valid uri".to_owned()), auth())
.await
.expect_err("an invalid URI cannot dial");
assert!(
matches!(&error, WyrdClientError::TransportDown { transport, .. } if transport == "grpc"),
"{error:?}"
);
}
#[tokio::test]
async fn connection_keeps_the_callers_auth_and_message_size() {
let auth = auth();
let connection = GrpcConnection::connect(
&GrpcConfig {
max_message_bytes: 8 * 1024 * 1024,
..config(h2_stub().await)
},
Arc::clone(&auth),
)
.await
.expect("the stub accepts the dial");
assert!(Arc::ptr_eq(&auth, &connection.auth()));
assert_eq!(connection.max_message_bytes(), 8 * 1024 * 1024);
}
}