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.