Skip to content

Publish SNS headers the way SQS subscribers read them - #108

Open
florianPat wants to merge 1 commit into
brefphp:masterfrom
florianPat:fix/sns-to-sqs-header-round-trip
Open

florianPat wants to merge 1 commit into
brefphp:masterfrom
florianPat:fix/sns-to-sqs-header-round-trip

Conversation

@florianPat

Copy link
Copy Markdown

The problem

A message published by SnsTransport cannot be consumed from an SQS queue subscribed to that topic — neither with this package's own SqsConsumer nor with Symfony's Amazon SQS transport.

SnsTransport::send() puts the whole header set into one message attribute named Headers:

'MessageAttributes' => [
    'Headers' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => json_encode($headers, JSON_THROW_ON_ERROR)]),
],

That is the convention SnsConsumer reads. SqsConsumer reads a different one, the one symfony/amazon-sqs-messenger writes: an X-Symfony-Messenger attribute holding the headers that cannot be sent individually, and then one attribute per remaining header.

if (isset($attributes[self::MESSAGE_ATTRIBUTE_NAME]) && $attributes[self::MESSAGE_ATTRIBUTE_NAME]['dataType'] === 'String') {
    $headers = json_decode($attributes[self::MESSAGE_ATTRIBUTE_NAME]['stringValue'], true);
    unset($attributes[self::MESSAGE_ATTRIBUTE_NAME]);
}

foreach ($attributes as $name => $attribute) {
    // ...
    $headers[$name] = $attribute['stringValue'];
}

Headers is not X-Symfony-Messenger, so it falls through to the second loop and arrives as a single header literally named Headers, whose value is the JSON of the real ones. The envelope ends up without a type header and Serializer::decode() fails.

SNS to SQS fan-out is a normal way to use SNS, and both consumers ship in this package, so the mismatch is worth closing here rather than in every application.

The change

send() now writes both conventions. The Headers attribute is published exactly as before, so SnsConsumer and any message already in flight are unaffected; alongside it the headers are published the way an SQS subscriber expects.

Which headers can travel as their own attribute is decided by the rules AWS documents for attribute names. That check mirrors Connection::send() in symfony/amazon-sqs-messenger, which this package already requires, so the two stay comparable:

  • Symfony's serializer emits stamp headers such as X-Message-Stamp-Symfony\Component\Messenger\Stamp\DelayStamp, and a backslash is not valid in an attribute name.
  • SNS rejects an empty attribute value.
  • Headers and X-Symfony-Messenger themselves would collide with the two aggregates.

Anything in those categories goes into the X-Symfony-Messenger aggregate instead, so no header is lost.

Tests

Four functional tests in SnsTransportTest, including one that reconstructs the headers the way SqsConsumer does and asserts they match what was published. vendor/bin/phpunit is green (45 tests) and vendor/bin/phpstan reports no errors.

Trade-off worth naming

The headers are now on the wire twice: once in Headers, once as attributes or in X-Symfony-Messenger. That costs a few hundred bytes of the 256 KB attribute budget and two to three of the ten available attributes.

The alternative would be to teach SnsConsumer the X-Symfony-Messenger convention and drop Headers, which avoids the duplication but breaks every message published by an older version of this package while a deployment is in flight. I went for the compatible option, but I am happy to switch if you would rather make it a clean cut, or put the new behaviour behind a transport option.

A topic published to by SnsTransport cannot be consumed from a subscribed
SQS queue. The transport puts every header into a single "Headers"
message attribute, which is the convention SnsConsumer reads. SqsConsumer
and Symfony's own Amazon SQS transport read a different one: an
"X-Symfony-Messenger" attribute holding the headers that cannot be sent
individually, plus one attribute per remaining header.

Because "Headers" is not "X-Symfony-Messenger", it falls through to the
second step and arrives as a single header literally named "Headers" whose
value is the JSON of the real ones. The envelope therefore has no "type"
header and decoding fails.

SNS to SQS fan-out is a common enough setup that the two consumers this
package ships should agree, so send() now writes both conventions. The
"Headers" attribute is untouched, so SnsConsumer and messages already in
flight are unaffected.

Which headers can travel as their own attribute is decided by the rules
AWS documents for attribute names, mirroring Connection::send() in
symfony/amazon-sqs-messenger, which this package already depends on.
Symfony's serializer emits stamp headers such as
"X-Message-Stamp-Symfony\Component\...", and a backslash is not valid in
an attribute name, so those go into the aggregate instead. So does a
header with an empty value, which SNS rejects outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wbzh5AJ1EjPE62vYwUsPUZ
@florianPat
florianPat force-pushed the fix/sns-to-sqs-header-round-trip branch from 586dfbf to 7f584b2 Compare August 30, 2026 08:52
@mnapoli

mnapoli commented Aug 30, 2026

Copy link
Copy Markdown
Member

I'll leave this one for review by other users of this package. Let's give it a week or two. If no-one has voiced an opinion here, I'll merge it (feel free to ping me then because I will likely have forgotten 😅)

@mnapoli

mnapoli commented Aug 30, 2026

Copy link
Copy Markdown
Member

(oh btw is there an issue this would fix?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants