Skip to content

Redis: RedisEnvelopeMapper duplicates the protocol version header when re-sending a received envelope #4595

Closed

Description

@jeremydmiller

RedisEnvelopeMapper writes the protocol version header twice whenever it re-sends an envelope it received. The field name is wolverine-wolverine-protocol-version, and the duplicate lands in the Redis stream entry — most visibly in the native dead letter queue.

Mechanism

writeOutgoingHeader prefixes every header with wolverine-, and MapEnvelopeToOutgoing unconditionally appends TransportConstants.ProtocolVersion (itself already the literal wolverine-protocol-version), so the wire field is wolverine-wolverine-protocol-version. That part is fine on its own.

The problem is the round trip. writeIncomingHeaders copies every wolverine--prefixed wire field into Envelope.Headers with exactly one prefix stripped:

https://github.com/JasperFx/wolverine/blob/main/src/Transports/Redis/Wolverine.Redis/RedisEnvelopeMapper.cs#L66-L76

so the received envelope carries a literal wolverine-protocol-version entry in Headers. The next time that envelope is mapped outgoing, the header is re-prefixed and MapEnvelopeToOutgoing adds its own copy:

https://github.com/JasperFx/wolverine/blob/main/src/Wolverine/Transports/EnvelopeMapper.cs#L149-L155

Two identical field names in one StreamEntry. It stabilizes at two rather than growing per hop, because Headers is a dictionary and both copies collapse back to one key on the way in.

Repro

No broker required:

var transport = new RedisTransport();
var endpoint = transport.StreamEndpoint("scratch-stream");
var mapper = new RedisEnvelopeMapper(endpoint);

// 1. Map an outgoing envelope, exactly as a sender does.
var outgoing = new List<NameValueEntry>();
mapper.MapEnvelopeToOutgoing(new Envelope { Data = "{}"u8.ToArray(), MessageType = "scratch" }, outgoing);
outgoing.Count(x => x.Name == "wolverine-wolverine-protocol-version").ShouldBe(1); // passes

// 2. Read it back, exactly as the listener does.
var received = new Envelope();
mapper.MapIncomingToEnvelope(received, new StreamEntry("1-0", outgoing.ToArray()));

// 3. Re-send that received envelope -- what the dead letter sender does.
var resent = new List<NameValueEntry>();
mapper.MapEnvelopeToOutgoing(received, resent);
resent.Count(x => x.Name == "wolverine-wolverine-protocol-version").ShouldBe(1); // FAILS: was 2

Fields written on the second pass:

wolverine-wolverine-protocol-version
wolverine-id
wolverine-sent-at
wolverine-message-type
wolverine-accepted-content-types
payload
wolverine-wolverine-protocol-version

How it reaches a user

A Redis listener that dead-letters under BufferedInMemory or Durable listening. Those modes route the failure through BufferedReceiver/DurableReceiver → endpoint.TryBuildDeadLetterSender → InlineRedisStreamSender → the envelope mapper, rather than through RedisStreamListener.MoveToErrorsAsync, which builds its fields explicitly and is not affected.

So the dead letter entry is malformed, and any consumer that reads it into a keyed structure throws. StreamEntry.Values.ToDictionary(...) gives System.ArgumentException : An item with the same key has already been added. Key: wolverine-wolverine-protocol-version.

This is latent on main today only because the Redis DeadLetterQueueTests currently run with an Inline listener, which takes the MoveToErrorsAsync path. It surfaced in CI on #4506, where dropping a SendInline() moved those tests onto the buffered path. It is not caused by that PR.

Worth noting separately: the two dead-letter paths write different shapes. MoveToErrorsAsync writes exception-type, exception-message, exception-stack, failed-at, envelope-id, message-type and attempts; the dead letter sender path writes mapper-shaped wolverine-* fields. Whether the native dead letter metadata should be mode-dependent at all is probably the larger question behind this bug.

Suggested fix

Have writeIncomingHeaders skip the protocol version rather than copying it into Headers — it is transport plumbing that the outgoing mapper re-stamps anyway, so it should not survive as an envelope header. RedisEnvelopeMapper already does exactly this for RedisEntryIdHeader in writeOutgoingHeader, so the shape of the fix is established.

Other transports that copy prefixed headers wholesale on the way in are worth a look for the same pattern.

Activity

  1. added 2 commits that reference this issue on Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions