sqs

package module
v1.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 14 Imported by: 0

README

babelqueue-go/sqs

Amazon SQS transport for the BabelQueue Go runtime — "Polyglot Queues, Simplified."

It implements §3 of the broker-bindings contract: the canonical envelope is the message body, and the contract fields are projected onto native SQS MessageAttributes so a PHP/Python/… peer can route and correlate without parsing the body.

Install

go get github.com/babelqueue/babelqueue-go/sqs

Use

ctx := context.Background()

tr, err := sqs.New(ctx,
    sqs.WithRegion("eu-central-1"),
    // sqs.WithEndpoint("http://localhost:4566"), // LocalStack/ElasticMQ
)
if err != nil { log.Fatal(err) }

app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))

// produce
app.Publish(ctx, "urn:babel:orders:created", map[string]any{"order_id": 1042})

// consume
app.Handle("urn:babel:orders:created", func(ctx context.Context, env babelqueue.Envelope) error {
    // env.Data, env.TraceID ...
    return nil
})
app.Run(ctx)

Credentials come from the standard AWS default provider chain (env vars, shared profile, instance role, IRSA, SSO). FIFO queues: add sqs.WithFIFO(true) (the queue name must end in .fifo).

How it maps to the contract (§3)

Envelope SQS
body MessageBody (byte-identical across SDKs)
job (URN) MessageAttributes.bq-job
trace_id MessageAttributes.bq-trace-id
meta.id MessageAttributes.bq-message-id
meta.schema_version MessageAttributes.bq-schema-version (Number)
meta.lang MessageAttributes.bq-source-lang
meta.created_at MessageAttributes.bq-created-at (Number, ms)
attempts reconciled to ApproximateReceiveCount − 1 on receive
reserve / ack visibility timeout → DeleteMessage
release (handler failure, unknown-URN release) ChangeMessageVisibility(VisibilityTimeout = backoff s) — never deleted or re-sent
out-of-band headers (e.g. traceparent) extra String MessageAttributes (see below)

The envelope is unchanged (schema_version stays 1); SQS is purely additive.

Release and the poison-message risk

