added on local at 2026-07-01 13:48:23
| 1 | #!/usr/bin/perl | |
| 2 | # AffSoft -- webhook delivery worker. | |
| 3 | # | |
| 4 | # Runs every 5 minutes. Picks up queued + due-for-retry deliveries, | |
| 5 | # POSTs to subscriber URLs with HMAC signature, retries with exponential | |
| 6 | # backoff up to 6 attempts (1m, 5m, 30m, 2h, 12h, 24h), then dead-letters. | |
| 7 | # | |
| 8 | # Cron: */5 * * * * cd /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com && perl _webhook_worker.pl >> _webhook.log 2>&1 | |
| 9 | use strict; use warnings; | |
| 10 | use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com'; | |
| 11 | use MODS::DBConnect; | |
| 12 | use MODS::AffSoft::Config; | |
| 13 | use MODS::AffSoft::Webhooks; | |
| 14 | ||
| 15 | my @BACKOFF_SECONDS = (60, 300, 1800, 7200, 43200, 86400); | |
| 16 | my $MAX_ATTEMPTS = scalar @BACKOFF_SECONDS; | |
| 17 | ||
| 18 | my $db = MODS::DBConnect->new; | |
| 19 | my $cfg = MODS::AffSoft::Config->new; | |
| 20 | my $DB = $cfg->settings('database_name'); | |
| 21 | my $dbh = $db->db_connect or die "no dbh\n"; | |
| 22 | ||
| 23 | my @due = $db->db_readwrite_multiple($dbh, qq~ | |
| 24 | SELECT id, subscription_id, event_type, payload_json, attempts | |
| 25 | FROM ${DB}.webhook_deliveries | |
| 26 | WHERE status IN ('queued','failed') | |
| 27 | AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) | |
| 28 | ORDER BY id | |
| 29 | LIMIT 100 | |
| 30 | ~, $0, __LINE__); | |
| 31 | ||
| 32 | my $sent = 0; my $failed = 0; my $dead = 0; | |
| 33 | for my $d (@due) { | |
| 34 | $db->db_readwrite($dbh, qq~ | |
| 35 | UPDATE ${DB}.webhook_deliveries SET status='sending' WHERE id=$d->{id} | |
| 36 | ~, $0, __LINE__); | |
| 37 | ||
| 38 | my $r = MODS::AffSoft::Webhooks::send_one($db, $dbh, $DB, $d); | |
| 39 | my $body_q = $r->{response_body} // ''; $body_q =~ s/'/\\'/g; | |
| 40 | my $code = $r->{response_code} // 0; | |
| 41 | my $sub_id = $d->{subscription_id} + 0; | |
| 42 | ||
| 43 | if ($r->{ok}) { | |
| 44 | $db->db_readwrite($dbh, qq~ | |
| 45 | UPDATE ${DB}.webhook_deliveries | |
| 46 | SET status='sent', sent_at=NOW(), | |
| 47 | response_status=$code, response_body='$body_q', | |
| 48 | attempts=attempts+1 | |
| 49 | WHERE id=$d->{id} | |
| 50 | ~, $0, __LINE__); | |
| 51 | $db->db_readwrite($dbh, qq~ | |
| 52 | UPDATE ${DB}.webhook_subscriptions | |
| 53 | SET last_success_at=NOW(), last_status_code=$code | |
| 54 | WHERE id=$sub_id | |
| 55 | ~, $0, __LINE__); | |
| 56 | $sent++; | |
| 57 | print scalar(localtime) . " [webhook] id=$d->{id} OK ($code)\n"; | |
| 58 | } | |
| 59 | else { | |
| 60 | my $new_attempts = $d->{attempts} + 1; | |
| 61 | if ($new_attempts >= $MAX_ATTEMPTS) { | |
| 62 | $db->db_readwrite($dbh, qq~ | |
| 63 | UPDATE ${DB}.webhook_deliveries | |
| 64 | SET status='dead', | |
| 65 | response_status=$code, response_body='$body_q', | |
| 66 | attempts=$new_attempts | |
| 67 | WHERE id=$d->{id} | |
| 68 | ~, $0, __LINE__); | |
| 69 | $dead++; | |
| 70 | print scalar(localtime) . " [webhook] id=$d->{id} DEAD after $new_attempts attempts\n"; | |
| 71 | } else { | |
| 72 | my $delay = $BACKOFF_SECONDS[$new_attempts - 1]; | |
| 73 | $db->db_readwrite($dbh, qq~ | |
| 74 | UPDATE ${DB}.webhook_deliveries | |
| 75 | SET status='failed', | |
| 76 | response_status=$code, response_body='$body_q', | |
| 77 | attempts=$new_attempts, | |
| 78 | next_attempt_at = DATE_ADD(NOW(), INTERVAL $delay SECOND) | |
| 79 | WHERE id=$d->{id} | |
| 80 | ~, $0, __LINE__); | |
| 81 | $db->db_readwrite($dbh, qq~ | |
| 82 | UPDATE ${DB}.webhook_subscriptions | |
| 83 | SET last_failure_at=NOW(), last_status_code=$code | |
| 84 | WHERE id=$sub_id | |
| 85 | ~, $0, __LINE__); | |
| 86 | $failed++; | |
| 87 | print scalar(localtime) . " [webhook] id=$d->{id} FAIL ($code) retry+$delay\n"; | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | $db->db_disconnect($dbh); | |
| 93 | print scalar(localtime) . " [webhook] DONE sent=$sent failed=$failed dead=$dead\n"; | |
| 94 | exit 0; |