Amazon SQS transport
Last updated:
com.babelqueue:babelqueue-sqs is an Amazon SQS transport on the Java core. It sends the
canonical envelope as the MessageBody with the
§3 SQS MessageAttributes, and consumes by
routing each message to a handler by URN — so a message it produces is consumed by any
other BabelQueue SDK, and vice-versa.
Install
Maven:
<dependency>
<groupId>com.babelqueue</groupId>
<artifactId>babelqueue-sqs</artifactId>
<version>1.2.1</version>
</dependency>
Gradle:
implementation("com.babelqueue:babelqueue-sqs:1.2.1")
Requirements: Java 17+. It pulls babelqueue-core and the AWS SDK for Java v2
(software.amazon.awssdk:sqs) transitively. You supply the AWS SqsClient (an interface),
so its credentials/region come from your standard AWS configuration chain.
Produce
import com.babelqueue.sqs.SqsPublisher;
import java.util.Map;
import software.amazon.awssdk.services.sqs.SqsClient;
SqsClient sqs = SqsClient.create(); // your AWS config / credentials chain
String url = "https://sqs.eu-central-1.amazonaws.com/123456789012/orders";
String id = SqsPublisher.create(sqs, url)
.publish("urn:babel:orders:created", Map.of("order_id", 1042L));
publish(urn, data) returns the message meta.id; publish(urn, data, traceId)
continues an existing trace. FIFO queues use the builder:
SqsPublisher.builder(sqs, url).fifo(true).build() (the queue URL must end in .fifo).
Consume
Register handlers by URN; poll() receives one batch, routes each message, and deletes
the ones handled. A throwing handler’s message is released, never deleted: the consumer
calls ChangeMessageVisibility with the configured release delay (default 0 s, so SQS
redelivers it at once, at-least-once). Give the queue a native RedrivePolicy
(maxReceiveCount → a DLQ) so a message that always fails cannot loop forever:
import com.babelqueue.sqs.SqsConsumer;
SqsConsumer consumer = SqsConsumer.builder(sqs, url)
.handler("urn:babel:orders:created", (envelope, message) -> {
var orderId = envelope.data().get("order_id");
// ... use envelope.traceId(), envelope.data()
})
.onUnknownUrn((envelope, message) -> { /* no handler for this URN */ })
.onError((error, envelope, message) -> { /* loop never stops */ })
.build();
while (running) {
consumer.poll();
}
attempts is reconciled to ApproximateReceiveCount − 1 for the handler (never lowering
a runtime-incremented count). Point the SqsClient’s endpoint at LocalStack/ElasticMQ for
local testing.
Poison bodies and version-gated messages
A poison body (not JSON, not an envelope, no valid job or urn, or an unsupported
meta.schema_version) and a version-gated message can never succeed, so the consumer settles
them on their first delivery, never hands them to a handler and never retries them
(ADR-0037, since 1.4.0).
Before decoding, the consumer version-gates on the bq-schema-version message attribute
(§3.7). A missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF,
VT, FF, CR) changes nothing: the body is decoded as usual. Any other value is compared with
"1" exactly as sent, with no trimming, so "2", "x", "01" and " 1" are all rejected
without decoding the body, and onError is called with a null envelope. A "1" value does
not skip the body check: the decoded envelope still goes through the schema_version
validation, and a body that fails it is a poison body.
What happens next depends on whether you gave the consumer a BabelQueue dead-letter queue:
SqsConsumer consumer = SqsConsumer.builder(sqs, url)
.handler("urn:babel:orders:created", (envelope, message) -> { /* ... */ })
.deadLetterQueueUrl("https://sqs.eu-central-1.amazonaws.com/123456789012/orders-dlq")
.build();
| Configuration | Poison body | Version-gated message |
|---|---|---|
deadLetterQueueUrl set |
sent to <queue>-dlq as the poison record (a JSON object body spliced with a dead_letter block, reason: "poison"; anything else wrapped under raw), then deleted |
the raw body, byte for byte and with no dead_letter block, sent to <queue>.dlq, then deleted |
unknownUrnStrategy("delete") |
deleted, no record | deleted, no record (dropped) |
no deadLetterQueueUrl |
reported and left to SQS: not deleted, so visibility expiry redelivers it and the queue’s RedrivePolicy moves it to its DLQ |
the same |
The original is deleted only after the SendMessage to the DLQ succeeded; a failed send leaves it
undeleted, so the write is retried when the visibility timeout lapses. The DLQ queue must exist, named <queue>-dlq
(SQS does not accept a dot in a queue name, so not <queue>.dlq; a FIFO one is <queue>-dlq.fifo; the consumer sends it the queue name as message group and the SQS
message id as deduplication id). deadLetterQueueUrl is opt-in: with none, nothing changes and the
native RedrivePolicy stays your safety net. With one set, leaving a poison or version-gated
message to the RedrivePolicy is no longer a conformant settle.
Only the strategy you set explicitly with unknownUrnStrategy(...) is read here (fail when none is
set). An onUnknownUrn hook, and the delete it implies for an unknown URN, never applies to a
poison or version-gated message, so a hook-only consumer never drops one silently. With a DLQ URL
the dead_letter unknown-URN strategy also writes the envelope with a dead_letter block
(reason: "unknown_urn") to it; without one it degrades to delete.
Because the wire format is the canonical envelope, the message a handler receives may have been produced by any BabelQueue SDK.