When a handler fails (and attempts remain) or an unknown URN uses the release strategy, the App releases the message in place with ChangeMessageVisibility. The delay is WithRetryBackoff / WithUnknownURNReleaseDelay — default 0 s, i.e. the message becomes visible again immediately (clamped to SQS's 0–43200 s). If that call fails, the message is left reserved and SQS redelivers it when its visibility timeout lapses; it is never deleted or re-sent. The IAM principal needs sqs:ChangeMessageVisibility.

A zero delay means a message that always fails is redelivered in a tight loop until the App's WithMaxAttempts is reached (counted from ApproximateReceiveCount, surfaced as ReceivedMessage.DeliveryCount — so the bound also holds for a body that does not decode). A poison body (it does not decode, or has no identity) is never retried by the App: with WithDeadLetter(true) (or StrategyDeadLetter) it is written to the dead-letter queue as the ADR-0035 record and then deleted (on SQS that queue is <queue>-dlq by default, FIFO <queue>-dlq.fifo: a queue name cannot contain a dot; WithDeadLetterQueue on the App overrides it, and WithDeadLetterSuffix replaces -dlq, placed before .fifo on a FIFO queue); StrategyDelete deletes it at once; and with dead-lettering off it is not deleted and not re-sent — the transport's SettleNative capability leaves it to SQS on the first delivery (the event is logged), so it becomes visible again after the visibility timeout and the queue's RedrivePolicy moves it to the native DLQ after maxReceiveCount (without a RedrivePolicy it is redelivered until the retention period ends; ADR-0037 decision 2). A handler failure still follows WithMaxAttempts and, once exhausted with dead-lettering off, is deleted. To keep such failed bodies intact in the broker-side DLQ, set WithMaxAttempts above the queue's maxReceiveCount — note this is App-wide, so it also routes every handler failure to the broker DLQ (raw, without a dead_letter block) instead of the App's WithDeadLetter queue. A failed ChangeMessageVisibility (e.g. a missing IAM permission) is reported through WithReleaseErrorHandler; the retry then waits for the queue's visibility timeout rather than WithRetryBackoff. A failed DeleteMessage after a successful handler is an ack failure, not a handler failure: it is reported through WithAckErrorHandler and the message is not released. As a broker-side backstop — and for any consumer that stops before dead-lettering it — configure a RedrivePolicy with a maxReceiveCount on every queue, and set a non-zero WithRetryBackoff where a hot retry loop is undesirable.

Schema-version gate (§3.7)

Pop reads the bq-schema-version message attribute before it decodes the body. A missing or blank attribute (empty, or only ASCII whitespace) takes the normal path. Anything else is compared as-is — never trimmed — with the version the core supports (1); on a mismatch ("2", "x", "01", " 1", a non-ASCII space, ...) the body is not decoded and is handed to the App untouched with the reserved route babelqueue:unsupported-schema-version:<v>. No handler owns that route, so the App dead-letters the message directly, with no retry — whatever WithMaxAttempts is (the handler is never reached): with WithDeadLetter(true) it lands on the dead-letter queue as the incoming body, byte-identical (never decoded, no dead_letter block). Without dead-lettering it is not deleted: it is left to SQS (no DeleteMessage, no re-send; the event is logged), so visibility expiry redelivers it — the gate rejects it again — and the queue's RedrivePolicy moves it to the native DLQ after maxReceiveCount (ADR-0037 decision 1, rule 3; before, it was acknowledged and dropped). StrategyDelete is the one exception: it drops the message (acknowledged, no dead-letter record). If the dead-letter write itself fails the message is not deleted: it stays in the queue and becomes visible again after the visibility timeout, and the failure is logged and reported to WithDeadLetterErrorHandler. A retry could not change the outcome, so none is attempted. This needs a core release with the direct dead-letter path (see the root CHANGELOG.md).

The rejected version in the hook, the route and the dead-letter record is bounded to its first 64 characters plus ..., so a hostile attribute cannot bloat them.

An optional hook reports each gated message — useful to alert on a producer that is ahead of this consumer. It runs before the body is touched, so it also fires for a body that is not valid JSON, and a panic in it is recovered and ignored:

tr, _ := sqs.New(ctx, sqs.WithOnSchemaVersionRejected(func(version, queue string) {
    log.Printf("quarantined schema_version=%q from %s", version, queue)
}))

Without the hook the gate still works; the App's error names the rejected version.

Out-of-band transport headers

This transport implements the optional babelqueue.HeaderPublisher capability: out-of-band headers ride as String MessageAttributes beside the frozen envelope, never in it (GR-1), and are surfaced back to the consumer on ReceivedMessage.Headers. So the optional otel module's W3C traceparent (ADR-0028) — and the bq-replay-bypass marker (ADR-0027) — propagate over SQS for true cross-hop span parent-child linkage. Headers are merged beside the contract bq-* attributes without clobbering them, and bounded by the 10-attribute SQS limit (the contract attributes are always preserved first).

Test

go test ./...                       # unit (fake SQS client, no broker)
go test -tags=integration ./...     # integration (needs LocalStack at SQS_ENDPOINT)

The default unit run fakes the AWS client. The New config-loading path is covered by the LocalStack integration test (-tags=integration).

License

MIT

Documentation

Overview

Package sqs is an Amazon SQS-backed babelqueue.Transport for the BabelQueue Go runtime. Producing sends the canonical envelope as the message body and projects the contract envelope fields onto native SQS MessageAttributes (bq-job = URN, bq-trace-id = trace_id, bq-message-id = meta.id, plus bq-schema-version / bq-source-lang / bq-created-at) — so a PHP/Python/... peer can route on bq-job and correlate on bq-trace-id without parsing the body. Consuming uses the visibility-timeout reservation model (ReceiveMessage → process → DeleteMessage); the authoritative attempt count is the broker's ApproximateReceiveCount, reconciled onto the envelope as attempts = count − 1.

tr, _ := sqs.New(ctx, sqs.WithRegion("eu-central-1"))
app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))

