There are bugs that teach you a new algorithm.
There are bugs that teach you a new framework.
And then there are bugs that make you question everything you know about software.
This was one of those bugs.
⸻
“It works in staging…”
I had just finished implementing email notifications in my Django application.
Whenever a new user signed up, a Celery task would send a notification email to the admin.
The code was straightforward.
@receiver(post_save, sender=get_user_model())
def notify_admin_on_new_user(sender, instance, created, **kwargs):
if not created:
return
send_new_user_notification_task.delay(
user_id=instance.id,
username=instance.username,
email=instance.email,
name=instance.name,
)
The task itself simply called Django’s send_mail().
Nothing fancy.
⸻
Everything worked locally
Local development?
Perfect.
The Celery worker received the task immediately.
Emails were delivered.
No errors.
⸻
So I deployed it…
Production came online.
I created a new user from Django Admin.
Instead of a success page, I got:
500 Internal Server Error
My first thought:
“Redis is down.”
It wasn’t.
⸻
The traceback
The logs were terrifying.
The first thing I saw was this:
AttributeError:
'ChannelPromise' object has no attribute 'value'
That looked like an internal Celery or Kombu bug.
Scrolling further…
kombu.exceptions.OperationalError:
[Errno 111] Connection refused
Even stranger.
Further down…
URI: amqp://guest:**@localhost:5672//
Wait…
RabbitMQ?
I wasn’t even using RabbitMQ.
I was using Redis.
Why on earth was Celery trying to connect to localhost:5672?
⸻
Maybe Redis?
I checked Redis.
Healthy.
I could connect to it.
The Celery worker itself was running perfectly.
Even manual tasks executed successfully.
This made no sense.
The worker clearly knew how to talk to Redis.
But Django apparently didn’t.
⸻
Maybe Docker?
I rebuilt every image.
No luck.
I deleted old images.
Rebuilt without cache.
Still broken.
⸻
Maybe uWSGI?
I noticed uWSGI was running with multiple workers.
Maybe Celery didn’t like that.
I reduced it to a single worker.
Still broken.
⸻
Maybe Celery 5.6?
Maybe I had discovered a regression.
I checked package versions.
Everything looked fine.
I seriously considered downgrading Celery.
Thankfully, I didn’t.
⸻
The weirdest part
I created an entirely separate staging deployment.
Same repository.
Same Dockerfile.
Same compose file.
Same environment variables.
Same Redis image.
Same PostgreSQL image.
Everything worked.
At this point the bug became even more frustrating.
How can the same code behave differently?
⸻
The clue
Instead of looking at logs, I started comparing behavior.
Inside the Django shell I ran:
from celery import current_app
print(current_app.conf.broker_url)
Staging
redis://redis:6379/0
Perfect.
Production
None
Now I finally had a direction.
If the broker URL is None, Celery falls back to its default broker:
amqp://guest@localhost:5672//
Exactly what the logs showed.
The traceback wasn’t the real problem.
It was only a symptom.
⸻
Comparing the projects
I started comparing files between staging and production.
Eventually one command stood out.
diff config/init.py
Production replied:
No such file or directory
The file simply didn’t exist.
A file so small that I had never even thought about it.
⸻
The fix
I recreated the file.
from .celery import app as celery_app
all = ("celery_app",)
That’s it.
Two lines.
Sixty-five bytes.
⸻
Suddenly everything worked
I restarted the containers.
Back into the Django shell.
from celery import current_app
print(current_app.conf.broker_url)
This time:
redis://redis:6379/0
I created another user.
No 500 error.
Celery received the task.
The email was sent.
Everything worked exactly as expected.
⸻
What was actually happening?
Celery has the idea of a default application.
If your Django project doesn’t expose your configured Celery app, shared_task uses Celery’s default app.
That default app has no broker configured.
So internally Celery assumes:
amqp://guest@localhost:5672//
Every .delay() call tries to publish a message through RabbitMQ.
Since RabbitMQ isn’t running, Kombu raises:
Connection refused
The scary ChannelPromise traceback is merely an internal consequence of that failed connection.
The missing init.py wasn’t adding functionality.
It was registering my configured Celery application as the project’s default application.
Without it, Django and Celery were speaking different languages.
⸻
What I learned
This bug taught me several lessons.
First, don’t trust the first exception you see.
The ChannelPromise error looked like the root cause.
It wasn’t.
It was only the messenger.
Second, compare a working system with a broken one.
The breakthrough wasn’t reading another stack trace.
It was noticing this single difference:
Working:
current_app.conf.broker_url
redis://redis:6379/0
Broken:
current_app.conf.broker_url
None
That single observation eliminated hours of guessing.
Third, tiny files can have enormous responsibilities.
A two-line file determined whether my application used Redis correctly or attempted to connect to a RabbitMQ server that didn’t even exist.
⸻
The moral
Sometimes the bug isn’t hidden inside a thousand lines of business logic.
Sometimes it isn’t Docker.
Sometimes it isn’t Redis.
Sometimes it isn’t Celery.
Sometimes the difference between a perfectly working production system and hours of frustration is a tiny file you never noticed because every tutorial quietly included it from the very beginning.
Those are the bugs you’ll never forget.
Top comments (0)