Getting ChatGPT to Write Accurate Message Queue Consumer Logic Without Poison-Pill Loops
You paste a quick prompt into ChatGPT asking for an SQS or RabbitMQ consumer, and the code it returns looks perfectly reasonable. Then you push to production and a single malformed JSON payload gets retried ten thousand times in four minutes, pegging your worker CPU and blocking every healthy message behind it. That's a poison-pill loop, and ChatGPT's default output almost never guards against it.
The problem isn't that the model doesn't know about dead-letter queues or visibility timeouts. It does. The problem is that vague prompts produce vague code, and "vague" in a queue consumer means missing the exact failure handling that keeps a single bad message from strangling the whole pipeline.
What You'll Learn
- Why ChatGPT's default consumer templates are structurally fragile for production workloads
- How to write prompts that force the model to include poison-pill guards, dead-letter routing, and retry caps
- How to prompt for correct visibility-timeout lease renewal so messages don't re-appear mid-processing
- A checklist for auditing any AI-generated consumer before you merge it
- The most common gotchas in generated queue code and how to avoid them
Prerequisites
You should have a basic understanding of message queues (SQS, RabbitMQ, or similar) and know what terms like ack, nack, visibility timeout, and dead-letter queue mean in practice. The prompt patterns here are broker-agnostic but the code examples use Python with boto3 (SQS) and pika (RabbitMQ) to keep things concrete.
The Poison-Pill Problem: What It Is and Why It Kills Workers
A poison pill is any message your consumer cannot successfully process regardless of how many times it tries. The payload might be malformed JSON, reference a database record that no longer exists, or contain a field type that your handler rejects. On its own, one bad message is fine. But without an explicit retry cap and a way to route the message out of the main queue, the broker will keep re-delivering it.
In SQS terms, that means the message's receive count climbs until the visibility timeout expires each time, and the message bounces back to the front of the queue. In RabbitMQ, an unconditional nack with requeue=True does the same thing instantly. The worker spins, the queue depth appears stable, and legitimate messages pile up behind the looping one.
The fix is a three-part pattern: count retries, route failures to a dead-letter queue (DLQ) after a threshold, and apply backoff between retries so a temporary downstream outage doesn't look identical to a permanently bad message. Knowing this is easy. Getting ChatGPT to generate all three without prompting for each one explicitly is where most developers trip up.
How ChatGPT's Default Consumer Looks (and Where It Breaks)
Ask ChatGPT for "an SQS consumer in Python" and you'll typically get something like this:
import boto3
import json
sqs = boto3.client("sqs", region_name="us-east-1")
QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789/my-queue"
while True:
response = sqs.receive_message(
QueueUrl=QUEUE_URL,
MaxNumberOfMessages=10,
WaitTimeSeconds=20,
)
messages = response.get("Messages", [])
for message in messages:
try:
body = json.loads(message["Body"])
process(body)
sqs.delete_message(
QueueUrl=QUEUE_URL,
ReceiptHandle=message["ReceiptHandle"],
)
except Exception as e:
print(f"Error processing message: {e}")
# message will become visible again after timeout
The code runs. It even handles exceptions. But look at what it doesn't do: it never checks the ApproximateReceiveCount attribute, never sends a failing message to a DLQ manually, never applies backoff between retries, and never extends the visibility timeout for long-running jobs. A bad message will re-appear indefinitely until someone notices the queue depth anomaly or the worker crashes from another cause.
This is the structural gap you need to close through better prompting. You can read more about a similar problem in AI-generated async infrastructure in the article on getting ChatGPT to write accurate event-driven architecture configs without phantom consumers.
Prompt Patterns That Produce Reliable Consumer Code
The key insight is that ChatGPT responds to constraints. The more precisely you specify the failure modes you care about, the more precisely the output handles them. Generic prompts return generic code. Threat-specific prompts return defensive code.
Explicit Poison-Pill Guard Prompt
Start by naming the failure mode directly in your prompt:
Write a Python SQS consumer using boto3 that prevents poison-pill loops. Specifically: read the ApproximateReceiveCount message attribute on every receive; if it exceeds 3, send the message to a separate dead-letter queue URL and delete it from the main queue rather than letting SQS redeliver it. Apply exponential backoff (1s, 2s, 4s) between retry attempts. Do not use nack or requeue β manage routing explicitly in code. Include type hints and structured logging with the message ID and receive count on every log line.
That prompt produces substantially better output. Here's a representative example of what a well-prompted response looks like:
import boto3
import json
import logging
import time
from typing import Any
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
sqs = boto3.client("sqs", region_name="us-east-1")
MAIN_QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789/main-queue"
DLQ_URL = "https://sqs.us-east-1.amazonaws.com/123456789/main-queue-dlq"
MAX_RETRIES = 3
BACKOFF_BASE = 1 # seconds
def send_to_dlq(message: dict[str, Any]) -> None:
sqs.send_message(
QueueUrl=DLQ_URL,
MessageBody=message["Body"],
MessageAttributes={
"OriginalMessageId": {
"StringValue": message["MessageId"],
"DataType": "String",
}
},
)
logger.warning(
"Routed to DLQ",
extra={"message_id": message["MessageId"]},
)
def consume() -> None:
while True:
response = sqs.receive_message(
QueueUrl=MAIN_QUEUE_URL,
MaxNumberOfMessages=10,
WaitTimeSeconds=20,
AttributeNames=["ApproximateReceiveCount"],
)
messages = response.get("Messages", [])
for message in messages:
message_id = message["MessageId"]
receive_count = int(
message["Attributes"]["ApproximateReceiveCount"]
)
logger.info(
"Received message",
extra={"message_id": message_id, "receive_count": receive_count},
)
if receive_count > MAX_RETRIES:
send_to_dlq(message)
sqs.delete_message(
QueueUrl=MAIN_QUEUE_URL,
ReceiptHandle=message["ReceiptHandle"],
)
continue
backoff = BACKOFF_BASE * (2 ** (receive_count - 1))
time.sleep(backoff)
try:
body = json.loads(message["Body"])
process(body)
sqs.delete_message(
QueueUrl=MAIN_QUEUE_URL,
ReceiptHandle=message["ReceiptHandle"],
)
logger.info(
"Message processed",
extra={"message_id": message_id},
)
except Exception:
logger.exception(
"Processing failed, will retry",
extra={"message_id": message_id, "receive_count": receive_count},
)
Notice the prompt did the work: it specified exactly three behaviours (receive count check, DLQ routing, backoff), and the output contains all three. If you had written "handle errors gracefully", none of this would appear.
Dead-Letter Queue Routing Prompt
For RabbitMQ, the poison-pill guard works differently because you control ack/nack directly. Use this prompt structure:
Write a Python RabbitMQ consumer using pika with a manual ack mode. Maintain an in-memory dictionary keyed by message delivery tag to track per-message retry counts. After 3 failed attempts, publish the message body to a dead-letter exchange named 'dlx.main' and ack the original delivery (do not nack-requeue it). Apply jittered exponential backoff before each retry using time.sleep. Explain any assumptions about the exchange topology.
The phrase "do not nack-requeue it" is critical. Without it, ChatGPT defaults to nack(requeue=True), which is the exact mechanism that creates the poison-pill loop. Being explicit about what you don't want is just as important as specifying what you do want. This mirrors the same principle discussed in getting ChatGPT to write accurate backoff strategies without hammering downstream services.
Visibility Timeout and Lease Renewal Prompt
Long-running consumers have a second failure mode: the message's visibility timeout expires before processing finishes, causing SQS to re-deliver it while your worker is still handling it. You end up with duplicate processing and, if the handler isn't idempotent, corrupted state. The fix is a heartbeat thread that extends the visibility timeout at regular intervals.
Extend the SQS consumer above to include a background thread that calls ChangeMessageVisibility every 20 seconds for any message currently being processed, adding 30 seconds each call. The thread must stop and clean up when processing finishes or raises an exception. Use threading.Event for coordination between the main thread and the heartbeat thread. Do not use concurrent.futures for this part.
Specifying the coordination primitive (threading.Event) and explicitly ruling out an alternative (concurrent.futures) forces the model to produce a clear, testable pattern rather than choosing whichever approach happened to appear most in its training data for similar prompts.
Verifying the Output: What to Check Before You Ship
No matter how good your prompt, treat ChatGPT output as a first draft that needs a structured review. Run through this checklist on every generated consumer:
- Retry cap present? Look for a hardcoded or configurable maximum receive/attempt count. If you don't see one, the consumer can loop forever on a bad message.
- DLQ routing explicit? Confirm that messages exceeding the retry cap are actively routed out of the main queue, not just logged. A log line does not prevent re-delivery.
- Delete after DLQ send? The message must be deleted from the main queue after DLQ routing succeeds. If the DLQ send throws an exception and the delete is skipped, the message reappears β which is acceptable. If the delete happens first, you can lose the message if the DLQ send fails.
- Backoff applied correctly? Verify the sleep or delay runs before the retry attempt, not after a successful one. Also check that pure backoff doesn't block the consumer thread from processing other messages β consider whether a threaded or async model is more appropriate.
- Visibility timeout sufficient? Check that the configured timeout is longer than your worst-case processing time. If not, ensure a heartbeat thread is present.
- AttributeNames included in receive call? In SQS, you only get
ApproximateReceiveCountif you explicitly request it. ChatGPT frequently forgets this.
For a broader framework on auditing AI-generated infrastructure code, the guidance in getting ChatGPT to write accurate distributed lock logic without split-brain bugs covers a similar structured review approach.
Common Pitfalls in AI-Generated Consumer Code
Even with specific prompts, certain failure patterns appear in generated consumer code so often they deserve their own callout.
Sleeping the main thread during backoff. If your consumer uses a single thread and you call time.sleep() during a retry pause, every other message in your batch sits blocked. For batch consumers, apply backoff per message in its own thread, or use an async model where await asyncio.sleep() yields control to the event loop.
Catching too broadly, then not re-raising. A bare except Exception: pass silently swallows errors and deletes the message. The consumer thinks it succeeded. Ask ChatGPT specifically to log the full exception with the message ID and only delete the message on confirmed success or confirmed DLQ routing.
Retry count stored in memory only. For RabbitMQ consumers without queue-level retry counting, ChatGPT often uses an in-memory dictionary. That counter resets on worker restart, so a message that failed five times before a deploy starts fresh. If your broker doesn't support delivery count headers, prompt explicitly for a Redis-backed counter keyed by a stable message identifier.
Assuming message bodies are always valid JSON. Generated consumers almost always call json.loads() inside the happy path without a schema validation step. A message that is syntactically valid JSON but semantically wrong (missing required keys, wrong types) passes the parse and crashes inside process(). Prompt for a try/except json.JSONDecodeError separate from the processing exception, and route parse failures directly to the DLQ without retrying.
Missing idempotency on retries. Even with correct retry logic, a message can be processed more than once if the worker crashes after processing but before deleting. Prompt ChatGPT to add a comment or stub noting where an idempotency check (database upsert keyed on message ID, for example) belongs, so you remember to implement it. This is similar to the approach described in getting ChatGPT to write accurate secrets rotation scripts without downtime gaps, where the model needs explicit prompting to account for the gap between a successful operation and its acknowledgment.
Wrapping Up
ChatGPT can generate production-quality queue consumer code, but only when you specify the exact failure modes you want it to defend against. Default prompts produce default code, and default code skips the retry cap, the DLQ routing, and the visibility timeout management that separate a demo consumer from one that runs safely under real workloads.
Here are five concrete next steps:
- Copy the poison-pill guard prompt from this article and run it against your current consumer codebase. Compare the output against what you already have deployed.
- Add
AttributeNames=["ApproximateReceiveCount"]to every SQSreceive_messagecall you own right now. You need this attribute to implement any form of retry cap. - Confirm you have an actual DLQ configured at the broker level as a backstop, even if your application-level routing is correct. Broker-level DLQs catch consumer crashes before your retry logic runs.
- Run the output checklist from the verification section above on every piece of consumer code generated by ChatGPT or any other AI coding tool before merging.
- Prompt explicitly for idempotency stubs. Even if you don't implement them immediately, having the hook in the code makes it far harder to forget.
Frequently Asked Questions
How do I stop an SQS consumer from retrying a bad message forever?
Set a maximum receive count using the ApproximateReceiveCount message attribute, and when that threshold is exceeded, explicitly send the message to a dead-letter queue and delete it from the main queue. Do not rely solely on SQS's built-in redrive policy if you need application-level control over what constitutes a poison pill.
What is the difference between a poison-pill message and a normal transient failure?
A transient failure is one that succeeds on a later retry because the underlying condition resolved, such as a temporary downstream timeout. A poison pill is a message that will never succeed regardless of retries, typically because the payload itself is malformed or references data that no longer exists. You distinguish them by capping retries and routing persistent failures to a DLQ.
Does ChatGPT include dead-letter queue logic in queue consumer code by default?
No, in most cases ChatGPT generates a basic try/except loop that logs errors but lets failed messages re-enter the queue via visibility timeout expiry. You need to explicitly prompt for dead-letter routing, retry capping, and backoff logic to get production-safe output.
Should I use SQS's built-in redrive policy or implement DLQ routing in my application code?
Use both as complementary layers. The broker-level redrive policy is a safety net that catches messages your application code never processes, such as crashes before your logic runs. Application-level routing gives you finer control, such as routing parse failures differently from processing failures, and lets you attach metadata before sending to the DLQ.
How does visibility timeout cause duplicate message processing in SQS consumers?
When SQS delivers a message, it hides it from other consumers for the duration of the visibility timeout. If your processing takes longer than that timeout, the message becomes visible again and can be picked up by another worker or the same worker in a later poll, resulting in duplicate processing. The fix is to extend the visibility timeout mid-processing using ChangeMessageVisibility, ideally from a background heartbeat thread.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!