Skip to content

fix: passing dlq msg as a payload, not in headers - #173

Merged
HardMax71 merged 1 commit into
mainfrom
fix/dlq-to-payload
Feb 12, 2026
Merged

fix: passing dlq msg as a payload, not in headers#173
HardMax71 merged 1 commit into
mainfrom
fix/dlq-to-payload

Conversation

@HardMax71

@HardMax71 HardMax71 commented Feb 12, 2026

Copy link
Copy Markdown
Owner

Summary by cubic

Move DLQ metadata from Kafka headers into the message body (DLQMessage) and switch consumers to body-based filtering with auto OpenTelemetry via middleware. This fixes DLQ retry issues and header-related errors while simplifying producers and handlers.

  • Bug Fixes

    • Send full DLQMessage as payload instead of relying on headers, ensuring retries and metrics work even without headers.
    • Use body-based event_type filters to avoid KeyError when headers are missing.
  • Refactors

    • Added KafkaTelemetryMiddleware to the broker for automatic trace propagation; removed manual context inject/extract and custom consumer span wrapper.
    • Producers publish events without headers; DLQ producer sends DLQMessage; DLQ consumer reads DLQMessage and sets dlq_offset/partition on the body.
    • Removed headers field from DLQMessage and kept all DLQ metadata in the model.
    • Simplified event handlers by dropping the msg param and tracing wrapper; idempotency logic remains unchanged.

Written for commit 091c18d. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enabled OpenTelemetry tracing for Kafka message interactions, improving observability and debugging capabilities.
  • Refactor

    • Refactored Dead Letter Queue message structure to store metadata within message bodies for improved consistency.
    • Simplified event processing by replacing header-based filtering with body-based filtering mechanisms.

@coderabbitai

coderabbitai Bot commented Feb 12, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes remove OpenTelemetry trace context header propagation from Kafka publishing and DLQ flows, instead relying on middleware-based telemetry and model-embedded metadata. DLQ messages now carry metadata fields directly in the DLQMessage model rather than via headers. A KafkaTelemetryMiddleware instance is added to the Kafka broker configuration. Header-based trace context utilities are removed from the public API.

Changes

Cohort / File(s) Summary
Tracing Infrastructure
backend/app/core/tracing/__init__.py, backend/app/core/tracing/utils.py
Removed public exports and implementations of extract_trace_context and inject_trace_context functions; eliminated OpenTelemetry context and propagate imports. Header-based trace propagation is no longer available.
Kafka Broker Configuration
backend/app/core/providers.py
Added KafkaTelemetryMiddleware instance to the KafkaBroker middlewares configuration for automatic OpenTelemetry tracing of Kafka interactions.
DLQ Model & Manager
backend/app/dlq/models.py, backend/app/dlq/manager.py
Removed the headers field from DLQMessage model and eliminated header map construction in the DLQ retry flow; headers are no longer passed to publish calls.
Event Producer
backend/app/events/core/producer.py
Removed inject_trace_context usage and header construction in publish paths; DLQ messages now constructed as DLQMessage instances with embedded metadata (event, original_topic, error, retry_count, failed_at, status, producer_id) instead of header-encoded fields.
Event Handlers
backend/app/events/handlers.py
Introduced body-based event filtering helper; removed KafkaMessage parameters from handler signatures; eliminated manual trace context extraction from headers; refactored DLQ handler to work with DLQMessage body directly and derive metrics from model fields instead of headers; updated multiple subscriber signatures and consolidated filtering logic.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 No more headers hopping through the Kafka stream,
Middleware now handles tracing—a cleaner dream!
DLQ messages carry their truth inside,
With metadata fields as their trusted guide. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the changeset: shifting DLQ message metadata from headers to payload structure across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/dlq-to-payload

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@backend/app/events/handlers.py`:
- Around line 68-71: _event_type_filter currently calls
decode_message(...).get(...) directly which will raise if decode_message throws
or returns None/non-dict; wrap the body of _event_type_filter in a try/except
that catches exceptions from decode_message and checks the returned value is a
mapping before calling .get, and return False on any failure so malformed/poison
messages don't crash the consumer; reference the function _event_type_filter and
the helper decode_message when making the change.
- Around line 322-328: Replace the assert with a runtime guard: in the block
around start = asyncio.get_running_loop().time(), check if msg.raw_message is a
tuple (or isinstance(raw, tuple)) and raise a clear exception (e.g., ValueError
or RuntimeError) with a descriptive message instead of using assert, so that
setting body.dlq_offset and body.dlq_partition will never run on a batch tuple;
update the code around raw = msg.raw_message / body.dlq_offset /
body.dlq_partition and ensure manager.handle_message(body) still runs only after
the guard passes.
🧹 Nitpick comments (1)
backend/app/events/handlers.py (1)

36-44: handler: Any weakens the type contract.

The previous signature likely constrained handler to Callable[..., Awaitable[None]]. Widening to Any loses static-analysis benefits — callers can now pass non-callables without a type error. Consider using Callable[[DomainEvent], Awaitable[None]] or a suitable protocol instead.

♻️ Suggested type annotation
+from collections.abc import Callable, Awaitable
+
 async def with_idempotency(
         event: DomainEvent,
-        handler: Any,
+        handler: Callable[[DomainEvent], Awaitable[None]],
         idem: IdempotencyManager,

Comment on lines +68 to +71
def _event_type_filter(msg: Any, expected: str) -> bool:
"""Body-based event_type filter for @sub(filter=...) lambdas."""
return decode_message(msg).get("event_type") == expected # type: ignore[union-attr]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

_event_type_filter lacks error handling — a malformed message will crash the consumer.

If decode_message raises (e.g. invalid JSON) or returns None / non-dict, the .get() call will propagate an unhandled exception inside the filter callback. Since filters run for every incoming message, one poison pill could block or crash the entire consumer group.

Wrap the body in a try/except to return False on failure:

🛡️ Proposed fix
 def _event_type_filter(msg: Any, expected: str) -> bool:
     """Body-based event_type filter for `@sub`(filter=...) lambdas."""
-    return decode_message(msg).get("event_type") == expected  # type: ignore[union-attr]
+    try:
+        decoded = decode_message(msg)
+        return isinstance(decoded, dict) and decoded.get("event_type") == expected
+    except Exception:
+        return False
🤖 Prompt for AI Agents
In `@backend/app/events/handlers.py` around lines 68 - 71, _event_type_filter
currently calls decode_message(...).get(...) directly which will raise if
decode_message throws or returns None/non-dict; wrap the body of
_event_type_filter in a try/except that catches exceptions from decode_message
and checks the returned value is a mapping before calling .get, and return False
on any failure so malformed/poison messages don't crash the consumer; reference
the function _event_type_filter and the helper decode_message when making the
change.

Comment on lines 322 to +328
start = asyncio.get_running_loop().time()
headers = _extract_headers(msg)
raw = msg.raw_message
assert not isinstance(raw, tuple) # single-message consumer, never batch

dlq_msg = DLQMessage(
event=body,
original_topic=headers["original_topic"],
error=headers["error"],
retry_count=int(headers["retry_count"]),
failed_at=datetime.fromisoformat(headers["failed_at"]),
status=DLQMessageStatus(headers["status"]),
producer_id=headers["producer_id"],
dlq_offset=raw.offset,
dlq_partition=raw.partition,
headers=headers,
)
assert not isinstance(raw, tuple)
body.dlq_offset = raw.offset
body.dlq_partition = raw.partition

await manager.handle_message(body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace assert with a runtime guard for production safety.

assert statements are stripped when Python runs with -O / PYTHONOPTIMIZE. If raw is unexpectedly a tuple (batch consumer path), the code would silently proceed and fail on .offset / .partition access. Use an explicit if + raise instead.

🛡️ Proposed fix
         raw = msg.raw_message
-        assert not isinstance(raw, tuple)
+        if isinstance(raw, tuple):
+            raise TypeError(f"Expected single ConsumerRecord, got batch of {len(raw)}")
         body.dlq_offset = raw.offset
         body.dlq_partition = raw.partition
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
start = asyncio.get_running_loop().time()
headers = _extract_headers(msg)
raw = msg.raw_message
assert not isinstance(raw, tuple) # single-message consumer, never batch
dlq_msg = DLQMessage(
event=body,
original_topic=headers["original_topic"],
error=headers["error"],
retry_count=int(headers["retry_count"]),
failed_at=datetime.fromisoformat(headers["failed_at"]),
status=DLQMessageStatus(headers["status"]),
producer_id=headers["producer_id"],
dlq_offset=raw.offset,
dlq_partition=raw.partition,
headers=headers,
)
assert not isinstance(raw, tuple)
body.dlq_offset = raw.offset
body.dlq_partition = raw.partition
await manager.handle_message(body)
start = asyncio.get_running_loop().time()
raw = msg.raw_message
if isinstance(raw, tuple):
raise TypeError(f"Expected single ConsumerRecord, got batch of {len(raw)}")
body.dlq_offset = raw.offset
body.dlq_partition = raw.partition
await manager.handle_message(body)
🤖 Prompt for AI Agents
In `@backend/app/events/handlers.py` around lines 322 - 328, Replace the assert
with a runtime guard: in the block around start =
asyncio.get_running_loop().time(), check if msg.raw_message is a tuple (or
isinstance(raw, tuple)) and raise a clear exception (e.g., ValueError or
RuntimeError) with a descriptive message instead of using assert, so that
setting body.dlq_offset and body.dlq_partition will never run on a batch tuple;
update the code around raw = msg.raw_message / body.dlq_offset /
body.dlq_partition and ensure manager.handle_message(body) still runs only after
the guard passes.

@HardMax71
HardMax71 merged commit 8751085 into main Feb 12, 2026
15 checks passed
@HardMax71
HardMax71 deleted the fix/dlq-to-payload branch February 12, 2026 18:00
@coderabbitai coderabbitai Bot mentioned this pull request Feb 13, 2026
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.

1 participant