Build your own client
Implement a client against the open gRPC protocol from the .proto files.
Sepp speaks standard gRPC over a single proto file. If your language has a protobuf compiler and an HTTP/2 gRPC library, you can build a client. The three official clients — sepp-rs, sepp-py, and sepp-js — are small enough to read in an afternoon and demonstrate the patterns below.
Get the proto
The canonical proto lives on the Buf Schema Registry. Vendor the file:
buf export buf.build/sepp-org/sepp-proto:main -o protoThis drops proto/sepp/v1/queue.proto into your tree. The file is plain proto3 — no custom options, imports only google/protobuf/duration.proto, timestamp.proto, and empty.proto. The service is sepp.v1.QueueService with eight unary RPCs. How you compile it is up to your toolchain: prost/tonic at build time (Rust), buf generate with language plugins committed into the repo (Python, TypeScript), or protoc directly (Go, Java, C#, etc.).
The full message catalogue and RPC signatures are in the protocol reference. This guide focuses on what you can't learn by reading the proto file.
Establish a connection
Connect to the server's gRPC address over HTTP/2. The address is a plain host:port string. If you accept a URL-like argument, strip the https:// scheme and use it to decide TLS. Three channel settings matter beyond the defaults:
- Keepalive — interval 30 s, timeout 10 s, and keepalive-while-idle must be on.
Reserveis a long-poll that holds the connection open with no data; without keepalive the connection will be dropped by proxies and load balancers. - User-Agent — set it to
your-client/<version>. It shows up in server logs and metrics, which makes debugging a lot easier when someone reports a problem. - Max receive message size — the server's
max_message_bytesdefaults to 16 MiB. Set your client's receive limit at least as high, otherwise a batch of jobs with large payloads will fail at the transport layer before you ever see a rejection.
Authentication is a single metadata header on every call: authorization: Bearer <api_key>. Pre-render it once at connection time and reuse it. If the user provides an API key without TLS, log a warning. If [auth] is not configured on the server the header is silently ignored.
The enqueue-reserve-ack cycle
A sepp client has two halves that share a gRPC channel but otherwise operate independently: the producer side calls EnqueueBatch (and optionally EnqueueAtomic), the consumer side calls Reserve → Ack/Nack/Extend in a loop.
Enqueue
EnqueueBatch takes a list of jobs and returns a same-length list of results. Every result is either a success (carrying the job id) or a rejection. A rejection is a value, not an error — the rest of the batch may have succeeded. The caller decides what to do with partial failures.
Don't send an empty batch. The server rejects it with INVALID_ARGUMENT, but it's a client bug — catch it before the RPC.
Retry the RPC itself only on transient statuses: UNAVAILABLE, DEADLINE_EXCEEDED, ABORTED, RESOURCE_EXHAUSTED. Use exponential backoff with jitter (the official clients use a [0.5, 1.0) multiplier). Don't retry INVALID_ARGUMENT, UNAUTHENTICATED, or INTERNAL — the first two need a code fix, the third needs the server to recover.
EnqueueAtomic takes the same input but has different semantics: either every job is accepted, or none are. The response contains per-job validation failures rather than a mix of successes and rejections. Use it when a batch forms a transactional unit.
Reserve
Reserve is a long-poll. You send a list of queue names (ordered — the server checks them in order and returns jobs from the first non-empty one), a lease_duration, and an optional wait_timeout. The call blocks until a job arrives or the timeout elapses.
The most important implementation detail: set the gRPC call deadline to wait_timeout + 10 s of slack. If the call deadline fires before the server's internal timeout, you get a DEADLINE_EXCEEDED error instead of a clean empty response. The slack lets the server finish and return normally.
Do not retry Reserve. An empty response is the normal idle case — no jobs are ready. Just call it again.
Each returned Job carries an id (opaque UUID), an attempt number, and a lease_expires_at timestamp. You need all three to perform lease operations. The attempt is particularly important: the server uses it to detect when a lease has been reassigned to another worker. Echo it back exactly in Ack/Nack/Extend.
Malformed jobs in a response batch (unparseable UUID, missing required fields) are possible in theory. Skip them, log a warning, and continue. One corrupt entry should not crash the consumer.
Ack, Nack, Extend
All three lease operations require job_id and attempt. They share the same error semantics:
NOT_FOUNDmeans the job is no longer in flight (already acked or lease expired). This is a terminal state for that particular lease — map it to aJobNotFounderror.FAILED_PRECONDITIONmeans the attempt number doesn't match (the server reassigned the lease to another worker). Map it to anAttemptMismatcherror.- Transient statuses (
UNAVAILABLE, etc.) should be retried.
Nack additionally takes a retry directive with three choices: an empty oneof means retry under the queue's retry policy, delay schedules a retry after a given duration, and dead_letter sends the job straight to the dead-letter queue. Return a boolean from your nack wrapper indicating whether the job was dead-lettered, so the caller can drain it later.
Extend lets a long-running handler push out the lease before it expires. Call it well before lease_expires_at — the server rejects extensions after the lease has already lapsed. The response carries the new expiry.
The worker pattern
Your consumer API should provide a Worker abstraction that runs the reserve-ack-nack loop so application code only writes handler functions. Every official client ships one.
A minimal worker:
- Call
GetServerInfoat startup to discover limits and feature flags. - Enter a loop: call
Reserve, for each returned job invoke the user's handler, on success callAck, on a retryable failure callNackwith a delay, on a permanent failure callNackwithDeadLetter. - If
Reservereturns empty, poll again immediately. - Run a background task that watches
lease_expires_atand callsExtendon jobs still being processed when the deadline approaches. - Expose a shutdown signal. On shutdown: stop accepting new jobs (break the reserve loop), extend leases on in-flight jobs once more to give them a grace period, then cancel remaining handlers after a deadline.
The official clients implement this in worker.rs (Rust, ~400 lines), worker.py (Python, ~250 lines), and worker.ts (TypeScript, ~350 lines). They also track which handler is processing which job so they never Ack/Nack a job that a concurrently-acked handler has already released.
Handler errors: retryable or fatal?
Your Worker needs a way for the handler to signal intent. In the official clients the handler returns a result or raises a typed error: a retry variant means Nack with a delay, a dead_letter variant means Nack with DeadLetter, and anything else is treated as fatal (dead-letter). Let the handler choose what happens to the job — the worker just executes the lease operations.
Propagate trace context
Sepp threads W3C trace context between producer and consumer through the trace_context field on EnqueueRequest and Job. If you don't use distributed tracing, leave the field unset and the server ignores it. If you do, here is how to stay compatible with the official clients and the server's own tracing.
W3C traceparent format
The trace_context message has two fields: traceparent (string, required) and tracestate (string, optional). The traceparent follows the W3C Trace Context spec:
version-trace_id-parent_id-trace_flags
00 -<32 hex> -<16 hex> -<2 hex>The version is always 00. The trace_id is 32 hex characters. The parent_id is 16 hex characters — set this to the current span's ID so the server creates a child span linked to your producer span. The trace_flags is 2 hex characters; 01 means sampled, 00 means not.
On enqueue
Before calling EnqueueBatch, get the current active span from your tracing library. If it has a valid context, build the traceparent string and set it on each job's trace_context (skip jobs that already have one). Also set traceparent and tracestate as gRPC metadata headers on every RPC call — the server's interceptor reads these to continue the gRPC-level span.
All three official clients do this as a best-effort, optional step. If no tracing library is configured (or the feature flag is off), the trace_context field is simply left empty. The server treats a missing trace_context identically to one that is present — it just won't create a consumer side link.
On job processing
When a Job arrives with a trace_context, create a consumer span:
- Parse
traceparentto extracttrace_idandparent_span_id. - Build a
SpanContextfrom them. - Create a span named
sepp.processwith kindCONSUMER. - Add a link from the consumer span to the producer's span context. Use a link, not a parent relationship — the producer enqueued the work asynchronously, and linking preserves the causal graph while keeping the spans in separate traces.
- Run the handler inside this span.
The official clients back this behaviour. The Rust worker creates the span in worker.rs with otel.kind = "consumer" and a link. The Python worker does the same in _otel.py. The TypeScript worker decodes traceparent to a SpanContext and links it with span.addLink(...).
Conformance checklist
Use this list to track completeness against the official clients.
- Plaintext and TLS connection with system CA and custom CA/domain override.
-
authorization: Bearer <key>on every RPC; warning when API key is set without TLS. - Channel keepalive: 30 s interval, 10 s timeout, keepalive-while-idle on.
- User-Agent header.
-
EnqueueBatchwith per-job success/rejection mapping; empty batch rejected client-side; response count validated. -
EnqueueAtomicwith all-or-nothing semantics. - Retry on
UNAVAILABLE | DEADLINE_EXCEEDED | ABORTED | RESOURCE_EXHAUSTEDwith exponential backoff and jitter; no retry onINVALID_ARGUMENT | UNAUTHENTICATED | INTERNAL. -
Reservewith call deadline set towait_timeout + 10 s; no retry on empty; skip malformed jobs. - Lease handle on each job context carrying
id,attempt, and a reference back to the client. -
Ackwith retry;NOT_FOUND→ job-not-found;FAILED_PRECONDITION→ attempt-mismatch. -
Nackwithdefault | delay | dead_letterdirective; returns dead-lettered flag. -
Extendreturning newlease_expires_at. - Worker loop: reserve → process → ack/nack → repeat; pre-emptive lease extension; shutdown with grace period.
- Trace context: optional, best-effort. On enqueue: stamp
traceparentfrom current span onto jobs without one. On process: create CONSUMER span with link from job's trace context. Also settraceparent/tracestateas gRPC metadata. -
GetServerInfomethod that returns a typed response with all advertised limits and feature flags. -
DrainDeadLettersmethod with an optional queue name filter. - Job rejections returned as values (discriminated union), not thrown as exceptions.
See also
- Protocol reference — the full gRPC service definition and message catalogue
- Server capabilities — every
GetServerInfofield in detail - Rejection catalogue — every rejection reason and its governing limit
- Workers — how the worker loop and lease management work from the application side
- sepp-rs — official Rust client (reference for
prost/tonicapproach) - sepp-py — official Python client (reference for
grpcioapproach) - sepp-js — official TypeScript client (reference for
@grpc/grpc-jsapproach)