This binding implements §3 of the BabelQueue broker-bindings contract. The envelope is unchanged (schema_version stays 1); SQS is purely additive.

It also implements the optional babelqueue.HeaderPublisher capability: out-of-band transport headers (e.g. a W3C traceparent for cross-hop span linkage, ADR-0028, or the bq-replay-bypass marker, ADR-0027) ride as String MessageAttributes beside the frozen envelope — never in it (GR-1) — and are surfaced back to the consumer on babelqueue.ReceivedMessage.Headers.

Before the attempts reconcile, a bq-schema-version message attribute that is present but not the core's supported version is version-gated (§3.7): the body is not decoded and the message is routed to a reserved unsupported-version URN no handler owns, so the App dead-letters it directly, with no retry (whatever the unknown-URN strategy, StrategyRelease included). With dead-lettering off it is not deleted: the transport's Transport.SettleNative leaves it to SQS (visibility expiry redelivers it, the queue's RedrivePolicy moves it to the native DLQ after maxReceiveCount); a poison body takes the same native settle on the first delivery. The optional OnSchemaVersionRejected hook names the rejected version.

Full spec: https://babelqueue.com

Index

Constants

View Source
const MaxDelaySeconds = 900

MaxDelaySeconds is the SQS ceiling for a per-message DelaySeconds (15 min, §3.4).

View Source
const MaxVisibilityTimeoutSeconds = 43200

MaxVisibilityTimeoutSeconds is SQS's ceiling for a visibility timeout (12 h).

Variables

View Source
var ErrInvalidDelay = errors.New("babelqueue/sqs: delay not supported by this queue (§3.4)")

ErrInvalidDelay is returned by Transport.PublishDelayed when the delay cannot be honoured without silently dropping or truncating it: more than MaxDelaySeconds, or any positive per-message delay on a FIFO queue (SQS FIFO supports only a queue-level delay).

View Source
var ErrReleaseUnsupported = fmt.Errorf("babelqueue/sqs: release needs ChangeMessageVisibility and a receipt handle: %w", babelqueue.ErrReleaseUnsupported)

ErrReleaseUnsupported is returned by Transport.Release when the injected client does not implement VisibilityAPI, or the message has no receipt handle. It wraps babelqueue.ErrReleaseUnsupported, which is what makes the App fall back.

Functions

This section is empty.

Types

type API

type API interface {
	SendMessage(ctx context.Context, in *awssqs.SendMessageInput, optFns ...func(*awssqs.Options)) (*awssqs.SendMessageOutput, error)
	ReceiveMessage(ctx context.Context, in *awssqs.ReceiveMessageInput, optFns ...func(*awssqs.Options)) (*awssqs.ReceiveMessageOutput, error)
	DeleteMessage(ctx context.Context, in *awssqs.DeleteMessageInput, optFns ...func(*awssqs.Options)) (*awssqs.DeleteMessageOutput, error)
	GetQueueUrl(ctx context.Context, in *awssqs.GetQueueUrlInput, optFns ...func(*awssqs.Options)) (*awssqs.GetQueueUrlOutput, error)
}

API is the subset of the AWS SQS client the transport uses. The concrete *github.com/aws/aws-sdk-go-v2/service/sqs.Client satisfies it; a fake satisfies it in tests.

type OnSchemaVersionRejected added in v1.10.0

type OnSchemaVersionRejected func(version, queue string)

OnSchemaVersionRejected is called when the version gate (§3.7) quarantines a message whose bq-schema-version attribute is present but not the core's supported version. version is the raw attribute value and queue the queue it was received from. The gate runs before the body is decoded; the App's quarantine error names the reserved route, and this hook additionally hands an operator the rejected version and queue directly (including for a body that is not valid JSON). It is a diagnostic hook: a panic in it is recovered and ignored, and it never changes how the message is delivered, acked or retried.

type Option

type Option func(*Transport)

Option customizes New / NewWithClient.

func WithClient

func WithClient(client API) Option

WithClient injects a preconfigured SQS client (or a fake in tests), bypassing AWS config loading.

func WithContentDedup

func WithContentDedup(enabled bool) Option

WithContentDedup uses the queue's content-based deduplication instead of meta.id as the MessageDeduplicationId (FIFO only).

func WithEndpoint

func WithEndpoint(endpoint string) Option

WithEndpoint overrides the SQS endpoint — point it at LocalStack/ElasticMQ for local/CI (e.g. "http://localhost:4566").

func WithFIFO

func WithFIFO(enabled bool) Option

WithFIFO marks the queue as FIFO (its name must end in ".fifo"). Sends then carry a MessageGroupId (see WithMessageGroupID, default the queue name) and, unless content-based dedup is enabled, a MessageDeduplicationId = meta.id.

func WithMessageGroupID

func WithMessageGroupID(id string) Option

WithMessageGroupID sets the FIFO ordering group (default: the queue name).

func WithOnSchemaVersionRejected added in v1.10.0

func WithOnSchemaVersionRejected(fn OnSchemaVersionRejected) Option

WithOnSchemaVersionRejected registers an optional OnSchemaVersionRejected hook (default: none).

func WithQueueURLPrefix

func WithQueueURLPrefix(prefix string) Option

WithQueueURLPrefix sets a base queue-URL so a queue name resolves to "<prefix>/<name>" without a GetQueueUrl call (e.g. "https://sqs.eu-central-1.amazonaws.com/123456789012"). When unset, the transport resolves names via GetQueueUrl and caches the result.

func WithRegion

func WithRegion(region string) Option

WithRegion sets the AWS region (otherwise resolved from the environment/config).

func WithVisibilityTimeout

func WithVisibilityTimeout(seconds int32) Option

WithVisibilityTimeout sets the reservation window (seconds) applied on receive. Zero leaves the queue default.

func WithWaitTimeSeconds

func WithWaitTimeSeconds(seconds int32) Option

WithWaitTimeSeconds caps the ReceiveMessage long-poll wait (0–20). The runtime's per-iteration poll timeout still bounds it; this caps the upper limit.

type Transport

type Transport struct {
	// contains filtered or unexported fields
}

Transport implements babelqueue.Transport over Amazon SQS. It is safe for concurrent use; the queue-URL cache is guarded by a mutex.

func New

func New(ctx context.Context, opts ...Option) (*Transport, error)

New builds a transport, loading AWS configuration (the default credential provider chain) unless a client was injected with WithClient. Apply WithRegion/WithEndpoint to target a region or LocalStack/ElasticMQ.

func NewWithClient

func NewWithClient(client API, opts ...Option) *Transport

NewWithClient builds a transport over a preconfigured client (or fake). It never touches AWS config; useful for tests and dependency injection.

func (*Transport) Ack

Ack deletes the reserved message (DeleteMessage on its receipt handle).

func (*Transport) DeadLetterQueueName added in v1.12.0

func (t *Transport) DeadLetterQueueName(queue, suffix string) string

DeadLetterQueueName implements babelqueue.DeadLetterNamer: the BabelQueue DLQ of an SQS queue is "<queue>-dlq" by default and, for a FIFO queue, "<queue>-dlq.fifo". An explicit suffix (set with babelqueue.WithDeadLetterSuffix; "" means none) replaces "-dlq" and is placed before ".fifo" as well: an SQS FIFO queue name must end in ".fifo", so "orders.fifo" + "-x" is "orders-x.fifo", never "orders.fifo-x". An SQS queue name may hold only alphanumerics, hyphens and underscores (plus that one ".fifo" suffix), so the runtime-wide default "<queue>.dlq" cannot be created on SQS (ADR-0037 decision 7). An explicit babelqueue.WithDeadLetterQueue overrides all of it.

func (*Transport) Pop

func (t *Transport) Pop(ctx context.Context, queue string, timeout time.Duration) (*babelqueue.ReceivedMessage, error)

Pop reserves the next message (long-poll up to timeout, capped at 20s and by WithWaitTimeSeconds). It reconciles attempts to max(body.attempts, ApproximateReceiveCount − 1) so a crash-redelivered message reflects its true delivery count. Returns (nil, nil) when no message arrives.

func (*Transport) Publish

func (t *Transport) Publish(ctx context.Context, queue, body string) error

Publish sends body to queue with the contract MessageAttributes projection.

func (*Transport) PublishDelayed added in v1.11.0

func (t *Transport) PublishDelayed(ctx context.Context, queue, body string, delay time.Duration) error

PublishDelayed sends body with a per-message DelaySeconds (broker-bindings.md §3.4), rounding a fractional second up. A delay of zero or less behaves like Transport.Publish. A delay above MaxDelaySeconds, or a positive delay on a FIFO queue (WithFIFO), is rejected with ErrInvalidDelay and nothing is sent.

func (*Transport) PublishWithHeaders added in v1.1.0

func (t *Transport) PublishWithHeaders(ctx context.Context, queue, body string, headers map[string]string) error

PublishWithHeaders sends body together with out-of-band transport headers (babelqueue.HeaderPublisher). The headers ride as String MessageAttributes beside the frozen envelope (GR-1) — e.g. a W3C traceparent for cross-hop span linkage (ADR-0028) — merged on top of the contract bq-* attributes without clobbering them, and bounded by the 10-attribute SQS limit. A nil/empty map behaves exactly like Transport.Publish.

func (*Transport) Release added in v1.9.0

func (t *Transport) Release(ctx context.Context, msg *babelqueue.ReceivedMessage, delay time.Duration) error

Release implements babelqueue.Releaser per contract §3: the reserved message is neither deleted nor re-sent; its visibility timeout is set to delay (whole seconds, clamped to 0–43200) via ChangeMessageVisibility, so SQS redelivers the same message after the backoff (0 = redeliver now). The broker's ApproximateReceiveCount stays the authoritative attempt counter — it is reconciled onto attempts on the next Pop and surfaced as babelqueue.ReceivedMessage.DeliveryCount, which bounds retries even for a body that does not decode.

func (*Transport) SettleNative added in v1.12.0

SettleNative implements babelqueue.NativeSettler (ADR-0037 decisions 1 and 2): a version-gated delivery or a poison body that the App may neither process nor drop, with dead-lettering off, is left to SQS itself. It deliberately does nothing: no DeleteMessage, no ChangeMessageVisibility, no re-send. The message becomes visible again when its visibility timeout lapses (the gate or the poison check rejects it again), and the queue's RedrivePolicy moves it to the native DLQ once maxReceiveCount is reached; without a RedrivePolicy it is redelivered until the retention period ends. The body and its attributes are never touched, and the App has already logged the event.

type VisibilityAPI added in v1.9.0

type VisibilityAPI interface {
	ChangeMessageVisibility(ctx context.Context, in *awssqs.ChangeMessageVisibilityInput, optFns ...func(*awssqs.Options)) (*awssqs.ChangeMessageVisibilityOutput, error)
}

VisibilityAPI is the optional client capability used by Transport.Release: ChangeMessageVisibility on a receipt handle (contract §3 release/nack). The concrete *github.com/aws/aws-sdk-go-v2/service/sqs.Client satisfies it. It is kept separate from API so existing API implementations stay valid; a client without it makes Release return ErrReleaseUnsupported and the runtime falls back to re-publish + delete.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL