sqs-job-worker
A framework-agnostic, long-running worker for processing Amazon SQS jobs. Hand it a boto3 client and it works; the core depends on no web framework, APM SDK, or deployment platform.
Features
- A simple long-running worker that receives and processes one message at a time; concurrency scales with the number of processes (containers)
- Polls multiple queues in weighted random order, prioritizing some queues without starving the low-weight ones
- A dedicated thread keeps extending the visibility timeout while a job runs, so even jobs with unpredictable runtimes are unlikely to be delivered twice
- Graceful shutdown on SIGTERM / SIGINT: in-flight jobs run to completion and unprocessed messages are returned to the queue immediately
- Rack-style middleware with three hooks: produce / poll / consume
- New Relic, OpenTelemetry, and Sentry trace propagation, plus draining for ECS rolling deploys, bundled as
contrib - Structured logging via structlog; correlation fields travel in SQS message attributes and are attached to consumer-side logs automatically
- Works with both standard and FIFO queues
Installation
$ pip install sqs-job-worker
To use an APM integration, install the corresponding extra:
$ pip install "sqs-job-worker[newrelic]"
$ pip install "sqs-job-worker[otel]"
$ pip install "sqs-job-worker[sentry]"
Requires Python 3.11 or later.
Quickstart
Create a QueueGroup that maps logical names to physical queues. Share the same definition between producers and consumers.
import boto3
from sqs_job_worker import QueueGroup
sqs = boto3.client("sqs")
queues = QueueGroup.build(boto_client=sqs, queues={"default": {"name": "my-app-jobs"}})
Enqueue a job:
queues.enqueue("default", "send_welcome_email", {"user_id": 42})
In the worker process, start the loop with a handler for each job_type:
def send_welcome_email(payload: dict) -> None:
...
queues.run_worker({"send_welcome_email": send_welcome_email})
run_worker() is a blocking loop with signal handling built in.
On ECS or Kubernetes, run this process as a long-running service as is.
Messages are delivered at least once. The same message may arrive more than once, so implement handlers idempotently.
Message format
The message body is the following JSON. Any producer that follows this format can enqueue jobs, including producers written in other languages.
{"job_type": "send_welcome_email", "payload": {"user_id": 42}}
- When the body cannot be parsed as JSON,
job_typeis not a string, orpayloadis not an object, the message is deleted, because no retry can fix it - When no handler is registered for the
job_type, the message is left undeleted, on the assumption that it will be redelivered to another worker responsible for that job. Its visibility timeout is extended to at least five minutes to avoid a tight redelivery loop. On a FIFO queue this holds back the rest of that message group for the same period, so unknown job types stall ordered delivery until the redrive policy moves them to the dead-letter queue
Correlation fields and trace headers travel in SQS message attributes, not in the body.
enqueue() also accepts SQS send options such as message_group_id / message_deduplication_id for FIFO queues.
For standard queues you can also delay delivery with delay_seconds.
FIFO queues do not support per-message delays.
queues.enqueue(
"default",
"recalculate_balance",
{"account_id": "a-1"},
message_group_id="a-1",
message_deduplication_id="job-1",
)
Retries and failures
- When a handler returns normally, the message is deleted
- When a handler raises, the message is not deleted and is redelivered after the visibility timeout expires
- Production queues must configure an SQS redrive policy and dead-letter queue to bound retries for handler failures, malformed messages that escape validation, and unknown job types
- When retrying cannot possibly succeed, raise
NonRetryableError. The message is deleted immediately without redelivery
from sqs_job_worker import NonRetryableError
def sync_user(payload: dict) -> None:
user = find_user(payload["user_id"])
if user is None:
raise NonRetryableError("user was deleted; retrying cannot succeed")
Multiple queues and weighted polling
queues = QueueGroup.build(
boto_client=sqs,
queues={
"critical": {"name": "my-app-jobs-critical", "weight": 5},
"default": {"name": "my-app-jobs", "weight": 1},
},
)
- Each cycle, the worker draws a random queue order based on the weights, short-polls each queue in that order, and processes the first message it finds
weightis a relative value used in this ordering draw. In the example above,criticalis more likely to come first, but every queue is checked every cycle, so low-weight queues are never left behind- When every queue is empty, the worker long-polls the queue that came first in the order for
idle_wait_seconds(default 20 seconds), waiting for a message to arrive
Instead of name, you can specify the queue URL directly with url. For per-queue tuning, construct an SqsQueue yourself and pass it to QueueGroup.
from sqs_job_worker import QueueGroup, SqsQueue
heavy = SqsQueue(queue_url, boto_client=sqs, max_runtime_seconds=7200)
queues = QueueGroup({"heavy": {"queue": heavy, "weight": 1}})
Long-running jobs and the visibility timeout
- When no visibility timeout is specified, the worker fetches the queue's configured value at startup
- While a job runs, a dedicated heartbeat thread keeps extending the visibility timeout at intervals of one third of the timeout. You don't need to raise the queue's visibility timeout to match the job's maximum runtime
- When a job runs longer than
max_runtime_seconds(default 3600 seconds), the heartbeat stops and the message returns to SQS for redelivery - When extending the visibility timeout fails, the message may already have been redelivered to another worker, so it is not deleted even if the job finishes successfully
Graceful shutdown
On SIGTERM / SIGINT, run_worker() stops receiving new messages, finishes the in-flight job, then exits.
A second signal exits immediately.
Messages received after the stop request are not processed; their visibility timeout is reset to 0 so they become available for redelivery right away.
If receive errors persist, the worker retries with jittered exponential backoff capped at 30 seconds. After 10 consecutive failures it exits with code 1, leaving the restart to the supervisor.
Middleware
Middleware wraps enqueueing, polling, and processing so you can inject cross-cutting behavior such as trace propagation and instrumentation.
Pass the middleware to use as a list via QueueGroup's middleware.
from sqs_job_worker.contrib.otel import OTelMiddleware
queues = QueueGroup.build(
boto_client=sqs,
queues={"default": {"name": "my-app-jobs"}},
middleware=[OTelMiddleware()],
)
Like Rack and WSGI, middleware is applied from the outside in, with the first list entry outermost.
Share the same QueueGroup definition between producers and consumers and the same middleware applies to both enqueueing and processing.
Middleware in contrib
The core depends only on boto3 and structlog; integrations that touch APM vendor SDKs or platform APIs are kept in contrib.
Install the corresponding extra to use an APM integration.
contrib.newrelic.NewRelicMiddleware— propagates New Relic distributed traces, instruments jobs asBackgroundTasks, and bindstrace_id/span_idto logs- Where the agent cannot link the trace, it falls back to a synthesized W3C traceparent so producer and consumer logs stay correlated by one
trace_id contrib.otel.OTelMiddleware— propagates traces with the propagator configured in your application, instruments jobs withCONSUMERspans carryingmessaging.*attributes, and bindstrace_id/span_idto logscontrib.sentry.SentryMiddleware— propagatessentry-trace/baggage, reports handler exceptions to Sentry, and leaves the retry decision to the workercontrib.ecs.EcsDrainMiddleware— during an ECS rolling deploy, pauses polling once a task from a newer revision of the same task definition family is RUNNING, preventing previous-generation workers from picking up new jobs. If the new generation fails and stops, polling resumes automatically- Pass an ECS client, e.g.
EcsDrainMiddleware(boto3.client("ecs")). The task role needs theecs:ListTasksandecs:DescribeTaskspermissions - The worker identifies its own task via the ECS Task Metadata Endpoint V4; outside ECS, or on ECS API errors, it fails open and keeps polling as usual
- Pass an ECS client, e.g.
Register only one trace-context-injecting middleware per process. Duplicates are detected as a ValueError when a job is enqueued.
Writing your own middleware
Subclass JobMiddleware and override only the hooks you need.
produce(job, call_next)wrapsenqueue(). Use it to inject correlation fields and trace headerspoll(call_next)wraps one polling cycle. Use it to pause receiving (draining) or for per-cycle instrumentationconsume(job, call_next)wraps handler execution. Use it to set up APM transactions or per-job metrics
from sqs_job_worker import Job, JobMiddleware
class RequestIdMiddleware(JobMiddleware):
def produce(self, job: Job, call_next):
job.correlation_fields.setdefault("request_id", current_request_id())
return call_next(job)
Correlation fields and trace propagation
- The dict passed as
enqueue(..., correlation_fields={"request_id": "r-1"})is stored as JSON in a single message attribute namedcorrelation_fields. On the consumer side it is bound to the structlog context automatically and attached to every log line the job emits. Keys reserved by the worker, such asqueueandmessage_id, are never overwritten - Correlation fields are untrusted observability metadata because any principal allowed to send a message can set them. Do not use them for authentication, authorization, or tenant selection
- Trace headers (such as
traceparent) travel as individual message attributes. The core never interprets their contents; injection and extraction are handled by the tracing middleware in contrib - SQS allows up to 10 message attributes per message. When the limit would be exceeded, attributes given explicitly via
message_attributestake precedence: propagation attributes are dropped from the end and apropagation_attributes_droppedwarning is logged
Logging
Logs are structured with structlog. Configure the output destination and formatting in your application.
Job lifecycle events are emitted to the sqs_job_worker.job logger.
job_enqueued— on enqueue (queue,job_type,message_id)job_started— when execution starts (group_id,queue_wait_ms)job_finished— on completion (outcome,duration_ms)
The outcome of job_finished is one of:
success— completed normally; the message is deletedretry— the handler raised; the message is redelivered after the visibility timeout expiresdelete— the message is deleted due toNonRetryableErrorheartbeat_failed— extending the visibility timeout failed, ormax_runtime_secondswas exceeded; the message is left for redelivery rather than deleteddelete_failed— processing finished but the delete request failed; the result of the processing itself is recorded inprocessing_outcome
Logs emitted while a job is being processed automatically carry queue / message_id / receive_count / job_type, the correlation fields, and trace_id / span_id when an APM middleware is in use.
Development
$ uv sync --all-extras --all-groups
$ uv run pytest
$ uv run ruff check . && uv run ruff format --check .
$ uv run ty check src
License
Metadata
Release files for sqs-job-worker 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sqs_job_worker-0.2.0.tar.gz | 30.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sqs_job_worker-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 55.5 kB
Release files / sqs_job_worker-0.2.0.tar.gz
| Download URL | sqs_job_worker-0.2.0.tar.gz |
|---|---|
| Size | 30.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7a5e39a0e226066ce812506c1e357a8ebcefd8879002ce6422907f85248d351b
|
|
BLAKE2b-256 checksum How to use checksums |
6739f4363157bba8882a8ea7cc0fe499ea65dc1d0ff1be6c09a74e28e11b2f28
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 12, 2026.
Transparency logRelease files / sqs_job_worker-0.2.0-py3-none-any.whl
| Download URL | sqs_job_worker-0.2.0-py3-none-any.whl |
|---|---|
| Size | 25.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
96bbe052066aa2e8f4f596398353ed218e2954c3a0b47cc83dc54e9ae842b5a1
|
|
BLAKE2b-256 checksum How to use checksums |
d2d3a55415d4a2e61544a55346c14730caa0e9e4a6a183276bdb2a122a2376c7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 12, 2026.
Transparency log