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:
parent
f46889956b
commit
2be27f70f1
4 changed files with 120 additions and 30 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
55
src/index.js
55
src/index.js
|
|
@ -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, () => {
|
||||
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')
|
||||
})
|
||||
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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,31 +3,65 @@ 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) {
|
||||
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
||||
const response = await axios.post(url, {
|
||||
chatId,
|
||||
contentType: 'string',
|
||||
content: text
|
||||
})
|
||||
return response.data
|
||||
return withRetry(async () => {
|
||||
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
||||
const response = await axios.post(url, {
|
||||
chatId,
|
||||
contentType: 'string',
|
||||
content: text
|
||||
})
|
||||
return response.data
|
||||
}, `sendTextMessage to ${chatId}`)
|
||||
}
|
||||
|
||||
async function sendImageMessage(chatId, filePath, filename) {
|
||||
const fileBuffer = fs.readFileSync(filePath)
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
||||
const response = await axios.post(url, {
|
||||
chatId,
|
||||
contentType: 'MessageMedia',
|
||||
content: {
|
||||
mimetype: 'image/png',
|
||||
data: base64Data,
|
||||
filename: filename
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
return withRetry(async () => {
|
||||
const fileBuffer = fs.readFileSync(filePath)
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
||||
const response = await axios.post(url, {
|
||||
chatId,
|
||||
contentType: 'MessageMedia',
|
||||
content: {
|
||||
mimetype: 'image/png',
|
||||
data: base64Data,
|
||||
filename: 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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue