mod analytics_exporter;
mod api_error;
mod api_response_stream;
mod connection_client;
mod connection_registry;
mod grpc;
#[cfg(not(target_arch = "wasm32"))]
mod segment_chunk_provider;
#[cfg(not(target_arch = "wasm32"))]
pub use self::segment_chunk_provider::SegmentChunkProvider;
pub use self::analytics_exporter::ConnectionAnalyticsExporter;
pub use self::api_error::{ApiError, ApiErrorKind, ApiResult};
pub use self::api_response_stream::ApiResponseStream;
pub use self::connection_client::{
BoxedRedapClientStack, Connection, ConnectionClient, FetchChunksResponseStream, RedapClient,
SegmentQueryParams,
};
pub use self::connection_registry::{
ClientCredentialsError, ConnectionRegistry, ConnectionRegistryHandle, CredentialSource,
Credentials, SourcedCredentials,
};
pub use self::grpc::{
ChunksWithSegment, RedapClientStack, SegmentDownload, StreamingOptions, channel,
fetch_chunks_response_to_chunk_and_segment_id, stream_blueprint_and_segment_from_server,
stream_table_blueprint_segment_from_server, table_blueprint_log_channel,
};
#[cfg(not(target_arch = "wasm32"))]
pub use self::grpc::PoolChannel;
pub use opentelemetry::TraceId;
pub const MAX_DECODING_MESSAGE_SIZE: usize = u32::MAX as usize;
pub const FETCH_CHUNKS_DEADLINE: std::time::Duration = std::time::Duration::from_mins(5);
const GRPC_RESPONSE_TRACEID_HEADER: &str = "x-request-trace-id";
pub fn extract_trace_id(metadata: &tonic::metadata::MetadataMap) -> Option<opentelemetry::TraceId> {
let s = metadata.get(GRPC_RESPONSE_TRACEID_HEADER)?.to_str().ok()?;
opentelemetry::TraceId::from_hex(s).ok()
}
#[derive(Debug)]
pub struct TonicStatusError(Box<tonic::Status>);
const _: () = assert!(
std::mem::size_of::<TonicStatusError>() <= 32,
"Error type is too large. Try to reduce its size by boxing some of its variants.",
);
impl AsRef<tonic::Status> for TonicStatusError {
#[inline]
fn as_ref(&self) -> &tonic::Status {
&self.0
}
}
impl TonicStatusError {
pub fn into_inner(self) -> tonic::Status {
*self.0
}
}
impl std::fmt::Display for TonicStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fmt_tonic_status(f, &self.0)
}
}
fn fmt_tonic_status(f: &mut std::fmt::Formatter<'_>, status: &tonic::Status) -> std::fmt::Result {
if status.message().is_empty() {
write!(f, "gRPC error")?;
} else {
write!(f, "{}", status.message())?;
}
if status.code() != tonic::Code::Unknown {
write!(f, " ({})", status.code())?;
}
if !status.metadata().is_empty() {
write!(
f,
"{} metadata: {:?}",
re_error::DETAILS_SEPARATOR,
status.metadata().as_ref()
)?;
}
Ok(())
}
impl From<tonic::Status> for TonicStatusError {
fn from(value: tonic::Status) -> Self {
Self(Box::new(value))
}
}
impl std::error::Error for TonicStatusError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.0.source()
}
}
struct RetryPolicy {
base: std::time::Duration,
max: std::time::Duration,
max_attempts: usize,
total_budget: Option<std::time::Duration>,
}
const CONNECTION_RETRY_POLICY: RetryPolicy = RetryPolicy {
base: std::time::Duration::from_millis(100),
max: std::time::Duration::from_secs(3),
max_attempts: 5,
total_budget: None,
};
const RESOURCE_EXHAUSTED_RETRY_POLICY: RetryPolicy = RetryPolicy {
base: std::time::Duration::from_millis(250),
max: std::time::Duration::from_secs(2),
max_attempts: 12,
total_budget: Some(std::time::Duration::from_secs(10)),
};
pub async fn with_retry<T, F, Fut>(req_name: &str, f: F) -> ApiResult<T>
where
F: Fn() -> Fut,
Fut: Future<Output = ApiResult<T>>,
{
use tracing::Instrument as _;
let span = tracing::debug_span!(
"with_retry",
otel.name = format!("{req_name} with_retry"),
req_name,
);
retry_loop(
req_name,
&CONNECTION_RETRY_POLICY,
|err| err.kind.is_retryable(),
f,
)
.instrument(span)
.await
}
pub async fn with_retry_resource_exhausted<T, F, Fut>(req_name: &str, f: F) -> ApiResult<T>
where
F: Fn() -> Fut,
Fut: Future<Output = ApiResult<T>>,
{
use tracing::Instrument as _;
let span = tracing::debug_span!(
"with_retry_resource_exhausted",
otel.name = format!("{req_name} with_retry_resource_exhausted"),
req_name,
);
retry_loop(
req_name,
&RESOURCE_EXHAUSTED_RETRY_POLICY,
|err| err.kind == ApiErrorKind::ResourcesExhausted,
f,
)
.instrument(span)
.await
}
async fn retry_loop<T, F, Fut, R>(
req_name: &str,
policy: &RetryPolicy,
should_retry: R,
f: F,
) -> ApiResult<T>
where
F: Fn() -> Fut,
Fut: Future<Output = ApiResult<T>>,
R: Fn(&ApiError) -> bool,
{
let start = web_time::Instant::now();
let mut backoff_gen =
re_backoff::BackoffGenerator::new(policy.base, policy.max).expect("base is less than max");
let mut attempts = 1;
let mut last_retryable_err = None;
while attempts <= policy.max_attempts {
use tracing::Instrument as _;
let res = f()
.instrument(tracing::debug_span!("attempt", attempts))
.await;
match res {
Err(err) if should_retry(&err) => {
last_retryable_err = Some(err);
if attempts >= policy.max_attempts {
break;
}
let backoff = backoff_gen.gen_next();
if let Some(budget) = policy.total_budget
&& start.elapsed() + backoff.jittered() > budget
{
break;
}
tracing::trace!(
attempts,
max_attempts = policy.max_attempts,
?backoff,
"{req_name} failed with retryable gRPC error, retrying after backoff"
);
backoff.sleep().await;
}
Err(err) => {
tracing::trace!(
attempts,
"{req_name} failed with non-retryable error: {err}"
);
return Err(err);
}
Ok(value) => {
tracing::trace!(attempts, "{req_name} succeeded");
return Ok(value);
}
}
attempts += 1;
}
tracing::debug!(
attempts,
max_attempts = policy.max_attempts,
elapsed_ms = start.elapsed().as_millis() as u64,
"{req_name} giving up after exhausting retries"
);
Err(last_retryable_err.expect("bug: this should not be None if we reach here"))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod retry_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn resource_exhausted_err() -> ApiError {
ApiError::tonic(tonic::Status::resource_exhausted("busy"), "test")
}
const FAST_POLICY: RetryPolicy = RetryPolicy {
base: std::time::Duration::from_millis(1),
max: std::time::Duration::from_millis(1),
max_attempts: 12,
total_budget: None,
};
fn is_resource_exhausted(err: &ApiError) -> bool {
err.kind == ApiErrorKind::ResourcesExhausted
}
#[tokio::test]
async fn resource_exhausted_retries_until_success() {
let calls = AtomicUsize::new(0);
let res: ApiResult<u32> =
retry_loop("test", &FAST_POLICY, is_resource_exhausted, || async {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < 3 {
Err(resource_exhausted_err())
} else {
Ok(42)
}
})
.await;
assert_eq!(res.expect("should eventually succeed"), 42);
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn non_resource_exhausted_is_not_retried() {
let calls = AtomicUsize::new(0);
let res: ApiResult<u32> = with_retry_resource_exhausted("test", || async {
calls.fetch_add(1, Ordering::SeqCst);
Err(ApiError::internal("boom"))
})
.await;
assert!(res.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn retry_loop_respects_wall_clock_budget() {
let policy = RetryPolicy {
base: std::time::Duration::from_millis(10),
max: std::time::Duration::from_millis(10),
max_attempts: 1000,
total_budget: Some(std::time::Duration::from_millis(40)),
};
let calls = AtomicUsize::new(0);
let res: ApiResult<u32> = retry_loop(
"test",
&policy,
|err| err.kind == ApiErrorKind::ResourcesExhausted,
|| async {
calls.fetch_add(1, Ordering::SeqCst);
Err(resource_exhausted_err())
},
)
.await;
assert!(res.is_err());
let n = calls.load(Ordering::SeqCst);
assert!(n >= 2, "should retry at least once, got {n}");
assert!(
n < policy.max_attempts,
"should stop on the wall-clock budget, not the attempt cap, got {n}"
);
}
}