Resilience: retry logic, graceful shutdown, SO_REUSEADDR, LoadDaemon ThrottleInterval

- whatsappApi.js: exponential backoff (3 retries) voor 5xx en connectiefouten op sendTextMessage en sendImageMessage
- index.js: SO_REUSEADDR port binding, EADDRINUSE retry, graceful shutdown (SIGTERM/SIGINT)
- README.md: documentatie resilience maatregelen bijgewerkt
- LoadDaemon plist: ThrottleInterval=10 tegen restart-loops
This commit is contained in:
Eddy de Vink 2026-06-17 12:40:36 +02:00
commit 2be27f70f1
4 changed files with 120 additions and 30 deletions

View file

@ -152,8 +152,11 @@ Bij elke interactie met de groep controleert de bot of het pinned bericht nog be
## Foutafhandeling
- **Ongeldig dienstnummer**: de bot stuurt een melding terug naar de afzender
- **Fout bij verzenden PNG**: de admin krijgt een WhatsApp melding
- **Webhook fout**: wordt gelogd in PM2 logs
- **Fout bij verzenden tekst/PNG**: maximaal 3 pogingen met exponential backoff (1s, 2s, 4s) voor 5xx HTTP-fouten en connectieproblemen (ECONNRESET, ECONNREFUSED, ETIMEDOUT). Bij definitieve fout krijgt de admin een WhatsApp-melding.
- **EADDRINUSE (poort 3002 in gebruik)**: de server wacht 5 seconden en herhaalt de bind-poging, met `SO_REUSEADDR` om TIME_WAIT te omzeilen
- **Graceful shutdown**: de server handelt SIGTERM en SIGINT af door actieve connecties netjes te sluiten (10s timeout)
- **Restart-loop preventie**: de LaunchDaemon heeft `ThrottleInterval=10` — maximaal 1 herstart per 10 seconden
- **Webhook fout**: wordt gelogd in de logs
## Spam protectie

View file

@ -1,4 +1,5 @@
require('dotenv').config()
const http = require('http')
const cron = require('node-cron')
const express = require('express')
const { handleWebhook } = require('./messageHandler')
@ -23,6 +24,15 @@ app.get('/health', (req, res) => {
res.json({ status: 'ok' })
})
app.get('/ping', (req, res) => {
const adminNumber = process.env.ADMIN_NUMBER
if (adminNumber) {
sendTextMessage(adminNumber, 'pong')
.catch(err => console.error('Failed to send pong:', err.message))
}
res.json({ pong: true })
})
const PORT = process.env.WEBHOOK_PORT || 3002
loadDiensten()
@ -42,9 +52,42 @@ cron.schedule('0 10 * * *', () => {
.catch(err => console.error('Failed to send daily summary:', err.message))
})
app.listen(PORT, () => {
async function startServer() {
const server = http.createServer(app)
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is in use, retrying in 5 seconds...`)
setTimeout(() => {
server.close()
server.listen(PORT)
}, 5000)
} else {
throw err
}
})
server.listen({ port: PORT, reuseAddr: true }, () => {
console.log(`dienst-bot listening on port ${PORT}`)
console.log(`Allowed group: ${process.env.ALLOWED_GROUP}`)
console.log(`Bot name: ${process.env.BOT_NAME}`)
console.log('Daily summary cron job scheduled for 10:00')
})
function shutdown(signal) {
console.log(`${signal} received, shutting down gracefully...`)
server.close(() => {
console.log('Server closed')
process.exit(0)
})
setTimeout(() => {
console.error('Forced shutdown after timeout')
process.exit(1)
}, 10000)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
}
startServer()

View file

@ -1,5 +1,5 @@
const { findDienst, getPngFilePaths } = require('./dienstService')
const { sendTextMessage, sendImageMessage, notifyAdmin } = require('./whatsappApi')
const { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen } = require('./whatsappApi')
const { checkLimit, increment, MAX_REQUESTS_PER_DAY } = require('./rateLimiter')
const { increment: incrementDaily } = require('./dailyStats')
const { checkAndRepinPinnedMessage } = require('./pinnedMessageService')
@ -27,6 +27,10 @@ async function handleWebhook(payload) {
incrementDaily()
sendChatSeen(ALLOWED_GROUP).catch(err =>
console.error('Failed to mark chat as seen:', err.message)
)
console.log(`Processing dienst ${dienstnummer} for ${message.author}`)
const author = message.author

View file

@ -3,8 +3,39 @@ const fs = require('fs')
const API_BASE = process.env.WHATSAPP_API_ENDPOINT
const SESSION = process.env.WHATSAPP_SESSION
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isRetryableError(error) {
if (error.response) {
const status = error.response.status
return status === 500 || status === 502 || status === 503 || status === 504
}
return error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT'
}
async function withRetry(fn, operation) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn()
} catch (err) {
if (attempt < MAX_RETRIES && isRetryableError(err)) {
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1)
console.warn(`${operation} attempt ${attempt}/${MAX_RETRIES} failed (${err.message}), retrying in ${delay}ms`)
await sleep(delay)
} else {
throw err
}
}
}
}
async function sendTextMessage(chatId, text) {
return withRetry(async () => {
const url = `${API_BASE}/client/sendMessage/${SESSION}`
const response = await axios.post(url, {
chatId,
@ -12,9 +43,11 @@ async function sendTextMessage(chatId, text) {
content: text
})
return response.data
}, `sendTextMessage to ${chatId}`)
}
async function sendImageMessage(chatId, filePath, filename) {
return withRetry(async () => {
const fileBuffer = fs.readFileSync(filePath)
const base64Data = fileBuffer.toString('base64')
const url = `${API_BASE}/client/sendMessage/${SESSION}`
@ -28,6 +61,7 @@ async function sendImageMessage(chatId, filePath, filename) {
}
})
return response.data
}, `sendImageMessage ${filename} to ${chatId}`)
}
async function notifyAdmin(errorText) {
@ -43,4 +77,10 @@ async function notifyAdmin(errorText) {
}
}
module.exports = { sendTextMessage, sendImageMessage, notifyAdmin }
async function sendChatSeen(chatId) {
const url = `${API_BASE}/client/sendSeen/${SESSION}`
const response = await axios.post(url, { chatId })
return response.data
}
module.exports = { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen }