All notable changes to babelqueue (Python) are documented here.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
The envelope wire format is versioned separately by meta.schema_version
(currently 1) — see the contract at babelqueue.com.
- Azure Service Bus:
publishno longer fails withValue supplied for int invalid. The pure-Python AMQP stack inazure-servicebusencodes a bare Python int as AMQP int32, so the millisecondbq-created-atapplication property (broker-bindings.md §4.3) overflowed at send time. Out-of-int32 integer properties are now sent as AMQPlong, matching the Go, Java, .NET and Node transports. The native projection and the envelope body are unchanged.
Implements ADR-0032 (conformance packaging, results format, ASCII trim and the non-string job rule).
The envelope stays at schema_version: 1.
EnvelopeCodec.decoderaisesBabelQueueErrorfor a JSON object with neither a valid stringjobnor a valid stringurn(ADR-0032 section 6). Malformed or non-object input still yields{}. The runtime dispatch, the transports' attempts reconciliation andredriveuse the newdecode_lenient, so such a body still takes the poison / pass-through path.- A non-string
job(None, number, boolean, object, array, falsy or not) is ignored with a warning and a valid stringurntakes over. - Trim and blank checks in
make(urn,trace_id) andaccepts(trace_id) use the closed ASCII set (space,\t,\n,\v,\f,\r);str.strip()Unicode whitespace is no longer trimmed (ADR-0032 section 5). The set is exported ascodec.ASCII_WHITESPACEand the transports import it.
EnvelopeCodec.decode_lenient.- Conformance runner covers the v1.5.0 sections and writes
results.json(ADR-0032 section 2).conformance-known-gaps.jsonandscripts/check_conformance_results.pygate it in CI and release: the SQSdelayand Kafkabackofftier cases run and record honestfails until they are implemented.
Conformance suite 1.4.0, ADR-0031. The envelope is unchanged
(schema_version: 1); this is a MINOR with one deliberate behaviour change (encode rejects).
EnvelopeCodec.make(urn, data, *, queue, trace_id, meta=None): a producermetawrite API. The entries land inmetanext to the core keys (recommended shapemeta.ext.<vendor>). A collision with a core meta key or a forbidden key raises the newEnvelopeKeyError(subclass ofBabelQueueError, with apointerattribute)._envelope_shape.CORE_META_KEYSis the single copy of the core key list.decodefolds theurnalias intojob(ADR-0031 (c)): ajobthat is missing, empty or only ASCII whitespace (" \t\n\v\f\r"; a U+2003jobis NOT blank) falls back tournsilently; when both carry a value,jobwins andurnis dropped with a warning naming/urn.accepts()/urn()follow the same rule. The decoded envelope never keeps aurnkey.- Tests: the runner now implements the 1.4.0 fields (
expect_preservedonforbidden_keys;expect_absent_after_reencode,apply_dead_letter,expect_dead_letteronroundtrip, the latter viadead_letter.annotate) plus theextension_writeandencode_rejectsections, with a runner self-test proving violations are reported.
- Behaviour change:
EnvelopeCodec.encoderaisesEnvelopeKeyErrorfor an envelope carrying a forbidden key (message-envelope.md section 10) or a top-levelurn, instead of dropping it with a warning (policy K-15 (a)). Nothing is written.decodestill warns and drops, so re-encoding a decoded envelope is safe. Code that built an envelope from a raw legacy body and relied on encode to clean it must decode it first (EnvelopeCodec.encode(EnvelopeCodec.decode(raw))). test_all_five_contract_keys_are_coveredis a subset check (the suite adds/urncases).- Docs only: the note in the previous release that the Go reference's case-insensitive envelope-key read "is tightened separately" is now closed — Go core 1.14.0 reads envelope keys exactly (case-sensitive, last duplicate wins), so every SDK now decides poison on the same exact-case keys.
- Raw-body re-encode paths now decode before they encode, so the new
encoderejection cannot strand a legacy body: the runtime's raw-body RELEASE re-publish (app.py), and the SQS and Pulsar attempts reconciliation (Kafka, Azure Service Bus and Artemis already decoded first). A body with a legacytimestampor only aurnalias is re-emitted normalised (forbidden key dropped and warned,urnfolded intojob); unknown top-level andmetakeys, includingmeta.ext, are preserved.
- Tests only: the vendored conformance suite is now 1.3.0 (exact-case envelope keys, last-wins duplicates: new
invalid-wrong-case-job,wrong-case-keys-roundtripandduplicate-keys-last-winsfixtures). No production change; the Python core already behaved this way. - Kafka: a poison record is dead-lettered wrapped (
dead_letter.reason: "poison"), like the Go reference (§6.5, ADR-0009). A value that is not valid UTF-8, not valid JSON, not a JSON object, or has a member of the wrong type (the exact set is listed in the next entry) used to reach<topic>.dlqas its raw bytes. It is now written as a body that keeps its keys when it is exactly one JSON object, otherwise{"raw": "<body>"}(bytes that are not UTF-8 are stored with U+FFFD substitutions), plus adead_letterblock (reason: "poison",original_queue,attemptsfrom thebq-attemptsheader,lang); the record key is preserved. A poison record is still never retried and is dropped when no DLQ is configured (theon_unknown_urnstrategy,DELETEincluded, no longer matters: see below). The version-gate rejection (bq-schema-version, §6.7) still dead-letters its record raw. The wrap is applied when the runtime writes the DLQ record (not when the record is read), so a poison record thatredriverestores into the DLQ it popped it from stays byte for byte, whatever that DLQ is named.original_queueis thebq-original-topicheader when present (else the consumed topic), like the Java, .NET and Node consumers; a body whose numbers cannot be written back as valid JSON (e.g.1e400) is carried as{"raw": ...}instead of producing an invalid DLQ body. A body that decodes but names no URN is not poison (unknown-URN path, as before). Consumers of the DLQ that expected the raw bytes of a malformed record must now read the wrapped body. - Kafka: the poison trigger set now equals what the Go reference's typed
Decoderejects. Behaviour change; the old Python-only rules (a lenientattemptsread,Accepts-style conformance checks) are gone. A record is poison when it is not valid UTF-8, not valid JSON (an empty or whitespace-only body, a BOM, trailing data, leading zeros,NaN/Infinity, single quotes), or not a JSON object (a barenullis not poison: it is an empty envelope and takes the unknown-URN path), or when a member has the wrong type:job,trace_id,meta.id|queue|langanddead_letter.reason|error|exception|original_queue|langmust be a string or null;meta.schema_version,meta.created_at,attemptsanddead_letter.failed_at|attemptsmust be an int64 integer literal or null (a string such as"3"or"2026", a fraction or exponent such as3.0/1e2, a bool, an array and an object are poison);data,metaanddead_lettermust be an object or null; every number insidedatamust be a finite double. Member names are matched exactly as spelled (case-sensitive, as the contract schema and the core decoder read them; aJOBorMetamember is an unknown member, not a typed one) and every duplicate key is checked. A record Go decodes is no longer poison, even when it is not a conformant envelope: a non-stringurnalias, an absentdata,"attempts": null(read as 0 and written back as 0) andmeta.schema_version: 2are routed by URN like any other record (Go's consume path never callsAccepts). The Kafka transport no longer delivers records under the shared non-numeric-attemptsroute; they are plain poison now.redriverestores a poison-routed record into the DLQ unchanged instead of replaying or re-wrapping it, even when itsjobstill reads as a URN. The only Python-specific limit is JSON nested deeper than the interpreter's recursion limit (about 1000 levels, Go allows 10000), which is treated as poison. - Known, intentional differences from the Go reference for a poison record:
dead_letter.attemptsis thebq-attemptsheader (Go writes 0) andoriginal_queueis thebq-original-topicheader when present (Go writes the consumed topic); a float literal such as1.0/1e2is re-formatted when the body is re-encoded. The wire envelope and thedead_lettershape are unchanged. - Kafka: any decode failure is poison; the partition is never left to wedge. Member names are matched case-sensitively, exactly as the core decoder reads them (no
encoding/json-style folding, nostr.casefold()); the Go reference's case-insensitive read is a known difference that contradicts the contract schema and is tightened separately. WhateverEnvelopeCodec.decoderaises after the shape scan (e.g.RecursionErrorat the interpreter's limit) is now routed as poison instead of escapingpop(which left the offset uncommitted and the partition redelivered forever); the poison wrapper falls back to{"raw": ...}onRecursionErroror a body that cannot be written as UTF-8. A lone surrogate escape ("\ud800") that survives into a retry/DLQ write is substituted by U+FFFD, as Go does, instead of raisingUnicodeEncodeError. Invalid UTF-8 remains poison (Go substitutes U+FFFD and accepts it). The wire envelope and thedead_lettershape are unchanged. - Kafka: a body the core JSON parser refuses is poison, not an empty envelope. The core decoder turns a
ValueErrorfromjson.loadsinto an empty envelope; where the shape scan accepts a body that plainjson.loadsrejects (Python 3.11+: an unknown member holding an integer of more than 4300 digits), the record would have reached a handler with its content dropped. It is now dead-lettered as poison. No change to the wire envelope or thedead_lettershape. - Kafka: a poison record is dead-lettered whatever
on_unknown_urnis. The runtime checked theDELETEstrategy before it recognised a poison record, so withon_unknown_urn="delete"a poison record was acknowledged and silently dropped even with a DLQ configured, where the Go reference writes it to<topic>.dlqwithdead_letter.reason: "poison"and commits. A poison delivery (PoisonRoute) is now settled before the strategy is consulted: written wrapped to the DLQ and committed underfail,delete,dead_letterandrelease, and dropped only when no DLQ is configured (unchanged). The version-gate path and every other transport keep their behaviour. No change to the wire envelope or thedead_lettershape.
- Kafka: a wrongly-typed
metaormeta.created_atin a wrapped poison body no longer fails the write that settles it (header projection and record timestamp tolerate it).
BatchPopper(optional transport capability, exported frombabelqueue) withpop_batch(queue, max=0, timeout=1.0): take several deliveries at once and hold them un-settled.KafkaTransportimplements it;redriveuses it when a transport offers it.
redriveno longer loops forever (defaultmax=0) or repeats records (max>1) on Kafka.redrivedrains the DLQ before it processes it, butKafkaTransport.popfirst seeks back to the previous un-acked record, so every pop returned the first record again. The drain now takes the batch throughpop_batch(one rewind up front, none between the polls), andKafkaTransporttracks several in-flight records: acks are committed in offset order, so the cumulative commit never passes a record that is still unsettled, and an abandoned batch is rewound to its lowest offset by the nextpop. The restore writes are not re-read in the same call. Other transports and the single-recordpop/ack/rewindbehaviour are unchanged. No wire change. A record whose envelope carries anattemptsthe runtime cannot read as an integer (a foreign producer:null,"", a list/object, a bool,"three",NaN/Infinity; an absent key is just 0) no longer raises out ofpopor the restore publish.KafkaTransport.pophands it over under a reserved poison route (like a non-UTF-8 record), so the runtime dead-letters it raw without running a handler (a failing handler can no longer wedge the partition) andredriverestores it to the DLQ instead of replaying it. The redundantbq-attemptsheader is left out of such a write.redrivenow carries the DLQ record key onto the redriven record. The restore writes back to the DLQ already kept the key; the redrive write to the work topic did not, so a replayed Kafka record lost its key (and per-key partition affinity). It now goes throughpublish_keeping_route(..., key=message.key), byte-for-byte (null stays null, empty stays empty), per contract §6.5 "Record key on republish". Non-keyed transports behave exactly as before; thebypass=Trueheader path still carries no key. No wire change.
SettlePublisher(optional transport capability, exported frombabelqueue) withpublish_settle(queue, body, route_urn=""): the runtime's settle writes (retry republish, envelope / poison / gate-rejection DLQ,redriverestore) go through it when a transport implements it.KafkaTransportimplements it.KafkaDeliveryError(aBabelQueueError) and the optionalKafkaTransport(delivery_timeout=30.0)constructor argument.ReceivedMessage.key(Optional[bytes],None= null key) and an optionalkey=argument onSettlePublisher.publish_settle()/publish_keeping_route().KafkaTransport.pop()fills the key from the consumed record.
- Kafka: the original record key now survives the retry republish, the envelope DLQ and the
raw (poison / gate-rejection) DLQ. The key was dropped on every settle write, so a keyed record
lost its key (and with it per-key partition affinity) after the first retry. A null key stays
null, an empty key stays empty, and a key is never synthesised.
redriverestores to<queue>.dlqalso keep it. Partition equality is not guaranteed. The envelope and thedead_letterblock are unchanged. - Kafka: retry / dead-letter writes are now broker-confirmed before the offset is committed.
KafkaTransport.publish_settle()waits for this write's own delivery report (flush(delivery_timeout)). A delivery error or timeout raisesKafkaDeliveryError, nothing is committed, and the record is re-read through the existing rewind/seek path — so such a write can no longer be lost to a commit that raced ahead of it.publish()(and soapp.publish()) is unchanged: still fire-and-forget and strict (a body that is not valid UTF-8 still raisesUnicodeEncodeError), also from inside a handler. - Kafka: a record whose value is not valid UTF-8 is dead-lettered raw instead of raising. The
original bytes are written to
<queue>.dlqunchanged, with no projected headers (as Java/Go/Node do for poison records), and the offset is committed only once that write is confirmed; with the DLQ off, orUnknownUrnStrategy.DELETE, the record is dropped and committed. It reuses the version-gate settle path, so a failed write logs as a "version-gated" message — the logged route (...:non-utf8-payload) tells them apart. A non-UTF-8bq-attemptsheader no longer raises. No wire change.
- Schema-version gate rejection now dead-letters the incoming body, byte-identical. A message
whose
bq-schema-version(bq_schema_versionon Artemis) is unsupported is rejected without decoding it (broker-bindings §3.7/§4.7/§5.7/§6.7/§7.7) and, when the DLQ is enabled, the DLQ record is the raw body as received, with nodead_letterblock. Previously the core decoded the envelope and wrote an annotateddead_letter{reason:"unknown_urn"}record. This aligns the Python SDK with Java, .NET and Node. A DLQ consumer that expected the annotation for gated messages is affected; a failed DLQ write still leaves the message unsettled (rewound on Kafka), and DLQ off /UnknownUrnStrategy.DELETEstill drops it. No wire or API change.
- Kafka: a record whose settlement raised was silently lost on a consume-loop restart. When a
retry / release / dead-letter
publishfailed (or the loop was otherwise interrupted beforeack),consume()ended with the record neither acked nor rewound whilepollhad already advanced the position; the restarted loop received the next record and acking it committed past the unsettled one (Kafka offsets are cumulative).KafkaTransportnow remembers the recordpophanded out and re-seeks it on the nextpopunless it was acked or rewound. No API or wire change.
- New optional
Rewinderprotocol (rewind(message)), implemented byKafkaTransportand exported frombabelqueue(public API, MINOR). The runtime calls it after a failed version-gate dead-letter write and then pauses briefly (1 s) before the next poll.
bq-schema-versionpre-decode gate on the SQS, Azure Service Bus, Kafka and Artemis transports (pop). The broker's schema-version property is read before the body is decoded (broker-bindings §3.7 / §4.7 / §6.7 / §7.7), mirroring the existing Pulsar gate: a missing or blank value (empty or only ASCII whitespace) takes the normal path; anything else is compared untrimmed to"1"and a mismatch is returned with the raw body and the reserved routebabelqueue:unsupported-schema-version:<v>(version percent-escaped) without decoding it. SQS/ASB/Kafka readbq-schema-version, Artemisbq_schema_version(ADR-0017); ASB and Artemis also accept an integer1(aboolis not1). The conformance suite now reads each binding'sschema_version_gatetable (including Pulsar's). No wire change.- A version-gated delivery is dead-lettered directly, never retried.
BabelQueuenow sends a message under the reserved gate route straight to the DLQ (reasonunknown_urn, body untouched) whendead_letteris enabled and drops it otherwise, regardless ofmax_attempts/on_unknown_urn. A retry would re-publish through paths that rebuild the version header from the (valid) body (ASB/Kafka/Artemis) or never advance the attempt counter (SQS), letting the gate be bypassed or the message loop. Applies to Pulsar too (previously it retried up tomax_attemptsfirst). If the dead-letter write itself fails, the message is still never retried: it is left unacknowledged (an error is logged) so the broker redelivers it and the gate rejects it again (SQS at the visibility timeout, Azure Service Bus at lock expiry, Artemis when the link or connection closes). Kafka commits offsets cumulatively, so merely not acknowledging the record would lose it once a later record is committed:KafkaTransportnowseeks the partition back to the failed record's offset so it is polled again and the dead-letter write is retried (no commit moves past it). - The rejected
bq-schema-versionvalue is capped at 64 characters plus...in the log and the route.
ReceivedMessage.route_urnrouting seam (MINOR). An optional field a transport can set when its broker carries the URN out of band. When non-emptyBabelQueue.dispatch()looks the handler up by it instead of the decoded body's URN; empty (the default for every existing transport) keeps routing on the body. The body is never rewritten, so the handler, the retry re-publish and the dead-letter record carry the producer's original body (including aurnalias). No wire change.RoutedPublisheroptional transport capability (MINOR).publish_routed(queue, body, route_urn). When a delivery has aroute_urnand the transport implements it, the retry, release and dead-letter writes, andredrive, use it so the broker's routing property keeps the original value across attempts (no flip to the body URN on the second delivery).PulsarTransportimplements it (bq-jobpinned; blank derives from the body). Others keep plainpublish. No wire change.
BabelQueue.dispatch()appliesroute_urnonly to a body that decodes; an undecodable body stays on the poison / unknown-URN path instead of being routed to a handler.
on_unknown_urn=RELEASEno longer loops a version-gated or poison message forever.BabelQueue.dispatch()re-published such a message unchanged on every delivery (attemptsnever grew, so it never reached the DLQ). A delivery a transport version-gated (reservedunsupported-schema-versionroute_urn, e.g. Pulsarbq-schema-version), an undecodable body and a body naming no URN now take the boundedFAILpath underRELEASE(retry up tomax_attempts, then dead-letter or drop), matching the Go core (GR-5). A genuinely unknown URN still releases verbatim. No public API or wire change. Note: as on the existingFAILpath, a body that is not valid JSON is not carried verbatim through the retry / dead-letter re-publish (the runtime re-encodes the envelope it could build, so the producer's original bytes are lost); previouslyRELEASEre-published those bytes unchanged.- Pulsar consumer version-gates on
bq-schema-version(broker-bindings §5.7 step 3).PulsarTransport.pop()now reads thebq-schema-versionmessage property before decoding the body. A missing or blank value changes nothing; "Blank" is the same set in every SDK: the empty string or only ASCII whitespace (space,\t,\n,\v,\f,\r); anything else (NBSP, U+001C-U+001F, U+0085, U+FEFF, U+3000, ...) is not blank and is rejected unless it is exactly the supported version. (str.strip()is not used: it also strips U+001C-U+001F, U+0085, NBSP, ...) a value other than the supportedSCHEMA_VERSION("2","x","01", ...) is not decoded: the delivery carries the reservedroute_urnbabelqueue:unsupported-schema-version:<value>, which no handler owns, so the runtime quarantines it through the existing unknown-URN retry / dead-letter path (the error names the rejected version). The body is passed through untouched. No wire change. The route is an in-process marker only: it also carries the delivery's originalbq-job, and the retry / dead-letter re-publish (publish_routed) writes that originalbq-job(or, when the delivery had none, the body's job) and the original rawbq-schema-versionback, never the sentinel, so a redrive meets the same gate. The gate also logs ababelqueue.pulsarwarning naming the rejected version and queue, including for a body that is not valid JSON (which the runtime quarantines as a poison message whose error names the reserved route). - Pulsar consumer now follows broker-bindings §5.2 / §5.7.
PulsarTransport.pop()routes on thebq-jobmessage property (set onReceivedMessage.route_urn; the body's URN is used only when the property is absent or blank) and takesattemptsfrom the authoritativebq-attemptsproperty (the body's value is used only when the property is absent or not a plain ASCII-decimal integer that fits a 32-bit int), then appliesmax(.., redelivery_count). Previously only the body was read. The body'sjob/urnis not rewritten, so a property/body mismatch no longer leaks into retries or the DLQ;attemptsis written into the body only when the body already holds an integerattemptsand the value changes (a missing or non-integerattemptsis still rejected byEnvelopeCodec.accepts). When the property and the body name different URNs the property wins and the new optionalon_routing_mismatch(property_urn, body_urn, envelope)constructor hook is called for an accepted envelope, after attempts are reconciled (its exceptions are swallowed; it is separate from error handling). A body without a URN is still rejected. No wire change (schema_version: 1).
- Graceful shutdown of
BabelQueue.consume(): on SIGTERM/SIGINT the loop sets a stop flag, takes no new message, lets the in-flight handler finish (or releases the message unchanged once the newshutdown_timeout— default 30 s — expires), restores the previous signal handlers and closes the transport. Handlers are installed only on the main thread (handle_signals=Falseopts out); a second signal forces an immediate exit with the message released. A plainKeyboardInterruptkeeps its previous behaviour. NewBabelQueue.stop()ends the loop from any thread after the in-flight message. Redelivererprotocol (redeliver(message, body, delay)) — a transport's native release; the runtime uses it for retries, unknown-URNRELEASEand shutdown releases, and falls back to publish + ack otherwise. Newretry_backoff/unknown_urn_release_delaysettings feed its delay.- Conformance runners for the
roundtrip,data_shape,forbidden_keysandpayload_schema_unicodesections (none skipped). .github/dependabot.yml(pip + GitHub Actions, weekly).
- SQS release (broker-bindings §3.5): retries, unknown-URN
RELEASEand shutdown releases always callChangeMessageVisibilityinstead of re-sending, so the broker'sApproximateReceiveCountcarries the attempt count. There is no send + delete fallback. The default delay forretry_backoffandunknown_urn_release_delayis 0 s (immediate redelivery); a delay outside 0..43200 s (includinginf/nan) is clamped with a warning on thebabelqueue.sqslogger. A message without a receipt handle is not released (warning); one without a receive count is released but its attempts cannot advance (warning). Poison-loop risk: a message that always fails is redelivered immediately untilmax_attempts— configure a native SQSRedrivePolicy(maxReceiveCount≥max_attempts) as the backstop and a non-zeroretry_backoffwhere needed. A shutdown release also counts as a receive on SQS, so keepshutdown_timeoutabove the longest handler time. - Delivery semantics are documented as at-least-once: a handler that finishes right as the shutdown deadline fires may be released and run again.
- The SQS, Kafka, Pulsar, Azure Service Bus and Artemis transports' header/property projections read the envelope without logging a false forbidden-key drop (the body is sent unchanged).
- The five forbidden envelope keys (
timestamp,meta.max_retries,meta.attempts,meta.source,meta.ts— message-envelope §10) are dropped on decode with a warning on thebabelqueue.codeclogger, and never written on encode. Transitional: encode currently drops them with a warning; a future MINOR (K-15 / R1-E0) will reject them instead. A raw-body release republish now strips them too (a clean body is re-sent byte for byte); DLQ redrive still restores the original bytes unchanged. BabelQueue.stop()called beforeconsume()starts is now honoured instead of being cleared.- A failed acknowledgement of an already-processed message (e.g. SQS
DeleteMessageafter a successful handler, an unknown-URNDELETE, or a dead-letter publish) is no longer treated as a handler failure: the message is not released or retried; the failure is logged atERRORon the newbabelqueue.applogger and the broker redelivers it on its own (SQS: after the visibility timeout). The consume loop keeps running.
- Runtime GDPR field encryption (ADR-0030) — the optional
babelqueue.gdprmodule ports the Go reference to Python: the SDK-enforcement half of governed PII. The babelqueue-registry only declares and auditsx-gdpr-sensitivefields (and can mask them for safe logging); this module enforces them on the wire — a producer encrypts each marked leaf before publish, a consumer decrypts it after decode. Strictly opt-in: a producer/consumer that never callsprotect/unprotectis unchanged. The envelope stays frozen (GR-1): only values insidedatachange — a sensitive leaf's value becomes a ciphertext string — sodatastays pure JSON (an SDK without the key still carries the envelope),meta.schema_versionstays1, andtrace_idis untouched (GR-4). The core stays stdlib-only (GR-7): Python's standard library has no AES-GCM, so the module ships no concrete cipher and pulls no crypto dependency —Cipheris a caller-providedtyping.Protocol(encrypt(bytes) -> str/decrypt(str) -> bytes) bound to a KMS / Vault / HSM / tokenisation service, or to a local AES-256-GCM via the optionalcryptographylibrary (newbabelqueue[gdpr]extra, documented in the README — still not a core dependency).protect(data, schema, cipher)canonically JSON-encodes each marked leaf and replaces it with the cipher's ciphertext string in place;unprotect(...)is the exact inverse, restoringdatabyte-for-byte. The marked paths come from the same per-URN schema the validator already loads —babelqueue.schema.sensitive_pathswalks nested objects (profile.full_name), array items (addresses[].line), container, and root marks, and thex-gdpr-sensitivekeyword is validation-neutral (annotating a schema is never breaking). An absent marked field is skipped (not an error); a re-rununprotecton already-cleartext data is a no-op (non-string leaves are left alone); a wrong key / tampered / non-ciphertext value raises the newbabelqueue.DecryptErrorso the consumer fails the message (retry / dead-letter) rather than handle unreadable PII. Validate cleartext beforeprotect/ afterunprotect, since a schema constraining a sensitive field would reject the ciphertext string. Unit-tested without any crypto dependency (a stdlib-only authenticated fake cipher) for round-trip exactness across nested/array/container/root marks, skips, idempotent re-runs, frozen-envelope decode/validate, and wrong-key failure. The envelope is unchanged (schema_version: 1); this is purely additive.
- Transactional outbox helper (ADR-0029) — the optional
babelqueue.outboxmodule ports the PHPBabelQueue\Outboxhelper to Python, removing the producer dual write: the message is persisted into your database, in the same transaction as the business data (so it commits or rolls back atomically with it), and a separate relay publishes the durable rows afterwards. No distributed transaction; exactly-once handoff into the broker, then at-least-once on the wire (the consumer dedupes onmeta.idvia the idempotency helper, the consumer-side mirror, ADR-0022). The core stays stdlib-only (GR-7):OutboxStoreis an abstracttyping.Protocolthe caller binds to their own DB — the module ships only the in-memoryInMemoryOutboxStorereference and pulls in no DB driver.Outbox.write(envelope)encodes via the frozenEnvelopeCodecand delegates toOutboxStore.saveinside the transaction the caller already opened — it does not begin/commit anything (the caller owns the transaction boundary).OutboxRelay.flush()publishes one batch through the existing publish-onlyTransport, marking each row published only after the transport accepts it, or failed (caught →mark_failed, row left pending, with a bounded linear backoff via an injectable sleeper) so one poison row never blocks the batch;OutboxRelay.drain()loops while a pass makes progress, with a safety ceiling. The relay publishes the stored bytes verbatim — it never decodes, rebuilds or re-encodes the envelope — sotrace_idis preserved end-to-end and the body is byte-compatible across SDKs (GR-1/GR-4/GR-5). Unit-tested without a broker (write stores the encoded envelope byte-identical; relay publishes via a fakeTransport+ marks published; a raising publish →mark_failed, row still pending, batch continues;drainloops to empty and stops on no-progress; backoff grows linearly and caps via the injected sleeper). The envelope is unchanged (schema_version: 1); this is purely additive.
- W3C
traceparentspan-context propagation (OpenTelemetry v0.2, ADR-0028) — the optionalbabelqueue.otelmodule now carries true cross-hop span parent-child linkage, not just shared-trace_idcorrelation. On publish,otel.publishinjects the active span context as a W3Ctraceparenttransport header (and still stampstrace_idfor the v0.1 fallback); on consume,otel.wrap_handlerextracts it and starts the CONSUMER span as a true child of the producer span. With notraceparentpresent it falls back to the v0.1trace_id-derived parent, so it is a strict, backward-compatible upgrade — no regression. The header rides out of band via a new dependency-free core seam —BabelQueue.publish_with_headers(urn, data, headers, …)(produce side) andbabelqueue.headers_from_context()(consume side, surfaced by the runtime) — so the wire envelope stays frozen (schema_version: 1, GR-1) and the core stays zero-dependency (OTel remains the optional[otel]extra, GR-7).traceparentis carried on the in-memory (reference), Redis (a transport-owned__bq_frameJSON frame with bare-value back-compat, so cross-version queues interoperate; degrades to a bare publish in Laravel-compat mode), RabbitMQ (native AMQP header table, beside the contractx-*headers) and SQS (nativeMessageAttributes, beside the contractbq-*attributes) transports; where a transport can't carry headers, propagation degrades cleanly to v0.1trace_idcorrelation with no error. A plainpublishis byte-identical to before. Unit-tested without a broker (frame round-trip + bare back-compat, header merge/extract per transport, and an in-memory producer→consumer parent-child end-to-end with the OTel SDK'sInMemorySpanExporter); broker-gated integration tests assert a publishedtraceparentarrives on the consumed message's headers beside the unchanged body. The envelope is unchanged; this is purely additive. Ships as a MINOR.
1.6.0 - 2026-06-14
- Redis/Laravel reservation parity — the Redis transport can now consume a
shared Laravel BabelQueue Redis queue using Laravel's reserved-set / reliable-queue
semantics, not just a Python-owned queue. Enable it with the
laravel=1URL flag (redis://host:6379/0?laravel=1) orRedisTransport(..., laravel_compat=True). In this mode the key layout is Laravel's stock Redis queue (§1 of the broker-bindings contract): aqueues:<name>ready list, aqueues:<name>:reservedsorted set scored by aretry_afterdeadline (default 60s), aqueues:<name>:delayedset, and aqueues:<name>:notifywake-up list. Reserve, ack and release run the byte-for-byte same Lua scripts Laravel uses, so the reserved member a Python worker writes is identical to a Laravel worker's — either side can ack (ZREM) the other's reservation, so a Python worker and a Laravel worker share one Redis queue without losing or double-processing messages. Before each pop, expired reserved/delayed jobs migrate back to the ready list, so a crashed worker's in-flight job is re-reserved exactly as Laravel does. Tunable via?prefix=/?retry_after=(or thekey_prefix/retry_afterconstructor args). The default Python-owned reliable-queue mode (BLMOVE+<queue>:processinglist) is unchanged and stays the default, so existing callers are unaffected. The reservation logic is fully unit-tested with an injected in-memory Redis double (noredispackage, no broker); a live cross-runtime PHP↔Python shared-queue round-trip is covered by the integration suite where a real Redis is present. The envelope is unchanged (schema_version: 1); this is purely additive. Ships as a MINOR.
- Apache ActiveMQ Artemis transport (
babelqueue[artemis],python-qpid-proton) —ArtemisTransport, selected by theartemis://(orartemis+ssl://) URL scheme (e.g.artemis://localhost:5672; or pass an injectedconnection). Artemis speaks AMQP 1.0 (not RabbitMQ's 0-9-1), so the transport uses thepython-qpid-protonblocking client. Implements §7 of the broker-bindings contract: the canonical envelope is the message body, projected onto the AMQP fields a JMS peer reads —correlation-id=trace_id(JMSCorrelationID),creation-time=meta.created_at(JMSTimestamp), thex-opt-jms-typeannotation = URN (JMSType, the AMQP-JMS mapping), plus thebq-schema-version/bq-source-lang/bq-attempts/bq-app-idapplication properties. Consume reserves one message at a time (receive→ process →accept);attemptsis reconciled tomax(body, delivery_count)— the AMQP delivery-count header is 0-based, so it maps directly with no −1 (the Java JMS binding reads the 1-basedJMSXDeliveryCountand subtracts 1, arriving at the same 0-basedattempts), and themaxnever lowers a higher body count carried by a republish-driven retry. The projection + reconciliation + pop/ack flow are unit-tested with no broker and nopython-qpid-proton(the proton import is lazy; the transport talks to an injected connection fake); the publish flow that builds a real protonMessageis exercised wherever proton is installed. The envelope is unchanged (schema_version: 1); Apache ActiveMQ Artemis is purely additive. Ships as a MINOR.
- Apache Kafka transport (
babelqueue[kafka],confluent-kafka) —KafkaTransport, selected by thekafka://URL scheme (e.g.kafka://host:9092; or pass an injectedproducer+consumer_factory). Implements §6 of the broker-bindings contract: the record value is the canonical envelope, projected onto native Kafka record headers (UTF-8 byte strings) —bq-job= URN,bq-trace-id,bq-message-id, plusbq-schema-version/bq-source-lang/bq-attempts— with the record timestamp mirroringmeta.created_at. Consume is process-then-commit (popreserves viapollwithenable.auto.commit=false,ackcommits the offset); thebq-attemptsheader is the authoritative attempt counter (the body'sattemptsis the fallback for non-BabelQueue producers). The projection + reconciliation + publish/pop/ack flow are unit-tested with no broker and noconfluent-kafka(the kafka import is lazy; the transport talks to injected producer/consumer fakes). The envelope is unchanged (schema_version: 1); Apache Kafka is purely additive. Ships as a MINOR.
- Apache Pulsar transport (
babelqueue[pulsar],pulsar-client) —PulsarTransport, selected by thepulsar://(orpulsar+ssl://) URL scheme (e.g.pulsar://localhost:6650; or pass a builtclient). Implements §5 of the broker-bindings contract: the canonical envelope is the message payload, projected onto native Pulsar message properties (string→string) —bq-job= URN,bq-trace-id=trace_id,bq-message-id=meta.id, plusbq-schema-version/bq-source-lang/bq-attempts; receive →acknowledge;attemptsreconciled tomax(bq-attempts, redelivery_count)(Pulsar's redelivery count is 0-based, so it maps directly with no −1, and themaxnever lowers a higher body count carried by a republish-driven retry). DefaultSharedsubscription namedbabelqueue; a client can be injected for tests/DI. The projection + reconciliation + publish/pop/ack flow are unit-tested with no broker and nopulsar-client(the pulsar import is lazy and publishing sends raw bytes). The envelope is unchanged (schema_version: 1); Apache Pulsar is purely additive. Ships as a MINOR.
1.2.0 - 2026-06-13
- Azure Service Bus transport (
babelqueue[azureservicebus],azure-servicebus) —AsbTransport, selected by thesb://URL scheme (e.g.sb://<namespace>.servicebus.windows.net, Azure AD viaDefaultAzureCredential; or passconnection_string=.../ a builtclient). Implements §4 of the broker-bindings contract: the canonical envelope is the message body, projected onto native Service Bus fields (Subject= URN,CorrelationId=trace_id,MessageId=meta.id, plus thebq-application properties); PeekLock reserve →complete_messageack;attemptsreconciled to the broker-authoritativeDeliveryCount − 1. A client can be injected for tests/DI. The projection + reconciliation are unit-tested with no broker and noazure-servicebus(the azure import is lazy); the publish flow is covered with a fake client. The envelope is unchanged (schema_version: 1); Azure Service Bus is purely additive. Ships as a MINOR.
1.1.0 - 2026-06-12
- Amazon SQS transport (
babelqueue[sqs],boto3) —SqsTransport, selected by thesqs://URL scheme (e.g.sqs://us-east-1?endpoint=http://localhost:4566for LocalStack). Implements §3 of the broker-bindings contract: the canonical envelope is theMessageBody, projected onto nativeMessageAttributes(bq-job/bq-trace-id/bq-message-id/bq-schema-version/bq-source-lang/bq-created-at); visibility-timeout reserve →delete_messageack;attemptsreconciled toApproximateReceiveCount − 1(never lowering a runtime-incremented count). Supports FIFO (MessageGroupId/MessageDeduplicationId=meta.id), content-based dedup, configurable wait/visibility, and aqueue_url_prefixthat skipsGetQueueUrl. A client can be injected for tests/DI. 16 unit tests run against a fake client (no boto3, no broker); a LocalStack integration test round-trips the realboto3path. The envelope is unchanged (schema_version: 1); SQS is purely additive. Ships as a MINOR.
1.0.0 - 2026-06-07
1.0.0 — the public API is now SemVer-stable: breaking changes require a MAJOR,
following the deprecation policy. The wire envelope is unchanged
(schema_version: 1); the core + Celery/Django adapters ship together. Full
reference at babelqueue.com.
- CI adds ruff + mypy static analysis and a >=90% coverage gate
(
pytest --cov --cov-fail-under=90, run in the broker-backed job so the Redis / RabbitMQ transports count). Type-safety fix inredis_transport(str-narrow the BLMOVE reply) surfaced by mypy — no behaviour change. - GR-8 latency benchmark (
tests/test_overhead.py) — asserts the envelope encode/decode path adds ≤2% over plain-JSON serialization vs a conservative 2ms broker round-trip (the pure-Python codec is slower than the compiled SDKs — ~16µs marginal on CPython 3.9/CI — so the reference is higher to stay robust).
0.5.0 - 2026-06-06
- Celery adapter (
babelqueue.celery,[celery]extra) —from_celery(app)builds aBabelQueueruntime on a Celery app's broker, andinstall_worker(app)registers a Celery worker bootstep that drains URN-routed polyglot messages in a background thread alongside Celery's own consumer. - Django adapter (
babelqueue.django,[django]extra) — settings-drivenBABELQUEUEconfig,get_app()/publish()shortcuts, and amanage.py babelqueue_workermanagement command. Add"babelqueue.django"toINSTALLED_APPS. - Both adapters lazy-import their framework, so the core stays dependency-free.
0.4.0 - 2026-06-06
EnvelopeCodec.urn()— resolve the URN (job, acceptingurnas an alias).EnvelopeCodec.accepts()— consumer-side envelope validation (rejects empty URN, unsupportedmeta.schema_version, blanktrace_id, non-objectdata).- Shared cross-SDK conformance suite under
tests/conformance/(vendored from the canonicalconformance/set) plus atest_conformance.pyrunner.
0.3.0 - 2026-06-06
- RabbitMQ transport (
PikaTransport,amqp://): durable queue, persistent delivery,basic_get+ manual ack, and the contract AMQP properties (type=URN,correlation_id=trace_id,x-schema-version/x-source-lang/x-attempts). Optional[amqp]extra (lazypikaimport) — the core stays zero-dep.
0.2.0 - 2026-06-06
- Runtime —
BabelQueue(broker_url=...)app with a@app.handler("urn:...")decorator,publish(), and aconsume()/run()loop. Routes by URN over the canonical envelope;attempts-based retry → opt-in dead-letter queue;on_unknown_urnstrategies (fail/delete/release/dead_letter). - Transports — a pluggable
Transportabstraction withInMemoryTransport(memory://, for tests/local) andRedisTransport(redis://, reliable-queue pattern viaBLMOVE+ a processing list). Redis client is an optional[redis]extra, imported lazily — the core stays zero-dep.
0.1.0 - 2026-06-06
EnvelopeCodec— builds (make,from_message), encodes and decodes the canonical{job, trace_id, data, meta, attempts}envelope (schema_version1). The single Python implementation of the wire format.- Contracts
PolyglotMessage/HasTraceId(typedProtocols). dead_letter.annotate()— additivedead_letterblock builder.UnknownUrnStrategy—fail/delete/release/dead_letter.BabelQueueError/UnknownUrnError.- Golden conformance fixtures under
tests/fixtures/(shared cross-SDK set). py.typed— ships inline type hints (PEP 561).
- Pre-1.0: the public API may change before the
1.0.0tag. - The core has zero runtime dependencies (standard library only); Python
>=3.9.