1use crate::{
14 DeadLetterRecord, EnqueueAck, JobRejection, ServerInfo, ServerInfoError, pb::sepp::v1 as pb,
15};
16use std::{
17 sync::{
18 Arc,
19 atomic::{AtomicI64, Ordering},
20 },
21 time::{Duration, SystemTime},
22};
23
24#[cfg(feature = "tls")]
25use tonic::transport::{Certificate, ClientTlsConfig};
26use tonic::{
27 Request, Status,
28 metadata::{Ascii, MetadataValue},
29 service::{Interceptor, interceptor::InterceptedService},
30 transport::{Channel, Endpoint},
31};
32use tracing::{debug, error, info, warn};
33
34use crate::{
35 EnqueueRequest, Job, JobConversionError, JobCtx, ReserveOptions,
36 pb::sepp::v1::queue_service_client::QueueServiceClient,
37};
38
39const RESERVE_DEADLINE_SLACK: Duration = Duration::from_secs(10);
40const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(30);
41
42type AuthChannel = InterceptedService<Channel, ApiKeyInterceptor>;
43
44#[derive(Clone)]
50pub struct SeppClient {
51 inner: QueueServiceClient<AuthChannel>,
52 retry_policy: Arc<RetryPolicy>,
53 rpc_timeout: Duration,
54}
55
56const _: fn() = || {
57 fn assert_send_sync<T: Send + Sync>() {}
58 assert_send_sync::<SeppClient>();
59};
60
61#[derive(Debug, thiserror::Error)]
70#[non_exhaustive]
71pub enum ClientError {
72 #[error("could not connect to Sepp server at {addr}: {reason}")]
74 Connect { addr: String, reason: String },
75 #[error("the API key is not a valid HTTP header value")]
77 InvalidApiKey,
78 #[error("transport failure: {0}")]
83 Transport(String),
84 #[error("authentication failed: {0}")]
87 Unauthenticated(String),
88 #[error("server is overloaded: {0}")]
90 Overloaded(String),
91 #[error("invalid request: {0}")]
93 InvalidRequest(String),
94 #[error("server internal error: {0}")]
96 ServerInternal(String),
97 #[error("server returned unexpected status {code:?}: {message}")]
100 UnexpectedStatus { code: tonic::Code, message: String },
101 #[error("empty batch")]
103 EmptyBatch,
104 #[error("server returned {got} results for a batch of {expected} jobs")]
107 BatchResultCountMismatch { expected: usize, got: usize },
108 #[error("malformed response: {0}")]
110 MalformedResponse(&'static str),
111 #[error("server returned a malformed job: {0}")]
113 MalformedJob(#[from] JobConversionError),
114 #[error("server returned malformed server info: {0}")]
116 MalformedServerInfo(#[from] ServerInfoError),
117}
118
119impl From<tonic::Status> for ClientError {
120 fn from(s: tonic::Status) -> Self {
121 use tonic::Code;
122 let msg = s.message().to_string();
123 match s.code() {
124 Code::Unavailable | Code::DeadlineExceeded | Code::Aborted | Code::Cancelled => {
125 Self::Transport(msg)
126 }
127 Code::Unauthenticated | Code::PermissionDenied => Self::Unauthenticated(msg),
128 Code::ResourceExhausted => Self::Overloaded(msg),
129 Code::InvalidArgument => Self::InvalidRequest(msg),
130 Code::Internal | Code::DataLoss | Code::Unknown => Self::ServerInternal(msg),
131 code => Self::UnexpectedStatus { code, message: msg },
132 }
133 }
134}
135
136#[derive(Debug, thiserror::Error)]
142#[non_exhaustive]
143pub enum EnqueueError {
144 #[error("server rejected the job: {0}")]
146 Rejected(JobRejection),
147 #[error(transparent)]
149 Client(#[from] ClientError),
150}
151
152impl From<tonic::Status> for EnqueueError {
153 fn from(s: tonic::Status) -> Self {
154 Self::Client(s.into())
155 }
156}
157
158#[derive(Debug, thiserror::Error)]
168#[non_exhaustive]
169pub enum LeaseError {
170 #[error("no in-flight job with this id (already acked, expired, or never existed)")]
173 JobNotFound,
174 #[error("attempt mismatch: the lease was reassigned")]
177 AttemptMismatch,
178 #[error(transparent)]
180 Client(#[from] ClientError),
181}
182
183impl From<tonic::Status> for LeaseError {
184 fn from(s: tonic::Status) -> Self {
185 use tonic::Code;
186 match s.code() {
187 Code::NotFound => Self::JobNotFound,
188 Code::FailedPrecondition => Self::AttemptMismatch,
189 _ => Self::Client(s.into()),
190 }
191 }
192}
193
194#[derive(Debug, thiserror::Error)]
196#[non_exhaustive]
197pub enum ReserveError {
198 #[error("requested queues are not declared on the server: {0}")]
201 UnknownQueues(String),
202 #[error(transparent)]
204 Client(#[from] ClientError),
205}
206
207impl From<tonic::Status> for ReserveError {
208 fn from(s: tonic::Status) -> Self {
209 use tonic::Code;
210 match s.code() {
211 Code::FailedPrecondition => Self::UnknownQueues(s.message().to_string()),
212 _ => Self::Client(s.into()),
213 }
214 }
215}
216
217#[derive(Debug, Clone)]
223pub enum RetryDirective {
224 Default,
226 After(Duration),
228 DeadLetter,
230}
231
232#[derive(Debug, Clone)]
262pub struct RetryPolicy {
263 max_attempts: u32,
264 initial_backoff: Duration,
265 max_backoff: Duration,
266 multiplier: f64,
267 jitter: bool,
268}
269
270impl RetryPolicy {
271 pub fn with_max_attempts(mut self, n: u32) -> Self {
274 self.max_attempts = n.max(1);
275 self
276 }
277
278 pub fn with_initial_backoff(mut self, d: Duration) -> Self {
282 self.initial_backoff = d;
283 self
284 }
285
286 pub fn with_max_backoff(mut self, d: Duration) -> Self {
288 self.max_backoff = d;
289 self
290 }
291
292 pub fn with_multiplier(mut self, m: f64) -> Self {
295 self.multiplier = m.max(1.0);
296 self
297 }
298
299 pub fn without_jitter(mut self) -> Self {
302 self.jitter = false;
303 self
304 }
305
306 pub fn max_attempts(&self) -> u32 {
308 self.max_attempts
309 }
310}
311
312impl Default for RetryPolicy {
313 fn default() -> Self {
314 Self {
315 max_attempts: 1,
316 initial_backoff: Duration::from_millis(100),
317 max_backoff: Duration::from_secs(10),
318 multiplier: 2.0,
319 jitter: true,
320 }
321 }
322}
323
324impl SeppClient {
325 pub async fn connect(addr: impl Into<String>) -> Result<Self, ClientError> {
330 Self::builder(addr).connect().await
331 }
332
333 pub fn builder(addr: impl Into<String>) -> SeppClientBuilder {
336 SeppClientBuilder::new(addr)
337 }
338
339 pub fn from_channel(channel: Channel) -> Self {
345 Self {
346 inner: QueueServiceClient::with_interceptor(channel, ApiKeyInterceptor::disabled()),
347 retry_policy: Arc::new(RetryPolicy::default()),
348 rpc_timeout: DEFAULT_RPC_TIMEOUT,
349 }
350 }
351
352 fn unary_request<T>(&self, body: T) -> Request<T> {
356 let mut request = Request::new(body);
357 request.set_timeout(self.rpc_timeout);
358 inject_metadata(&mut request);
359 request
360 }
361
362 #[tracing::instrument(
363 name = "sepp-rs.enqueue",
364 skip_all,
365 fields(otel.kind = "client", otel.status_code = tracing::field::Empty, jobs)
366 )]
367 pub async fn enqueue_batch(
381 &self,
382 jobs: impl IntoIterator<Item = EnqueueRequest>,
383 ) -> Result<Vec<Result<EnqueueAck, JobRejection>>, ClientError> {
384 let jobs: Vec<pb::EnqueueRequest> = jobs.into_iter().map(Into::into).collect();
385 if jobs.is_empty() {
386 return Err(ClientError::EmptyBatch);
387 }
388 let sent = jobs.len();
389 tracing::Span::current().record("jobs", sent);
390
391 let all_keyed = all_jobs_keyed(&jobs);
392 let retryable = |status: &Status| is_transient_enqueue(status, all_keyed);
393 let response = with_retry_if(&self.retry_policy, "enqueue_batch", retryable, || {
394 let mut request = self.unary_request(pb::EnqueueBatchRequest { jobs: jobs.clone() });
395 inject_trace_context(&mut request);
396 let mut inner = self.inner.clone();
397 async move { inner.enqueue_batch(request).await.map(|r| r.into_inner()) }
398 })
399 .await?;
400
401 if response.results.len() != sent {
402 return Err(ClientError::BatchResultCountMismatch {
403 expected: sent,
404 got: response.results.len(),
405 });
406 }
407
408 let mut results = Vec::with_capacity(sent);
409 for job_req in response.results {
410 results.push(match job_req.outcome {
411 Some(pb::job_result::Outcome::Success(r)) => {
412 debug!(job_id = %r.job_id, deduplicated = r.deduplicated, "job enqueued successfully");
413 Ok(r.into())
414 }
415 Some(pb::job_result::Outcome::Rejection(r)) => {
416 let rejection: JobRejection = r.into();
417 debug!(error = %rejection, "server rejected the job");
418 Err(rejection)
419 }
420 None => {
421 return Err(ClientError::MalformedResponse(
422 "missing outcome in job result",
423 ));
424 }
425 });
426 }
427
428 Ok(results)
429 }
430
431 pub async fn enqueue(&self, job: EnqueueRequest) -> Result<EnqueueAck, EnqueueError> {
439 let mut results = self.enqueue_batch(std::iter::once(job)).await?.into_iter();
440
441 match results.next() {
442 Some(Ok(ack)) => Ok(ack),
443 Some(Err(rej)) => Err(EnqueueError::Rejected(rej)),
444 None => Err(EnqueueError::Client(ClientError::MalformedResponse(
445 "empty results for single-job batch",
446 ))),
447 }
448 }
449
450 #[tracing::instrument(
451 name = "sepp-rs.enqueue_atomic",
452 skip_all,
453 fields(otel.kind = "client", otel.status_code = tracing::field::Empty, jobs)
454 )]
455 pub async fn enqueue_atomic(
468 &self,
469 jobs: impl IntoIterator<Item = EnqueueRequest>,
470 ) -> Result<Vec<EnqueueAck>, crate::AtomicEnqueueError> {
471 let jobs: Vec<pb::EnqueueRequest> = jobs.into_iter().map(Into::into).collect();
472 if jobs.is_empty() {
473 return Err(ClientError::EmptyBatch.into());
474 }
475 let sent = jobs.len();
476 tracing::Span::current().record("jobs", sent);
477
478 let all_keyed = all_jobs_keyed(&jobs);
479 let retryable = |status: &Status| is_transient_enqueue(status, all_keyed);
480 let response = with_retry_if(&self.retry_policy, "enqueue_atomic", retryable, || {
481 let mut request = self.unary_request(pb::EnqueueBatchRequest { jobs: jobs.clone() });
482 inject_trace_context(&mut request);
483 let mut inner = self.inner.clone();
484 async move { inner.enqueue_atomic(request).await.map(|r| r.into_inner()) }
485 })
486 .await?;
487
488 use pb::enqueue_atomic_response::Outcome;
489 match response.outcome {
490 Some(Outcome::Success(s)) => {
491 if s.responses.len() != sent {
492 return Err(ClientError::BatchResultCountMismatch {
493 expected: sent,
494 got: s.responses.len(),
495 }
496 .into());
497 }
498 Ok(s.responses.into_iter().map(Into::into).collect())
499 }
500 Some(Outcome::Rejection(r)) => {
501 let errors: Vec<crate::JobValidationError> =
502 r.errors.into_iter().map(Into::into).collect();
503 for e in &errors {
504 debug!(index = e.index, error = %e.rejection, "atomic batch rejected job");
505 }
506 Err(crate::AtomicEnqueueError::Validation(errors))
507 }
508 None => Err(
509 ClientError::MalformedResponse("missing outcome in EnqueueAtomicResponse").into(),
510 ),
511 }
512 }
513
514 #[tracing::instrument(
515 name = "sepp-rs.reserve",
516 skip_all,
517 fields(
518 otel.kind = "client",
519 otel.status_code = tracing::field::Empty,
520 jobs,
521 worker_id = opts.worker_id.as_deref().unwrap_or("<none>"),
522 )
523 )]
524 pub async fn reserve(&self, opts: &ReserveOptions) -> Result<Option<Vec<Job>>, ReserveError> {
538 let msg = pb::ReserveRequest::from(opts);
539 let mut request = Request::new(msg);
540 request.set_timeout(opts.wait_timeout() + RESERVE_DEADLINE_SLACK);
541 inject_metadata(&mut request);
542
543 let response = match self.inner.clone().reserve(request).await {
544 Ok(response) => response.into_inner(),
545 Err(status) => {
546 tracing::Span::current().record("otel.status_code", "error");
547 return Err(status.into());
548 }
549 };
550
551 let mut jobs = Vec::with_capacity(response.jobs.len());
553 for job in response.jobs {
554 match crate::job_from_pb(self, job, opts.worker_id.as_deref()) {
555 Ok(job) => jobs.push(job),
556 Err(e) => warn!(error = %e, "skipping malformed job in reserve response"),
557 }
558 }
559
560 if jobs.is_empty() {
561 tracing::Span::current().record("jobs", 0);
562 return Ok(None);
563 }
564
565 tracing::Span::current().record("jobs", jobs.len());
566 Ok(Some(jobs))
567 }
568
569 #[tracing::instrument(
570 name = "sepp-rs.ack",
571 skip_all,
572 fields(
573 otel.kind = "client",
574 otel.status_code = tracing::field::Empty,
575 job_id = %ctx.id,
576 attempt = ctx.attempt,
577 worker_id = ctx.lease.worker_id.as_deref().unwrap_or("<none>"),
578 )
579 )]
580 pub async fn ack(&self, ctx: &JobCtx) -> Result<(), LeaseError> {
587 let body = pb::AckRequest {
588 job_id: ctx.id.clone(),
589 attempt: ctx.attempt,
590 worker_id: ctx.lease.worker_id.clone(),
591 };
592 with_retry(&self.retry_policy, "ack", || {
593 let request = self.unary_request(body.clone());
594 let mut inner = self.inner.clone();
595 async move { inner.ack(request).await.map(|_| ()) }
596 })
597 .await?;
598 Ok(())
599 }
600
601 #[tracing::instrument(
602 name = "sepp-rs.nack",
603 skip_all,
604 fields(
605 otel.kind = "client",
606 otel.status_code = tracing::field::Empty,
607 job_id = %ctx.id,
608 attempt = ctx.attempt,
609 worker_id = ctx.lease.worker_id.as_deref().unwrap_or("<none>"),
610 )
611 )]
612 pub async fn nack(
621 &self,
622 ctx: &JobCtx,
623 retry: RetryDirective,
624 reason: impl Into<String>,
625 ) -> Result<bool, LeaseError> {
626 let body = nack_request(ctx, retry, reason.into());
627
628 let response = with_retry(&self.retry_policy, "nack", || {
629 let request = self.unary_request(body.clone());
630 let mut inner = self.inner.clone();
631 async move { inner.nack(request).await.map(|r| r.into_inner()) }
632 })
633 .await?;
634 Ok(response.dead_lettered)
635 }
636
637 pub async fn extend(
645 &self,
646 ctx: &JobCtx,
647 extension: Duration,
648 ) -> Result<SystemTime, LeaseError> {
649 self.extend_inner(
650 &ctx.id,
651 ctx.attempt,
652 extension,
653 ctx.lease.worker_id.as_deref(),
654 )
655 .await
656 }
657
658 #[tracing::instrument(
659 name = "sepp-rs.extend",
660 skip_all,
661 fields(
662 otel.kind = "client",
663 otel.status_code = tracing::field::Empty,
664 job_id = %job_id,
665 attempt,
666 worker_id = worker_id.unwrap_or("<none>"),
667 )
668 )]
669 pub(crate) async fn extend_inner(
670 &self,
671 job_id: &str,
672 attempt: u32,
673 extension: Duration,
674 worker_id: Option<&str>,
675 ) -> Result<SystemTime, LeaseError> {
676 let body = pb::ExtendRequest {
677 job_id: job_id.to_string(),
678 attempt,
679 lease_duration: Some(crate::duration_to_proto(extension)),
680 worker_id: worker_id.map(String::from),
681 };
682
683 let response = with_retry(&self.retry_policy, "extend", || {
684 let request = self.unary_request(body.clone());
685 let mut inner = self.inner.clone();
686 async move { inner.extend(request).await.map(|r| r.into_inner()) }
687 })
688 .await?;
689 crate::timestamp_to_system_time(response.lease_expires_at).ok_or_else(|| {
690 ClientError::MalformedResponse("extend returned an invalid lease_expires_at").into()
691 })
692 }
693
694 #[tracing::instrument(
695 name = "sepp-rs.get_server_info",
696 skip_all,
697 fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
698 )]
699 pub async fn get_server_info(&self) -> Result<ServerInfo, ClientError> {
704 let response = with_retry(&self.retry_policy, "get_server_info", || {
705 let request = self.unary_request(pb::GetServerInfoRequest {});
706 let mut inner = self.inner.clone();
707 async move { inner.get_server_info(request).await.map(|r| r.into_inner()) }
708 })
709 .await?;
710
711 Ok(ServerInfo::try_from(response)?)
712 }
713
714 #[tracing::instrument(
715 name = "sepp-rs.drain_dead_letters",
716 skip_all,
717 fields(
718 otel.kind = "client",
719 otel.status_code = tracing::field::Empty,
720 queue = queue.unwrap_or("<all>"),
721 drained = tracing::field::Empty,
722 )
723 )]
724 pub async fn drain_dead_letters(
738 &self,
739 queue: Option<&str>,
740 max: u32,
741 ) -> Result<Vec<DeadLetterRecord>, ClientError> {
742 if max == 0 {
743 return Ok(Vec::new());
746 }
747 let request = self.unary_request(pb::DrainDeadLettersRequest {
748 queue: queue.map(String::from),
749 max: Some(max),
750 });
751
752 let response = match self.inner.clone().drain_dead_letters(request).await {
753 Ok(response) => response.into_inner(),
754 Err(status) => {
755 tracing::Span::current().record("otel.status_code", "error");
756 return Err(status.into());
757 }
758 };
759
760 let mut records = Vec::with_capacity(response.records.len());
761 for record in response.records {
762 match crate::dead_letter_record_from_pb(record) {
763 Ok(r) => records.push(r),
764 Err(e) => {
765 warn!(error = %e, "skipping malformed dead-letter record in drain response")
766 }
767 }
768 }
769
770 tracing::Span::current().record("drained", records.len());
771 Ok(records)
772 }
773}
774
775#[derive(Clone)]
776pub(crate) struct Lease {
777 client: SeppClient,
778 job_id: String,
779 attempt: u32,
780 expiry: Arc<AtomicI64>,
781 worker_id: Option<String>,
782}
783
784impl Lease {
785 pub(crate) fn new(
786 client: SeppClient,
787 job_id: String,
788 attempt: u32,
789 lease_expires_at: SystemTime,
790 worker_id: Option<String>,
791 ) -> Self {
792 Self {
793 client,
794 job_id,
795 attempt,
796 expiry: Arc::new(AtomicI64::new(crate::system_time_to_millis(
797 lease_expires_at,
798 ))),
799 worker_id,
800 }
801 }
802
803 pub(crate) fn known_expiry_ms(&self) -> i64 {
804 self.expiry.load(Ordering::Acquire)
805 }
806
807 pub(crate) async fn extend(&self, by: Duration) -> Result<SystemTime, LeaseError> {
808 let new_expiry = self
809 .client
810 .extend_inner(&self.job_id, self.attempt, by, self.worker_id.as_deref())
811 .await?;
812 self.expiry
813 .store(crate::system_time_to_millis(new_expiry), Ordering::Release);
814 Ok(new_expiry)
815 }
816}
817
818impl std::fmt::Debug for Lease {
819 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820 f.debug_struct("Lease")
821 .field("job_id", &self.job_id)
822 .field("attempt", &self.attempt)
823 .field("known_expiry_ms", &self.known_expiry_ms())
824 .finish()
825 }
826}
827
828pub struct SeppClientBuilder {
848 addr: String,
849 api_key: Option<String>,
850 retry_policy: RetryPolicy,
851 rpc_timeout: Duration,
852 max_receive_message_bytes: Option<usize>,
853 #[cfg(feature = "tls")]
854 tls: Option<ClientTlsConfig>,
855}
856
857impl SeppClientBuilder {
858 fn new(addr: impl Into<String>) -> Self {
859 Self {
860 addr: addr.into(),
861 api_key: None,
862 retry_policy: RetryPolicy::default(),
863 rpc_timeout: DEFAULT_RPC_TIMEOUT,
864 max_receive_message_bytes: None,
865 #[cfg(feature = "tls")]
866 tls: None,
867 }
868 }
869
870 pub fn api_key(mut self, key: impl Into<String>) -> Self {
875 self.api_key = Some(key.into());
876 self
877 }
878
879 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
882 self.retry_policy = policy;
883 self
884 }
885
886 pub fn rpc_timeout(mut self, timeout: Duration) -> Self {
895 self.rpc_timeout = timeout;
896 self
897 }
898
899 pub fn max_receive_message_bytes(mut self, bytes: usize) -> Self {
907 self.max_receive_message_bytes = Some(bytes);
908 self
909 }
910
911 #[cfg(feature = "tls")]
915 pub fn tls(mut self) -> Self {
916 self.tls = Some(self.tls.unwrap_or_default().with_native_roots());
917 self
918 }
919
920 #[cfg(feature = "tls")]
925 pub fn tls_ca_certificate(mut self, pem: impl AsRef<[u8]>) -> Self {
926 let config = self.tls.unwrap_or_default();
927 self.tls = Some(config.ca_certificate(Certificate::from_pem(pem)));
928 self
929 }
930
931 #[cfg(feature = "tls")]
936 pub fn tls_domain(mut self, domain: impl Into<String>) -> Self {
937 let config = self.tls.unwrap_or_default();
938 self.tls = Some(config.domain_name(domain));
939 self
940 }
941
942 #[cfg(feature = "tls")]
947 pub fn tls_config(mut self, config: ClientTlsConfig) -> Self {
948 self.tls = Some(config);
949 self
950 }
951
952 pub async fn connect(self) -> Result<SeppClient, ClientError> {
955 let addr = self.addr;
956 let interceptor =
957 ApiKeyInterceptor::new(self.api_key.as_deref()).ok_or(ClientError::InvalidApiKey)?;
958
959 #[cfg(feature = "tls")]
960 let tls = self.tls;
961 #[cfg(feature = "tls")]
962 let tls_enabled = tls.is_some();
963 #[cfg(not(feature = "tls"))]
964 let tls_enabled = false;
965
966 if interceptor.is_enabled() && !tls_enabled {
967 warn!(
968 "API key configured without TLS; it will be sent over the connection in plaintext"
969 );
970 }
971
972 let channel = async {
973 #[allow(unused_mut)]
974 let mut endpoint = Endpoint::from_shared(addr.clone())?
975 .connect_timeout(Duration::from_secs(5))
976 .user_agent(concat!("sepp-rs/", env!("CARGO_PKG_VERSION")))?
977 .http2_keep_alive_interval(Duration::from_secs(30))
980 .keep_alive_timeout(Duration::from_secs(10))
981 .keep_alive_while_idle(true);
982 #[cfg(feature = "tls")]
983 if let Some(tls) = tls {
984 endpoint = endpoint.tls_config(tls)?;
985 }
986 endpoint.connect().await
987 }
988 .await
989 .map_err(|e| {
990 error!(%addr, error = %e, "failed to connect to Sepp server");
991
992 ClientError::Connect {
993 addr: addr.clone(),
994 reason: root_cause(&e),
995 }
996 })?;
997
998 info!(
999 %addr,
1000 tls = tls_enabled,
1001 auth = interceptor.is_enabled(),
1002 "connected to Sepp server",
1003 );
1004
1005 let mut inner = QueueServiceClient::with_interceptor(channel, interceptor);
1006 if let Some(bytes) = self.max_receive_message_bytes {
1007 inner = inner.max_decoding_message_size(bytes);
1008 }
1009
1010 Ok(SeppClient {
1011 inner,
1012 retry_policy: Arc::new(self.retry_policy),
1013 rpc_timeout: self.rpc_timeout,
1014 })
1015 }
1016}
1017
1018#[derive(Clone)]
1023pub struct ApiKeyInterceptor {
1024 bearer: Option<MetadataValue<Ascii>>,
1026}
1027
1028impl ApiKeyInterceptor {
1029 fn new(api_key: Option<&str>) -> Option<Self> {
1031 let bearer = match api_key {
1032 Some(key) => Some(format!("Bearer {key}").parse().ok()?),
1033 None => None,
1034 };
1035 Some(Self { bearer })
1036 }
1037
1038 fn disabled() -> Self {
1039 Self { bearer: None }
1040 }
1041
1042 fn is_enabled(&self) -> bool {
1043 self.bearer.is_some()
1044 }
1045}
1046
1047impl Interceptor for ApiKeyInterceptor {
1048 fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
1049 if let Some(bearer) = &self.bearer {
1050 request
1051 .metadata_mut()
1052 .insert("authorization", bearer.clone());
1053 }
1054 Ok(request)
1055 }
1056}
1057
1058fn root_cause(err: &(dyn std::error::Error + 'static)) -> String {
1059 let mut current = err;
1060 while let Some(source) = current.source() {
1061 current = source;
1062 }
1063 current.to_string()
1064}
1065
1066fn is_transient(status: &Status) -> bool {
1068 use tonic::Code;
1069 matches!(
1070 status.code(),
1071 Code::Unavailable | Code::DeadlineExceeded | Code::Aborted | Code::ResourceExhausted
1072 )
1073}
1074
1075fn is_transient_enqueue(status: &Status, all_jobs_keyed: bool) -> bool {
1084 use tonic::Code;
1085 if all_jobs_keyed {
1086 return is_transient(status);
1087 }
1088 matches!(status.code(), Code::Unavailable | Code::ResourceExhausted)
1089}
1090
1091fn all_jobs_keyed(jobs: &[pb::EnqueueRequest]) -> bool {
1094 jobs.iter()
1095 .all(|j| j.idempotency_key.as_deref().is_some_and(|k| !k.is_empty()))
1096}
1097
1098fn nack_request(ctx: &JobCtx, retry: RetryDirective, reason: String) -> pb::NackRequest {
1100 let strategy = match retry {
1101 RetryDirective::Default => pb::nack_retry::Strategy::Default(()),
1102 RetryDirective::After(d) => pb::nack_retry::Strategy::Delay(crate::duration_to_proto(d)),
1103 RetryDirective::DeadLetter => pb::nack_retry::Strategy::DeadLetter(()),
1104 };
1105 pb::NackRequest {
1106 job_id: ctx.id.clone(),
1107 attempt: ctx.attempt,
1108 reason: (!reason.is_empty()).then_some(reason),
1111 retry: Some(pb::NackRetry {
1112 strategy: Some(strategy),
1113 }),
1114 worker_id: ctx.lease.worker_id.clone(),
1115 }
1116}
1117
1118fn jitter_factor() -> f64 {
1120 let nanos = SystemTime::now()
1121 .duration_since(SystemTime::UNIX_EPOCH)
1122 .map(|d| d.subsec_nanos())
1123 .unwrap_or(0);
1124 let mut x = nanos as u64;
1125 x ^= x >> 17;
1126 x = x.wrapping_mul(0xed5ad4bb);
1127 x ^= x >> 11;
1128 0.5 + 0.5 * ((x & 0xffff) as f64 / 65536.0)
1129}
1130
1131async fn with_retry<F, Fut, T>(
1134 policy: &RetryPolicy,
1135 operation: &'static str,
1136 f: F,
1137) -> Result<T, Status>
1138where
1139 F: FnMut() -> Fut,
1140 Fut: Future<Output = Result<T, Status>>,
1141{
1142 with_retry_if(policy, operation, is_transient, f).await
1143}
1144
1145async fn with_retry_if<F, Fut, T, P>(
1148 policy: &RetryPolicy,
1149 operation: &'static str,
1150 retryable: P,
1151 mut f: F,
1152) -> Result<T, Status>
1153where
1154 F: FnMut() -> Fut,
1155 Fut: Future<Output = Result<T, Status>>,
1156 P: Fn(&Status) -> bool,
1157{
1158 let mut attempt: u32 = 1;
1159 let mut backoff = policy.initial_backoff;
1160 loop {
1161 match f().await {
1162 Ok(v) => return Ok(v),
1163 Err(status) if attempt >= policy.max_attempts || !retryable(&status) => {
1164 tracing::Span::current().record("otel.status_code", "error");
1165 return Err(status);
1166 }
1167 Err(status) => {
1168 let delay = if policy.jitter {
1169 Duration::from_secs_f64(backoff.as_secs_f64() * jitter_factor())
1170 } else {
1171 backoff
1172 };
1173 warn!(
1174 operation,
1175 attempt,
1176 delay_ms = delay.as_millis() as u64,
1177 code = ?status.code(),
1178 message = %status.message(),
1179 "retrying after transient error"
1180 );
1181 tokio::time::sleep(delay).await;
1182 backoff = Duration::from_secs_f64(
1183 (backoff.as_secs_f64() * policy.multiplier)
1184 .min(policy.max_backoff.as_secs_f64()),
1185 );
1186 attempt += 1;
1187 }
1188 }
1189 }
1190}
1191
1192fn inject_metadata<T>(request: &mut Request<T>) {
1193 #[cfg(feature = "opentelemetry")]
1194 {
1195 use tracing_opentelemetry::OpenTelemetrySpanExt;
1196
1197 let cx = tracing::Span::current().context();
1198 if let Some(tc) = crate::inject_pb_trace_context(&cx) {
1199 if let Ok(value) = tc.traceparent.parse() {
1200 request.metadata_mut().insert("traceparent", value);
1201 }
1202 if let Some(value) = tc.tracestate.as_deref().and_then(|s| s.parse().ok()) {
1203 request.metadata_mut().insert("tracestate", value);
1204 }
1205 }
1206 }
1207 #[cfg(not(feature = "opentelemetry"))]
1208 let _ = request;
1209}
1210
1211fn inject_trace_context(request: &mut Request<pb::EnqueueBatchRequest>) {
1212 #[cfg(feature = "opentelemetry")]
1213 {
1214 use tracing_opentelemetry::OpenTelemetrySpanExt;
1215
1216 let cx = tracing::Span::current().context();
1217 if let Some(tc) = crate::inject_pb_trace_context(&cx) {
1218 for job in &mut request.get_mut().jobs {
1219 job.trace_context.get_or_insert_with(|| tc.clone());
1220 }
1221 }
1222 }
1223 #[cfg(not(feature = "opentelemetry"))]
1224 let _ = request;
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use tonic::Code;
1231
1232 fn st(code: Code, msg: &str) -> tonic::Status {
1233 tonic::Status::new(code, msg)
1234 }
1235
1236 #[test]
1237 fn client_err_unavailable_is_transport() {
1238 assert!(matches!(
1239 ClientError::from(st(Code::Unavailable, "x")),
1240 ClientError::Transport(_)
1241 ));
1242 }
1243
1244 #[test]
1245 fn client_err_deadline_is_transport() {
1246 assert!(matches!(
1247 ClientError::from(st(Code::DeadlineExceeded, "x")),
1248 ClientError::Transport(_)
1249 ));
1250 }
1251
1252 #[test]
1253 fn client_err_aborted_is_transport() {
1254 assert!(matches!(
1255 ClientError::from(st(Code::Aborted, "x")),
1256 ClientError::Transport(_)
1257 ));
1258 }
1259
1260 #[test]
1261 fn client_err_cancelled_is_transport() {
1262 assert!(matches!(
1263 ClientError::from(st(Code::Cancelled, "x")),
1264 ClientError::Transport(_)
1265 ));
1266 }
1267
1268 #[test]
1269 fn client_err_unauthenticated() {
1270 assert!(matches!(
1271 ClientError::from(st(Code::Unauthenticated, "x")),
1272 ClientError::Unauthenticated(_)
1273 ));
1274 }
1275
1276 #[test]
1277 fn client_err_permission_denied_is_unauthenticated() {
1278 assert!(matches!(
1279 ClientError::from(st(Code::PermissionDenied, "x")),
1280 ClientError::Unauthenticated(_)
1281 ));
1282 }
1283
1284 #[test]
1285 fn client_err_resource_exhausted_is_overloaded() {
1286 assert!(matches!(
1287 ClientError::from(st(Code::ResourceExhausted, "x")),
1288 ClientError::Overloaded(_)
1289 ));
1290 }
1291
1292 #[test]
1293 fn client_err_invalid_argument_is_invalid_request() {
1294 assert!(matches!(
1295 ClientError::from(st(Code::InvalidArgument, "x")),
1296 ClientError::InvalidRequest(_)
1297 ));
1298 }
1299
1300 #[test]
1301 fn client_err_internal_is_server_internal() {
1302 assert!(matches!(
1303 ClientError::from(st(Code::Internal, "x")),
1304 ClientError::ServerInternal(_)
1305 ));
1306 }
1307
1308 #[test]
1309 fn client_err_data_loss_is_server_internal() {
1310 assert!(matches!(
1311 ClientError::from(st(Code::DataLoss, "x")),
1312 ClientError::ServerInternal(_)
1313 ));
1314 }
1315
1316 #[test]
1317 fn client_err_unknown_is_server_internal() {
1318 assert!(matches!(
1319 ClientError::from(st(Code::Unknown, "x")),
1320 ClientError::ServerInternal(_)
1321 ));
1322 }
1323
1324 #[test]
1325 fn client_err_other_is_unexpected_status() {
1326 match ClientError::from(st(Code::NotFound, "x")) {
1327 ClientError::UnexpectedStatus { code, .. } => assert_eq!(code, Code::NotFound),
1328 _ => panic!("wrong variant"),
1329 }
1330 }
1331
1332 #[test]
1333 fn client_err_preserves_message() {
1334 match ClientError::from(st(Code::Internal, "boom")) {
1335 ClientError::ServerInternal(m) => assert_eq!(m, "boom"),
1336 _ => panic!("wrong variant"),
1337 }
1338 }
1339
1340 #[test]
1341 fn enqueue_err_wraps_client() {
1342 assert!(matches!(
1343 EnqueueError::from(st(Code::Unavailable, "x")),
1344 EnqueueError::Client(ClientError::Transport(_))
1345 ));
1346 }
1347
1348 #[test]
1349 fn lease_err_not_found() {
1350 assert!(matches!(
1351 LeaseError::from(st(Code::NotFound, "x")),
1352 LeaseError::JobNotFound
1353 ));
1354 }
1355
1356 #[test]
1357 fn lease_err_failed_precondition_is_attempt_mismatch() {
1358 assert!(matches!(
1359 LeaseError::from(st(Code::FailedPrecondition, "x")),
1360 LeaseError::AttemptMismatch
1361 ));
1362 }
1363
1364 #[test]
1365 fn lease_err_other_wraps_client() {
1366 assert!(matches!(
1367 LeaseError::from(st(Code::Unavailable, "x")),
1368 LeaseError::Client(ClientError::Transport(_))
1369 ));
1370 }
1371
1372 #[test]
1373 fn reserve_err_failed_precondition_is_unknown_queues() {
1374 match ReserveError::from(st(Code::FailedPrecondition, "queues: a, b")) {
1375 ReserveError::UnknownQueues(m) => assert_eq!(m, "queues: a, b"),
1376 _ => panic!("wrong variant"),
1377 }
1378 }
1379
1380 #[test]
1381 fn reserve_err_other_wraps_client() {
1382 assert!(matches!(
1383 ReserveError::from(st(Code::Unavailable, "x")),
1384 ReserveError::Client(ClientError::Transport(_))
1385 ));
1386 }
1387
1388 #[test]
1389 fn atomic_enqueue_err_wraps_client() {
1390 assert!(matches!(
1391 crate::AtomicEnqueueError::from(st(Code::Unavailable, "x")),
1392 crate::AtomicEnqueueError::Client(ClientError::Transport(_))
1393 ));
1394 }
1395
1396 #[test]
1397 fn api_key_interceptor_rejects_invalid_header_value() {
1398 assert!(ApiKeyInterceptor::new(Some("bad\nkey")).is_none());
1400 }
1401
1402 #[test]
1403 fn api_key_interceptor_none_disables_auth() {
1404 let interceptor = ApiKeyInterceptor::new(None).unwrap();
1405 assert!(!interceptor.is_enabled());
1406 }
1407
1408 #[test]
1409 fn api_key_interceptor_some_enables_auth() {
1410 let interceptor = ApiKeyInterceptor::new(Some("token")).unwrap();
1411 assert!(interceptor.is_enabled());
1412 }
1413
1414 #[test]
1415 fn api_key_interceptor_injects_bearer_header() {
1416 let mut interceptor = ApiKeyInterceptor::new(Some("token")).unwrap();
1417 let req = interceptor.call(Request::new(())).unwrap();
1418 let auth = req.metadata().get("authorization").unwrap();
1419 assert_eq!(auth.to_str().unwrap(), "Bearer token");
1420 }
1421
1422 #[test]
1423 fn api_key_interceptor_disabled_leaves_metadata_empty() {
1424 let mut interceptor = ApiKeyInterceptor::disabled();
1425 let req = interceptor.call(Request::new(())).unwrap();
1426 assert!(req.metadata().get("authorization").is_none());
1427 }
1428
1429 #[test]
1430 fn is_transient_classifies_codes() {
1431 assert!(is_transient(&st(Code::Unavailable, "")));
1432 assert!(is_transient(&st(Code::DeadlineExceeded, "")));
1433 assert!(is_transient(&st(Code::Aborted, "")));
1434 assert!(is_transient(&st(Code::ResourceExhausted, "")));
1435
1436 assert!(!is_transient(&st(Code::Cancelled, "")));
1437 assert!(!is_transient(&st(Code::InvalidArgument, "")));
1438 assert!(!is_transient(&st(Code::NotFound, "")));
1439 assert!(!is_transient(&st(Code::FailedPrecondition, "")));
1440 assert!(!is_transient(&st(Code::Unauthenticated, "")));
1441 assert!(!is_transient(&st(Code::PermissionDenied, "")));
1442 assert!(!is_transient(&st(Code::Internal, "")));
1443 assert!(!is_transient(&st(Code::DataLoss, "")));
1444 assert!(!is_transient(&st(Code::Unknown, "")));
1445 }
1446
1447 #[test]
1448 fn is_transient_enqueue_all_keyed_matches_is_transient() {
1449 for code in [
1450 Code::Unavailable,
1451 Code::DeadlineExceeded,
1452 Code::Aborted,
1453 Code::ResourceExhausted,
1454 ] {
1455 assert!(is_transient_enqueue(&st(code, ""), true));
1456 }
1457 assert!(!is_transient_enqueue(&st(Code::Cancelled, ""), true));
1458 assert!(!is_transient_enqueue(&st(Code::Internal, ""), true));
1459 }
1460
1461 #[test]
1462 fn is_transient_enqueue_unkeyed_excludes_ambiguous_commit_codes() {
1463 assert!(!is_transient_enqueue(
1466 &st(Code::DeadlineExceeded, ""),
1467 false
1468 ));
1469 assert!(!is_transient_enqueue(&st(Code::Aborted, ""), false));
1470
1471 assert!(is_transient_enqueue(&st(Code::Unavailable, ""), false));
1473 assert!(is_transient_enqueue(
1474 &st(Code::ResourceExhausted, ""),
1475 false
1476 ));
1477 }
1478
1479 fn keyed_job(key: Option<&str>) -> pb::EnqueueRequest {
1480 pb::EnqueueRequest {
1481 idempotency_key: key.map(String::from),
1482 ..crate::EnqueueRequest::new("q", "t").unwrap().into()
1483 }
1484 }
1485
1486 #[test]
1487 fn all_jobs_keyed_requires_a_non_empty_key_on_every_job() {
1488 assert!(all_jobs_keyed(&[
1489 keyed_job(Some("a")),
1490 keyed_job(Some("b"))
1491 ]));
1492 assert!(!all_jobs_keyed(&[keyed_job(Some("a")), keyed_job(None)]));
1493 assert!(!all_jobs_keyed(&[keyed_job(Some(""))]));
1495 }
1496
1497 async fn run_enqueue_retry(all_keyed: bool, code: Code) -> u32 {
1499 use std::sync::atomic::{AtomicU32, Ordering};
1500 let calls = Arc::new(AtomicU32::new(0));
1501 let calls2 = calls.clone();
1502 let result: Result<(), Status> = with_retry_if(
1503 &fast_policy(3),
1504 "test",
1505 |status| is_transient_enqueue(status, all_keyed),
1506 move || {
1507 let calls = calls2.clone();
1508 async move {
1509 calls.fetch_add(1, Ordering::SeqCst);
1510 Err(st(code, "boom"))
1511 }
1512 },
1513 )
1514 .await;
1515 assert!(result.is_err());
1516 calls.load(Ordering::SeqCst)
1517 }
1518
1519 #[tokio::test]
1520 async fn keyless_enqueue_deadline_exceeded_is_not_retried() {
1521 assert_eq!(run_enqueue_retry(false, Code::DeadlineExceeded).await, 1);
1522 }
1523
1524 #[tokio::test]
1525 async fn keyless_enqueue_aborted_is_not_retried() {
1526 assert_eq!(run_enqueue_retry(false, Code::Aborted).await, 1);
1527 }
1528
1529 #[tokio::test]
1530 async fn keyed_enqueue_deadline_exceeded_is_retried() {
1531 assert_eq!(run_enqueue_retry(true, Code::DeadlineExceeded).await, 3);
1532 }
1533
1534 #[tokio::test]
1535 async fn keyless_enqueue_unavailable_is_retried() {
1536 assert_eq!(run_enqueue_retry(false, Code::Unavailable).await, 3);
1537 }
1538
1539 #[test]
1540 fn jitter_factor_in_range() {
1541 for _ in 0..256 {
1542 let f = jitter_factor();
1543 assert!((0.5..1.0).contains(&f), "jitter factor out of range: {f}");
1544 }
1545 }
1546
1547 fn fast_policy(max_attempts: u32) -> RetryPolicy {
1548 RetryPolicy::default()
1549 .with_max_attempts(max_attempts)
1550 .with_initial_backoff(Duration::from_millis(1))
1551 .with_max_backoff(Duration::from_millis(1))
1552 .without_jitter()
1553 }
1554
1555 #[tokio::test]
1556 async fn with_retry_returns_immediately_on_success() {
1557 use std::sync::atomic::{AtomicU32, Ordering};
1558 let calls = Arc::new(AtomicU32::new(0));
1559 let calls2 = calls.clone();
1560 let result: Result<u32, Status> = with_retry(&fast_policy(5), "test", move || {
1561 let calls = calls2.clone();
1562 async move {
1563 calls.fetch_add(1, Ordering::SeqCst);
1564 Ok(42)
1565 }
1566 })
1567 .await;
1568 assert_eq!(result.unwrap(), 42);
1569 assert_eq!(calls.load(Ordering::SeqCst), 1);
1570 }
1571
1572 #[tokio::test]
1573 async fn with_retry_succeeds_after_transient_failures() {
1574 use std::sync::atomic::{AtomicU32, Ordering};
1575 let calls = Arc::new(AtomicU32::new(0));
1576 let calls2 = calls.clone();
1577 let result: Result<u32, Status> = with_retry(&fast_policy(5), "test", move || {
1578 let calls = calls2.clone();
1579 async move {
1580 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
1581 if n < 3 {
1582 Err(st(Code::Unavailable, "blip"))
1583 } else {
1584 Ok(7)
1585 }
1586 }
1587 })
1588 .await;
1589 assert_eq!(result.unwrap(), 7);
1590 assert_eq!(calls.load(Ordering::SeqCst), 3);
1591 }
1592
1593 #[tokio::test]
1594 async fn with_retry_gives_up_after_max_attempts() {
1595 use std::sync::atomic::{AtomicU32, Ordering};
1596 let calls = Arc::new(AtomicU32::new(0));
1597 let calls2 = calls.clone();
1598 let result: Result<(), Status> = with_retry(&fast_policy(3), "test", move || {
1599 let calls = calls2.clone();
1600 async move {
1601 calls.fetch_add(1, Ordering::SeqCst);
1602 Err(st(Code::Unavailable, "still down"))
1603 }
1604 })
1605 .await;
1606 assert_eq!(result.unwrap_err().code(), Code::Unavailable);
1607 assert_eq!(calls.load(Ordering::SeqCst), 3);
1608 }
1609
1610 #[tokio::test]
1611 async fn with_retry_does_not_retry_non_transient_errors() {
1612 use std::sync::atomic::{AtomicU32, Ordering};
1613 let calls = Arc::new(AtomicU32::new(0));
1614 let calls2 = calls.clone();
1615 let result: Result<(), Status> = with_retry(&fast_policy(5), "test", move || {
1616 let calls = calls2.clone();
1617 async move {
1618 calls.fetch_add(1, Ordering::SeqCst);
1619 Err(st(Code::InvalidArgument, "nope"))
1620 }
1621 })
1622 .await;
1623 assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
1624 assert_eq!(calls.load(Ordering::SeqCst), 1);
1625 }
1626
1627 #[tokio::test]
1628 async fn with_retry_default_policy_runs_once() {
1629 use std::sync::atomic::{AtomicU32, Ordering};
1630 let calls = Arc::new(AtomicU32::new(0));
1631 let calls2 = calls.clone();
1632 let result: Result<(), Status> = with_retry(&RetryPolicy::default(), "test", move || {
1633 let calls = calls2.clone();
1634 async move {
1635 calls.fetch_add(1, Ordering::SeqCst);
1636 Err(st(Code::Unavailable, "transient"))
1637 }
1638 })
1639 .await;
1640 assert!(result.is_err());
1641 assert_eq!(calls.load(Ordering::SeqCst), 1);
1642 }
1643
1644 #[test]
1645 fn retry_policy_max_attempts_clamps_to_one() {
1646 let p = RetryPolicy::default().with_max_attempts(0);
1647 assert_eq!(p.max_attempts(), 1);
1648 }
1649
1650 #[test]
1651 fn retry_policy_default_is_no_retry() {
1652 assert_eq!(RetryPolicy::default().max_attempts(), 1);
1653 }
1654
1655 #[test]
1656 fn builder_defaults_rpc_timeout_and_message_size() {
1657 let b = SeppClient::builder("http://localhost:1");
1658 assert_eq!(b.rpc_timeout, DEFAULT_RPC_TIMEOUT);
1659 assert!(b.max_receive_message_bytes.is_none());
1660 }
1661
1662 #[test]
1663 fn builder_rpc_timeout_overrides_default() {
1664 let b = SeppClient::builder("http://localhost:1").rpc_timeout(Duration::from_secs(5));
1665 assert_eq!(b.rpc_timeout, Duration::from_secs(5));
1666 }
1667
1668 #[test]
1669 fn builder_max_receive_message_bytes_set() {
1670 let b =
1671 SeppClient::builder("http://localhost:1").max_receive_message_bytes(16 * 1024 * 1024);
1672 assert_eq!(b.max_receive_message_bytes, Some(16 * 1024 * 1024));
1673 }
1674
1675 #[tokio::test]
1676 async fn unary_request_sets_grpc_timeout() {
1677 let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
1678 let client = SeppClient::from_channel(channel);
1679 let request = client.unary_request(());
1680 assert!(request.metadata().contains_key("grpc-timeout"));
1681 }
1682
1683 #[test]
1684 fn root_cause_returns_error_message() {
1685 let err = std::io::Error::other("bottom");
1686 assert_eq!(root_cause(&err), "bottom");
1687 }
1688
1689 #[derive(Debug)]
1690 struct ChainedErr {
1691 msg: &'static str,
1692 source: Option<Box<dyn std::error::Error + 'static>>,
1693 }
1694
1695 impl std::fmt::Display for ChainedErr {
1696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1697 write!(f, "{}", self.msg)
1698 }
1699 }
1700
1701 impl std::error::Error for ChainedErr {
1702 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1703 self.source.as_deref()
1704 }
1705 }
1706
1707 #[test]
1708 fn root_cause_walks_error_chain() {
1709 let inner = ChainedErr {
1710 msg: "deep cause",
1711 source: None,
1712 };
1713 let outer = ChainedErr {
1714 msg: "wrapper",
1715 source: Some(Box::new(inner)),
1716 };
1717 assert_eq!(root_cause(&outer), "deep cause");
1718 }
1719
1720 #[test]
1721 fn retry_policy_with_max_attempts_clamps_to_one() {
1722 let p = RetryPolicy::default().with_max_attempts(0);
1723 assert_eq!(p.max_attempts(), 1);
1724 let p = RetryPolicy::default().with_max_attempts(1);
1725 assert_eq!(p.max_attempts(), 1);
1726 let p = RetryPolicy::default().with_max_attempts(5);
1727 assert_eq!(p.max_attempts(), 5);
1728 }
1729
1730 #[test]
1731 fn retry_policy_without_jitter_does_not_panic() {
1732 let _p = RetryPolicy::default().without_jitter();
1733 }
1734
1735 #[test]
1736 fn retry_policy_multiplier_methods_do_not_panic() {
1737 let _p = RetryPolicy::default().with_multiplier(0.5);
1738 let _p = RetryPolicy::default().with_multiplier(1.0);
1739 let _p = RetryPolicy::default().with_multiplier(2.5);
1740 }
1741
1742 #[test]
1743 fn enqueue_error_rejected_displays_job_rejection() {
1744 let err = EnqueueError::Rejected(crate::JobRejection::Unknown);
1745 let msg = err.to_string();
1746 assert!(msg.contains("unrecognized rejection variant"));
1747 }
1748
1749 #[test]
1750 fn client_error_empty_batch_display() {
1751 assert!(ClientError::EmptyBatch.to_string().contains("empty batch"));
1752 }
1753
1754 #[test]
1755 fn client_error_batch_result_count_mismatch_display() {
1756 let err = ClientError::BatchResultCountMismatch {
1757 expected: 10,
1758 got: 7,
1759 };
1760 let msg = err.to_string();
1761 assert!(msg.contains("10"));
1762 assert!(msg.contains("7"));
1763 }
1764
1765 #[test]
1766 fn client_error_malformed_response_display() {
1767 let err = ClientError::MalformedResponse("missing field id");
1768 assert!(err.to_string().contains("missing field id"));
1769 }
1770
1771 #[tokio::test]
1772 async fn drain_dead_letters_zero_max_returns_empty_without_rpc() {
1773 let channel = Endpoint::from_static("http://[::1]:1").connect_lazy();
1776 let client = SeppClient::from_channel(channel);
1777 let records = client.drain_dead_letters(None, 0).await.unwrap();
1778 assert!(records.is_empty());
1779 }
1780
1781 fn test_job_ctx() -> crate::JobCtx {
1782 let channel = Endpoint::from_static("http://[::1]:1").connect_lazy();
1783 let client = SeppClient::from_channel(channel);
1784 let job = pb::Job {
1785 id: "job-1".into(),
1786 job_type: "t".into(),
1787 payload: None,
1788 priority: 0,
1789 trace_context: None,
1790 enqueued_at: Some(prost_types::Timestamp {
1791 seconds: 1,
1792 nanos: 0,
1793 }),
1794 attempt: 2,
1795 max_attempts: 5,
1796 lease_expires_at: Some(prost_types::Timestamp {
1797 seconds: 2,
1798 nanos: 0,
1799 }),
1800 custom: Default::default(),
1801 scheduled_at: None,
1802 queue: "q".into(),
1803 };
1804 crate::job_from_pb(&client, job, Some("w1")).unwrap().ctx
1805 }
1806
1807 #[tokio::test]
1808 async fn nack_request_omits_empty_reason() {
1809 let ctx = test_job_ctx();
1810 let body = nack_request(&ctx, RetryDirective::Default, String::new());
1811 assert_eq!(body.reason, None);
1812 }
1813
1814 #[tokio::test]
1815 async fn nack_request_carries_fields_and_reason() {
1816 let ctx = test_job_ctx();
1817 let body = nack_request(
1818 &ctx,
1819 RetryDirective::After(Duration::from_secs(3)),
1820 "boom".into(),
1821 );
1822 assert_eq!(body.job_id, "job-1");
1823 assert_eq!(body.attempt, 2);
1824 assert_eq!(body.worker_id.as_deref(), Some("w1"));
1825 assert_eq!(body.reason.as_deref(), Some("boom"));
1826 assert_eq!(
1827 body.retry.unwrap().strategy,
1828 Some(pb::nack_retry::Strategy::Delay(prost_types::Duration {
1829 seconds: 3,
1830 nanos: 0
1831 }))
1832 );
1833 }
1834
1835 #[tokio::test]
1836 async fn nack_request_dead_letter_strategy() {
1837 let ctx = test_job_ctx();
1838 let body = nack_request(&ctx, RetryDirective::DeadLetter, "bad".into());
1839 assert_eq!(
1840 body.retry.unwrap().strategy,
1841 Some(pb::nack_retry::Strategy::DeadLetter(()))
1842 );
1843 }
1844
1845 #[tokio::test]
1846 async fn lease_new_and_known_expiry() {
1847 let channel = Endpoint::from_static("http://[::1]:1").connect_lazy();
1848 let client = SeppClient::from_channel(channel);
1849 let expiry = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
1850 let lease = Lease::new(client, "job-1".into(), 1, expiry, Some("worker-1".into()));
1851 assert_eq!(lease.known_expiry_ms(), 100_000);
1852 }
1853
1854 #[tokio::test]
1855 async fn lease_known_expiry_ms_without_worker_id() {
1856 let channel = Endpoint::from_static("http://[::1]:1").connect_lazy();
1857 let client = SeppClient::from_channel(channel);
1858 let expiry = SystemTime::UNIX_EPOCH + Duration::from_millis(42);
1859 let lease = Lease::new(client, "j".into(), 3, expiry, None);
1860 assert_eq!(lease.known_expiry_ms(), 42);
1861 }
1862
1863 #[tokio::test]
1864 async fn lease_debug_format() {
1865 let channel = Endpoint::from_static("http://[::1]:1").connect_lazy();
1866 let client = SeppClient::from_channel(channel);
1867 let expiry = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
1868 let lease = Lease::new(client, "job-1".into(), 1, expiry, None);
1869 let debug = format!("{:?}", lease);
1870 assert!(debug.contains("job-1"));
1871 }
1872
1873 #[cfg(feature = "tls")]
1874 mod tls_tests {
1875 use super::*;
1876
1877 #[test]
1878 fn builder_tls_does_not_panic() {
1879 let _b = SeppClient::builder("http://localhost:1").tls();
1880 }
1881
1882 #[test]
1883 fn builder_tls_chain_with_api_key() {
1884 let _b = SeppClient::builder("http://localhost:1")
1885 .api_key("secret")
1886 .tls();
1887 }
1888
1889 #[test]
1890 fn builder_tls_domain_sets_domain() {
1891 let _b = SeppClient::builder("http://localhost:1").tls_domain("example.com");
1892 }
1893
1894 #[test]
1895 fn builder_tls_ca_certificate_accepts_pem() {
1896 let pem = b"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----";
1897 let _b = SeppClient::builder("http://localhost:1").tls_ca_certificate(pem.as_ref());
1898 }
1899
1900 #[test]
1901 fn builder_tls_config_accepts_default() {
1902 let config = tonic::transport::ClientTlsConfig::default();
1903 let _b = SeppClient::builder("http://localhost:1").tls_config(config);
1904 }
1905 }
1906}