use crate::{
common::recoverable::RecoverableConnection,
error::{find_link_stolen, ErrorKind, EventHubsError, Result},
models::ReceivedEventData,
};
use async_stream::try_stream;
use azure_core::{http::Url, time::Duration};
use azure_core_amqp::{
error::AmqpErrorKind, AmqpDeliveryApis as _, AmqpError, AmqpReceiverApis as _,
AmqpReceiverOptions, AmqpSource,
};
use futures::Stream;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tracing::{debug, trace, warn, Instrument};
fn translate_receive_error(
error: AmqpError,
partition_id: &str,
source_url: &Url,
) -> EventHubsError {
if let Some(described) = find_link_stolen(&error) {
warn!(
partition_id = %partition_id,
source_url = %source_url,
condition = ?described.condition,
"Receiver link stolen by the broker (epoch displacement); mapping to ConsumerDisconnected."
);
return EventHubsError::from(ErrorKind::ConsumerDisconnected(Some(described.clone())));
}
if let AmqpErrorKind::AmqpDescribedError(described) = error.kind() {
warn!(
partition_id = %partition_id,
source_url = %source_url,
condition = ?described.condition,
"Receive delivery failed with an AMQP error condition."
);
} else {
warn!(
partition_id = %partition_id,
source_url = %source_url,
err = ?error,
"Receive delivery failed."
);
}
EventHubsError::from(error)
}
fn translate_attach_error(
error: EventHubsError,
partition_id: &str,
source_url: &Url,
) -> EventHubsError {
let ErrorKind::AmqpError(amqp_error) = &error.kind else {
return error;
};
match find_link_stolen(amqp_error) {
Some(described) => {
warn!(
partition_id = %partition_id,
source_url = %source_url,
condition = ?described.condition,
"Receiver attach rejected by the broker (epoch displacement); mapping to ConsumerDisconnected."
);
EventHubsError::from(ErrorKind::ConsumerDisconnected(Some(described.clone())))
}
None => error,
}
}
pub struct EventReceiver {
connection: Arc<RecoverableConnection>,
receiver_options: AmqpReceiverOptions,
message_source: AmqpSource,
source_url: Url,
partition_id: String,
timeout: Option<Duration>,
closed: AtomicBool,
}
impl EventReceiver {
pub(crate) fn new(
connection: Arc<RecoverableConnection>,
receiver_options: AmqpReceiverOptions,
message_source: AmqpSource,
source_url: Url,
partition_id: String,
timeout: Option<Duration>,
) -> Self {
Self {
source_url,
connection,
receiver_options,
message_source,
partition_id,
timeout,
closed: AtomicBool::new(false),
}
}
pub fn partition_id(&self) -> &str {
&self.partition_id
}
pub fn stream_events(&self) -> impl Stream<Item = Result<ReceivedEventData>> + '_ {
let span = tracing::debug_span!(
"stream_events",
connection_id = %self.connection.get_connection_id(),
partition_id = %self.partition_id,
source_url = %self.source_url,
);
Box::pin(try_stream! {
loop {
if self.closed.load(Ordering::Acquire) {
span.in_scope(|| debug!(
partition_id = %self.partition_id,
source_url = %self.source_url,
"Event stream terminating: receiver was closed by request_close()."
));
Err(EventHubsError::from(ErrorKind::ConsumerDisconnected(None)))?;
}
let receiver = self.connection.get_receiver(&self.source_url,
self.message_source.clone(),
self.receiver_options.clone(),
self.timeout
).instrument(span.clone()).await
.map_err(|e| translate_attach_error(e, &self.partition_id, &self.source_url))?;
let delivery = receiver
.receive_delivery()
.instrument(span.clone())
.await
.map_err(|e| translate_receive_error(e, &self.partition_id, &self.source_url))?;
let message = delivery.into_message();
let message = ReceivedEventData::from(message);
span.in_scope(|| trace!("Received message: {:?}", message));
yield message;
}
})
}
pub async fn close(self) -> Result<()> {
self.connection.close_receiver(&self.source_url).await
}
pub(crate) async fn request_close(&self) -> Result<()> {
self.closed.store(true, Ordering::Release);
self.connection.close_receiver(&self.source_url).await
}
}
impl Drop for EventReceiver {
fn drop(&mut self) {
trace!("Dropping EventReceiver for partition {}", self.partition_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use azure_core_amqp::{error::AmqpErrorCondition, AmqpDescribedError};
fn source_url() -> Url {
Url::parse("amqps://example.servicebus.windows.net/eh/Partitions/0").unwrap()
}
fn stolen() -> AmqpError {
AmqpError::from(AmqpErrorKind::AmqpDescribedError(AmqpDescribedError::new(
AmqpErrorCondition::LinkStolen,
Some("New receiver with higher epoch of '1' is created".to_string()),
Default::default(),
)))
}
fn wrapped_in_ensure_receiver(inner: AmqpError) -> AmqpError {
crate::common::recoverable::receiver::RecoverableReceiver::ensure_receiver_error(inner)
}
#[test]
fn translate_receive_error_maps_top_level_link_stolen() {
let translated = translate_receive_error(stolen(), "0", &source_url());
assert!(matches!(
translated.kind,
ErrorKind::ConsumerDisconnected(Some(_))
));
}
#[test]
fn translate_receive_error_maps_link_stolen_wrapped_by_ensure_receiver() {
let translated =
translate_receive_error(wrapped_in_ensure_receiver(stolen()), "0", &source_url());
assert!(
matches!(translated.kind, ErrorKind::ConsumerDisconnected(Some(_))),
"expected ConsumerDisconnected, got {:?}",
translated.kind
);
}
#[test]
fn translate_receive_error_passes_other_conditions_through() {
let other = AmqpError::from(AmqpErrorKind::AmqpDescribedError(AmqpDescribedError::new(
AmqpErrorCondition::ServerBusyError,
None,
Default::default(),
)));
let translated = translate_receive_error(other, "0", &source_url());
assert!(matches!(translated.kind, ErrorKind::AmqpError(_)));
}
#[test]
fn translate_receive_error_passes_non_described_errors_through() {
let translated =
translate_receive_error(AmqpError::with_message("boom"), "0", &source_url());
assert!(matches!(translated.kind, ErrorKind::AmqpError(_)));
}
#[test]
fn translate_attach_error_maps_link_stolen() {
let attach_error = EventHubsError::from(stolen());
let translated = translate_attach_error(attach_error, "0", &source_url());
assert!(
matches!(translated.kind, ErrorKind::ConsumerDisconnected(Some(_))),
"expected ConsumerDisconnected, got {:?}",
translated.kind
);
}
#[test]
fn translate_attach_error_maps_link_stolen_wrapped_in_azure_core() {
let attach_error = EventHubsError::from(wrapped_in_ensure_receiver(stolen()));
let translated = translate_attach_error(attach_error, "0", &source_url());
assert!(
matches!(translated.kind, ErrorKind::ConsumerDisconnected(Some(_))),
"expected ConsumerDisconnected, got {:?}",
translated.kind
);
}
fn receiver_with_failing_attach(attach_error: AmqpError) -> EventReceiver {
let connection = RecoverableConnection::new(
Url::parse("amqps://example.servicebus.windows.net").unwrap(),
None,
None,
Default::default(),
Arc::new(azure_core_test::credentials::MockCredential),
Default::default(),
None,
);
connection.force_attach_error(attach_error).unwrap();
EventReceiver::new(
connection,
AmqpReceiverOptions::default(),
AmqpSource::builder()
.with_address(source_url().to_string())
.build(),
source_url(),
"0".to_string(),
None,
)
}
#[tokio::test]
async fn stream_events_maps_stolen_attach_to_consumer_disconnected() {
use futures::StreamExt;
let receiver = receiver_with_failing_attach(stolen());
let mut stream = std::pin::pin!(receiver.stream_events());
let error = stream
.next()
.await
.expect("the stream yields the attach failure")
.expect_err("the injected attach error must surface");
assert!(
matches!(error.kind, ErrorKind::ConsumerDisconnected(Some(_))),
"expected ConsumerDisconnected, got {:?}",
error.kind
);
}
#[tokio::test]
async fn stream_events_passes_other_attach_errors_through() {
use futures::StreamExt;
let receiver = receiver_with_failing_attach(AmqpError::with_message("attach failed"));
let mut stream = std::pin::pin!(receiver.stream_events());
let error = stream
.next()
.await
.expect("the stream yields the attach failure")
.expect_err("the injected attach error must surface");
assert!(
matches!(error.kind, ErrorKind::AmqpError(_)),
"expected AmqpError, got {:?}",
error.kind
);
}
#[test]
fn translate_attach_error_passes_other_errors_through() {
let attach_error = EventHubsError::from(AmqpError::with_message("attach failed"));
let translated = translate_attach_error(attach_error, "0", &source_url());
assert!(matches!(translated.kind, ErrorKind::AmqpError(_)));
let attach_error = EventHubsError::with_message("not an AMQP error");
let translated = translate_attach_error(attach_error, "0", &source_url());
assert!(matches!(translated.kind, ErrorKind::SimpleMessage(_)));
}
}