Scheduler: a queued webhook whose secret cannot be decrypted ends the pass, and keeps ending it #573
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#573
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, comparing the webhook delivery pass with the document-copy and sync passes, and confirmed by runningsend_due_remindersafter changingSECRET_KEY.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atnotifications/management/commands/send_due_reminders.py:197(the call towebhooks.send_pending()) and:242(scheduler.beat());notifications/webhooks.pyand the two sibling passes have not moved.deliverreads the connection's secret before its firsttry, so a secret written under another key raisesSecretsUnreadableout ofdeliver, out ofsend_pendingand out of the scheduler pass. The row has already been claimed, so it comes back after the 15-minute lease with no attempt recorded, is never given up on, and ends a pass every time it does.notifications/webhooks.py:122-123,deliver:secret = connection.secrets.get("secret") or ""sits outside thetryblocks at lines 124 and 130;notifications/webhooks.py:155-165,send_pending, has no per-row guard;notifications/management/commands/send_due_reminders.py:195calls it in the middle ofone_pass, beforerun_syncs,reap_archives,errands.forget_old,pages.expire_unconfirmedandscheduler.beat()at line 240. The two sibling passes already handle this:documents/archiving.py:138andplugins/syncing.py:81catchSecretsUnreadableand record it.SECRET_KEYis rotated withoutPOSTULO_FIELD_KEY, or when a backup is restored onto an instance with a different key, both casesplugins/secrets.pyand the restore check describe. Any webhook row still pending or failed at that moment is picked up by the next pass:claimmovesnext_attempt_at15 minutes ahead,deliverraises, and the pass ends. In a probe on the snapshot the pass raisedSecretsUnreadable,run_syncswas never called, and the row stayedpendingwith 0 attempts andnext_attempt_at15 minutes on. A looping scheduler logs "A scheduler pass ended badly" and tries again; the row returns every 15 minutes and ends that pass too. Each bad row can end a pass, so several rows with staggered times end most passes.deliver, read the secret inside atry; onSecretsUnreadable, record the message on the connection withrecord_test(False, ...)and end the row withfail(str(error), final=True), as the copy pass does. Also wrap each row insend_pendingso an unexpected exception is logged against that row and the batch continues, asannounce_due_remindersdoes per reminder since #221.Done when
tests/test_webhooks.pyqueues a row, changesSECRET_KEYwithPOSTULO_FIELD_KEYempty, runswebhooks.send_pending()and expects no exception, the row given up with the unreadable-secrets message, and the message on the connection.tests/test_scheduler.pyexpectsrun_syncsto be called and the heartbeat written on the same pass.