Skip to main content

sepp_rs/
client.rs

1//! The gRPC client: connecting, enqueuing, reserving, and lease management.
2//!
3//! [`SeppClient`] is a cheaply-cloneable handle to a Sepp server. Build one with
4//! [`SeppClient::connect`] for the common case, or [`SeppClient::builder`] to
5//! configure authentication, TLS, timeouts, and an RPC [`RetryPolicy`]. All RPC methods
6//! take `&self`, so a single client can be shared across tasks by cloning.
7//!
8//! For consuming jobs you can call [`reserve`](SeppClient::reserve),
9//! [`ack`](SeppClient::ack), [`nack`](SeppClient::nack), and
10//! [`extend`](SeppClient::extend) directly, or hand the client to a
11//! [`Worker`](crate::worker::Worker) and let it drive that loop.
12
13use 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/// A handle to a Sepp server.
45///
46/// Cloning is cheap — clones share the same underlying connection and retry
47/// policy — so clone freely to use the client across tasks. Every RPC method
48/// takes `&self`.
49#[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/// The general client error type, shared by most RPCs.
62///
63/// gRPC status codes are mapped onto these variants: `Unavailable` /
64/// `DeadlineExceeded` / `Aborted` / `Cancelled` become [`Transport`](Self::Transport),
65/// `ResourceExhausted` becomes [`Overloaded`](Self::Overloaded), and so on. The
66/// [`RetryPolicy`] retries `Unavailable`, `DeadlineExceeded`, and `Aborted`
67/// (all surfacing as `Transport`) plus `ResourceExhausted` (`Overloaded`);
68/// a `Cancelled` RPC also maps to `Transport` but is never retried.
69#[derive(Debug, thiserror::Error)]
70#[non_exhaustive]
71pub enum ClientError {
72    /// Establishing the connection failed.
73    #[error("could not connect to Sepp server at {addr}: {reason}")]
74    Connect { addr: String, reason: String },
75    /// The configured API key could not be encoded as an HTTP header value.
76    #[error("the API key is not a valid HTTP header value")]
77    InvalidApiKey,
78    /// A transient transport-level failure (connection dropped, deadline
79    /// exceeded, request aborted/cancelled). Generally safe to retry, though
80    /// the [`RetryPolicy`] does not retry cancelled RPCs, and retried enqueues
81    /// can duplicate jobs that carry no idempotency key.
82    #[error("transport failure: {0}")]
83    Transport(String),
84    /// The server rejected the credentials (missing/invalid API key, or
85    /// permission denied).
86    #[error("authentication failed: {0}")]
87    Unauthenticated(String),
88    /// The server is shedding load (`ResourceExhausted`); back off and retry.
89    #[error("server is overloaded: {0}")]
90    Overloaded(String),
91    /// The server rejected the request as malformed (`InvalidArgument`).
92    #[error("invalid request: {0}")]
93    InvalidRequest(String),
94    /// The server hit an internal error (`Internal` / `DataLoss` / `Unknown`).
95    #[error("server internal error: {0}")]
96    ServerInternal(String),
97    /// The server returned a status code this client does not map to a more
98    /// specific variant.
99    #[error("server returned unexpected status {code:?}: {message}")]
100    UnexpectedStatus { code: tonic::Code, message: String },
101    /// An enqueue was attempted with no jobs.
102    #[error("empty batch")]
103    EmptyBatch,
104    /// The server returned a different number of results than jobs sent — a
105    /// protocol violation.
106    #[error("server returned {got} results for a batch of {expected} jobs")]
107    BatchResultCountMismatch { expected: usize, got: usize },
108    /// A response was missing a field the protocol requires.
109    #[error("malformed response: {0}")]
110    MalformedResponse(&'static str),
111    /// A job in a response could not be decoded; see [`JobConversionError`].
112    #[error("server returned a malformed job: {0}")]
113    MalformedJob(#[from] JobConversionError),
114    /// A server-info response could not be decoded; see [`ServerInfoError`].
115    #[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/// The error type of [`SeppClient::enqueue`] (the single-job convenience
137/// wrapper).
138///
139/// Separates a deterministic per-job [`JobRejection`] from a
140/// connection/protocol-level [`ClientError`].
141#[derive(Debug, thiserror::Error)]
142#[non_exhaustive]
143pub enum EnqueueError {
144    /// The server accepted the request but rejected this specific job.
145    #[error("server rejected the job: {0}")]
146    Rejected(JobRejection),
147    /// The call failed before a per-job verdict was reached.
148    #[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/// The error type of the lease operations [`ack`](SeppClient::ack),
159/// [`nack`](SeppClient::nack), and [`extend`](SeppClient::extend).
160///
161/// [`JobNotFound`](Self::JobNotFound) and [`AttemptMismatch`](Self::AttemptMismatch)
162/// both mean the worker no longer holds the lease — typically because it was
163/// allowed to expire and the job was redelivered. In that case any work the
164/// handler did may be processed again by another worker. With a retrying
165/// [`RetryPolicy`], `JobNotFound` can also mean an earlier attempt of this
166/// same call succeeded and only its response was lost.
167#[derive(Debug, thiserror::Error)]
168#[non_exhaustive]
169pub enum LeaseError {
170    /// No in-flight job has this id: it was already acked, the lease expired,
171    /// or it never existed.
172    #[error("no in-flight job with this id (already acked, expired, or never existed)")]
173    JobNotFound,
174    /// The attempt number no longer matches the server's: the lease was
175    /// reassigned to another delivery.
176    #[error("attempt mismatch: the lease was reassigned")]
177    AttemptMismatch,
178    /// A transport- or protocol-level failure.
179    #[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/// The error type of [`SeppClient::reserve`].
195#[derive(Debug, thiserror::Error)]
196#[non_exhaustive]
197pub enum ReserveError {
198    /// The server is in strict mode and one or more requested queues are not
199    /// declared; the message lists them.
200    #[error("requested queues are not declared on the server: {0}")]
201    UnknownQueues(String),
202    /// A transport- or protocol-level failure.
203    #[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/// How the server should handle a [`nack`](SeppClient::nack)ed job's next
218/// delivery.
219///
220/// This is *job-level* retry (the handler failed), distinct from the
221/// connection-level [`RetryPolicy`] (the RPC failed).
222#[derive(Debug, Clone)]
223pub enum RetryDirective {
224    /// Apply the queue's configured retry policy (backoff, max attempts).
225    Default,
226    /// Retry, but not before the given delay has elapsed.
227    After(Duration),
228    /// Do not retry; send the job straight to the dead-letter queue.
229    DeadLetter,
230}
231
232/// Backoff policy for retrying *transient* RPC failures (`Unavailable`,
233/// `DeadlineExceeded`, and `Aborted`, which map to [`ClientError::Transport`],
234/// plus `ResourceExhausted`, which maps to
235/// [`Overloaded`](ClientError::Overloaded); a `Cancelled` RPC also maps to
236/// `Transport` but is not retried).
237///
238/// Applies to enqueue, ack, nack, extend, and get-server-info — but not to
239/// [`reserve`](SeppClient::reserve), which is a long poll. The default policy
240/// performs **no** retries (`max_attempts == 1`); opt in by building one up and
241/// passing it to [`SeppClientBuilder::retry_policy`].
242///
243/// Retried enqueues can duplicate jobs that carry no idempotency key. To limit
244/// that hazard, an enqueue request in which any job lacks an
245/// [idempotency key](crate::EnqueueRequest::with_idempotency_key) is not
246/// retried on the ambiguous-commit codes `DeadlineExceeded` and `Aborted` —
247/// the server may have already committed the batch — while `Unavailable` and
248/// `ResourceExhausted` (the request was rejected or never ran) stay retryable.
249/// When every job in the request carries an idempotency key, the server
250/// dedupes replays and the full transient set is retried.
251///
252/// ```
253/// use std::time::Duration;
254/// use sepp_rs::client::RetryPolicy;
255///
256/// let policy = RetryPolicy::default()
257///     .with_max_attempts(5)
258///     .with_initial_backoff(Duration::from_millis(50))
259///     .with_max_backoff(Duration::from_secs(5));
260/// ```
261#[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    /// Sets the total number of attempts (including the first). Values below 1
272    /// are clamped to 1, so `1` means "no retries".
273    pub fn with_max_attempts(mut self, n: u32) -> Self {
274        self.max_attempts = n.max(1);
275        self
276    }
277
278    /// Sets the backoff before the first retry. Each subsequent retry multiplies
279    /// this by [`with_multiplier`](Self::with_multiplier), capped at
280    /// [`with_max_backoff`](Self::with_max_backoff).
281    pub fn with_initial_backoff(mut self, d: Duration) -> Self {
282        self.initial_backoff = d;
283        self
284    }
285
286    /// Caps the backoff between retries.
287    pub fn with_max_backoff(mut self, d: Duration) -> Self {
288        self.max_backoff = d;
289        self
290    }
291
292    /// Sets the exponential growth factor for the backoff. Values below 1.0 are
293    /// clamped to 1.0 (constant backoff).
294    pub fn with_multiplier(mut self, m: f64) -> Self {
295        self.multiplier = m.max(1.0);
296        self
297    }
298
299    /// Disables jitter. By default each delay is randomized within
300    /// `[0.5, 1.0)` of its computed value to avoid thundering herds.
301    pub fn without_jitter(mut self) -> Self {
302        self.jitter = false;
303        self
304    }
305
306    /// Returns the configured number of attempts.
307    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    /// Connects to a Sepp server over plaintext with no authentication.
326    ///
327    /// `addr` is a URI such as `http://127.0.0.1:50051`. For API-key auth, TLS,
328    /// or a custom [`RetryPolicy`], use [`builder`](Self::builder) instead.
329    pub async fn connect(addr: impl Into<String>) -> Result<Self, ClientError> {
330        Self::builder(addr).connect().await
331    }
332
333    /// Starts building a client for `addr`, allowing authentication, TLS, and
334    /// retry configuration before [`connect`](SeppClientBuilder::connect).
335    pub fn builder(addr: impl Into<String>) -> SeppClientBuilder {
336        SeppClientBuilder::new(addr)
337    }
338
339    /// Wraps an already-established tonic [`Channel`], with no authentication
340    /// and the default [`RetryPolicy`].
341    ///
342    /// Use this to share a channel or apply custom tonic transport
343    /// configuration the builder does not expose.
344    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    /// Builds a unary request with the client's RPC deadline and trace metadata
353    /// applied. Reserve builds its own request: its deadline follows the wait
354    /// timeout instead.
355    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    /// Enqueues a batch of jobs on a best-effort basis.
368    ///
369    /// Each job is accepted or rejected independently: the returned vector has
370    /// one entry per submitted job, in the same order, where the inner `Result`
371    /// is `Ok` for an accepted job or `Err` for a per-job [`JobRejection`]. The
372    /// outer `Err` is reserved for whole-call failures (empty batch, transport
373    /// error, protocol violation). Transient failures are retried per the
374    /// client's [`RetryPolicy`]; note that retried enqueues can duplicate jobs
375    /// that carry no idempotency key, so when any job in the batch lacks one,
376    /// the ambiguous-commit codes `DeadlineExceeded` and `Aborted` are not
377    /// retried (see [`RetryPolicy`]).
378    ///
379    /// For all-or-nothing semantics, use [`enqueue_atomic`](Self::enqueue_atomic).
380    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    /// Enqueues a single job.
432    ///
433    /// A convenience wrapper over [`enqueue_batch`](Self::enqueue_batch) that
434    /// flattens the result: a per-job rejection becomes
435    /// [`EnqueueError::Rejected`]. Its retry behavior — including that a
436    /// retried enqueue can duplicate a job that carries no idempotency key —
437    /// is inherited from [`enqueue_batch`](Self::enqueue_batch).
438    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    /// Enqueues a batch of jobs atomically: either all are accepted or none are.
456    ///
457    /// On success, returns one [`EnqueueAck`] per job, in order. If any job
458    /// fails validation, nothing is enqueued and every failure is returned
459    /// together as [`AtomicEnqueueError::Validation`](crate::AtomicEnqueueError::Validation).
460    /// Use this when the jobs are coordinated steps and a partial enqueue would
461    /// leave the system inconsistent.
462    ///
463    /// Transient failures are retried per the client's [`RetryPolicy`]; note
464    /// that retried enqueues can duplicate jobs that carry no idempotency key,
465    /// so when any job in the batch lacks one, the ambiguous-commit codes
466    /// `DeadlineExceeded` and `Aborted` are not retried (see [`RetryPolicy`]).
467    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    /// Long-polls for jobs to process.
525    ///
526    /// Blocks up to the options' [`wait_timeout`](ReserveOptions::wait_timeout)
527    /// for at least one job. Returns `Ok(Some(jobs))` with one or more leased
528    /// [`Job`]s, or `Ok(None)` if the wait elapsed with nothing available (poll
529    /// again). Each returned job must be [`ack`](Self::ack)ed,
530    /// [`nack`](Self::nack)ed, or [`extend`](Self::extend)ed before its lease
531    /// expires.
532    ///
533    /// Unlike the other RPCs, reserve is **not** retried by the
534    /// [`RetryPolicy`]: as a long poll, an empty return is the normal idle
535    /// outcome and the caller loops anyway. A malformed job in the response is
536    /// logged and skipped rather than failing the whole batch.
537    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        // A single malformed job must not discard the rest of the batch
552        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    /// Acknowledges that a job completed successfully, removing it from the
581    /// queue.
582    ///
583    /// The `attempt` carried by `ctx` guards against acking a job whose lease
584    /// was already reassigned — that surfaces as
585    /// [`LeaseError::AttemptMismatch`] or [`LeaseError::JobNotFound`].
586    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    /// Negatively acknowledges a job, signalling that processing failed.
613    ///
614    /// `retry` selects what the server does next (see [`RetryDirective`]) and
615    /// `reason` is recorded for debugging and metrics; an empty `reason` is
616    /// omitted from the request rather than sent as an empty string. Returns
617    /// `true` if this nack moved the job to the dead-letter queue (because
618    /// `DeadLetter` was requested or `max_attempts` was reached), `false` if
619    /// it will be retried.
620    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    /// Extends a job's lease by `extension`, measured from now, returning the
638    /// new expiry.
639    ///
640    /// Call this when a handler needs longer than the original lease. Equivalent
641    /// to [`JobCtx::extend`]; a [`Worker`](crate::worker::Worker) with
642    /// [`with_auto_extend`](crate::worker::Worker::with_auto_extend) does it
643    /// automatically.
644    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    /// Fetches the server's [`ServerInfo`]: version, capabilities, and limits.
700    ///
701    /// Useful once at startup so a producer can validate jobs locally against
702    /// the advertised limits and avoid round-trips that would only be rejected.
703    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    /// Drains dead-lettered jobs for inspection and manual replay.
725    ///
726    /// Returns up to `max` [`DeadLetterRecord`]s (oldest-first, optionally
727    /// filtered to one `queue`) and **removes them from the server**; a `max`
728    /// of `0` returns an empty vector without making an RPC. This is
729    /// destructive: the records are gone once returned, so a dropped response
730    /// loses exactly that batch — for that reason it is **not** retried by the
731    /// [`RetryPolicy`]. Inspect each record, then replay any you want with
732    /// [`DeadLetterRecord::to_enqueue_request`].
733    ///
734    /// An empty result means nothing matched, which is indistinguishable from
735    /// dead-letter retention being disabled — check
736    /// [`ServerInfo::dead_letter_retention_enabled`](crate::ServerInfo::dead_letter_retention_enabled).
737    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            // The server rejects max = 0; draining a record the caller asked
744            // zero of would be silent data loss.
745            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
828/// Configures and connects a [`SeppClient`].
829///
830/// Created by [`SeppClient::builder`]. Set an [`api_key`](Self::api_key), a
831/// [`retry_policy`](Self::retry_policy), and (with the `tls` feature) TLS
832/// options, then call [`connect`](Self::connect).
833///
834/// ```no_run
835/// use sepp_rs::client::{RetryPolicy, SeppClient};
836///
837/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
838/// let client = SeppClient::builder("http://127.0.0.1:50051")
839///     .api_key("secret")
840///     .retry_policy(RetryPolicy::default().with_max_attempts(3))
841///     .connect()
842///     .await?;
843/// # let _ = client;
844/// # Ok(())
845/// # }
846/// ```
847pub 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    /// Sends an `Authorization: Bearer <key>` header on every request.
871    ///
872    /// Without TLS the key travels in plaintext, so [`connect`](Self::connect)
873    /// logs a warning if you set a key but no TLS.
874    pub fn api_key(mut self, key: impl Into<String>) -> Self {
875        self.api_key = Some(key.into());
876        self
877    }
878
879    /// Sets the [`RetryPolicy`] for transient RPC failures. The default policy
880    /// does not retry.
881    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
882        self.retry_policy = policy;
883        self
884    }
885
886    /// Sets the per-call deadline for every unary RPC except
887    /// [`reserve`](SeppClient::reserve), whose deadline follows the requested
888    /// wait timeout instead. Defaults to 30 seconds.
889    ///
890    /// The deadline travels as the `grpc-timeout` request header and is
891    /// enforced by the server, not by a client-side timer.
892    ///
893    /// Enqueuing very large batches may need a higher value.
894    pub fn rpc_timeout(mut self, timeout: Duration) -> Self {
895        self.rpc_timeout = timeout;
896        self
897    }
898
899    /// Sets the largest gRPC message this client accepts, replacing tonic's
900    /// 4 MiB default.
901    ///
902    /// Workers should consider raising this: a reserve response can carry up
903    /// to the server's `max_reserve_batch` × `max_payload_bytes`, which can
904    /// exceed the 4 MiB default, and an oversized response fails client-side
905    /// while its jobs stay leased until they expire.
906    pub fn max_receive_message_bytes(mut self, bytes: usize) -> Self {
907        self.max_receive_message_bytes = Some(bytes);
908        self
909    }
910
911    /// Enables TLS using the platform's native root certificates.
912    ///
913    /// *Requires the `tls` feature.*
914    #[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    /// Enables TLS and trusts the given PEM-encoded CA certificate, e.g. for a
921    /// private/self-signed server.
922    ///
923    /// *Requires the `tls` feature.*
924    #[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    /// Overrides the domain name verified against the server certificate, for
932    /// when the connection address differs from the certificate's name.
933    ///
934    /// *Requires the `tls` feature.*
935    #[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    /// Sets a fully custom tonic [`ClientTlsConfig`], replacing any TLS options
943    /// set by the other `tls_*` methods.
944    ///
945    /// *Requires the `tls` feature.*
946    #[cfg(feature = "tls")]
947    pub fn tls_config(mut self, config: ClientTlsConfig) -> Self {
948        self.tls = Some(config);
949        self
950    }
951
952    /// Connects to the server with the configured options, yielding a ready
953    /// [`SeppClient`].
954    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                // Keepalives so a connection idling in a long-poll reserve is
978                // not dropped as dead.
979                .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/// A tonic interceptor that attaches the configured API key as an
1019/// `Authorization: Bearer <key>` header on each request.
1020///
1021/// Installed by [`SeppClientBuilder::api_key`]; not constructed directly.
1022#[derive(Clone)]
1023pub struct ApiKeyInterceptor {
1024    // Pre-rendered `Bearer <key>` header value; None disables the interceptor.
1025    bearer: Option<MetadataValue<Ascii>>,
1026}
1027
1028impl ApiKeyInterceptor {
1029    /// Returns `None` if the key cannot form a valid HTTP header value.
1030    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
1066/// Returns whether a gRPC status is worth retrying.
1067fn 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
1075/// Returns whether a gRPC status is worth retrying for an *enqueue* RPC.
1076///
1077/// When every job in the request carries an idempotency key the server dedupes
1078/// replays, so the full transient set is safe. Otherwise `DeadlineExceeded`
1079/// and `Aborted` are excluded: they are ambiguous-commit failures — the server
1080/// may have already committed the batch, and a retry would duplicate every job
1081/// without a key. `Unavailable` and `ResourceExhausted` mean the request was
1082/// rejected or never ran, so they stay retryable either way.
1083fn 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
1091/// Returns whether every job in the batch carries a non-empty idempotency key
1092/// (the server rejects present-but-empty keys, so those count as unkeyed).
1093fn 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
1098/// Builds the wire request for a nack.
1099fn 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        // The reason is optional on the wire: omit it entirely when the caller
1109        // has none rather than sending a present-but-empty string.
1110        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
1118/// Equal-jitter factor: returns a value in `[0.5, 1.0)`.
1119fn 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
1131/// Run `f` repeatedly until it succeeds, runs out of attempts, or hits a
1132/// non-transient error. Sleeps between attempts according to `policy`.
1133async 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
1145/// Like [`with_retry`], but with a custom `retryable` predicate. Enqueue RPCs
1146/// use this to narrow the retry set when jobs lack idempotency keys.
1147async 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        // Newline in header value is not a valid HTTP header
1399        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        // The server may have committed the batch on these; a retry would
1464        // duplicate jobs that carry no idempotency key.
1465        assert!(!is_transient_enqueue(
1466            &st(Code::DeadlineExceeded, ""),
1467            false
1468        ));
1469        assert!(!is_transient_enqueue(&st(Code::Aborted, ""), false));
1470
1471        // These mean the request was rejected or never ran.
1472        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        // The server rejects present-but-empty keys, so they count as unkeyed.
1494        assert!(!all_jobs_keyed(&[keyed_job(Some(""))]));
1495    }
1496
1497    /// Runs `with_retry_if` under the enqueue predicate, counting attempts.
1498    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        // The endpoint is unreachable, so any attempted RPC would fail: an Ok
1774        // result proves max = 0 short-circuits before hitting the wire.
1775        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}