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
|
## Foutafhandeling
|
||||||
|
|
||||||
- **Ongeldig dienstnummer**: de bot stuurt een melding terug naar de afzender
|
- **Ongeldig dienstnummer**: de bot stuurt een melding terug naar de afzender
|
||||||
- **Fout bij verzenden PNG**: de admin krijgt een WhatsApp melding
|
- **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.
|
||||||
- **Webhook fout**: wordt gelogd in PM2 logs
|
- **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
|
## Spam protectie
|
||||||
|
|
||||||
|
|
|
||||||
47
src/index.js
47
src/index.js
|
|
@ -1,4 +1,5 @@
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
|
const http = require('http')
|
||||||
const cron = require('node-cron')
|
const cron = require('node-cron')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { handleWebhook } = require('./messageHandler')
|
const { handleWebhook } = require('./messageHandler')
|
||||||
|
|
@ -23,6 +24,15 @@ app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok' })
|
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
|
const PORT = process.env.WEBHOOK_PORT || 3002
|
||||||
|
|
||||||
loadDiensten()
|
loadDiensten()
|
||||||
|
|
@ -42,9 +52,42 @@ cron.schedule('0 10 * * *', () => {
|
||||||
.catch(err => console.error('Failed to send daily summary:', err.message))
|
.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(`dienst-bot listening on port ${PORT}`)
|
||||||
console.log(`Allowed group: ${process.env.ALLOWED_GROUP}`)
|
console.log(`Allowed group: ${process.env.ALLOWED_GROUP}`)
|
||||||
console.log(`Bot name: ${process.env.BOT_NAME}`)
|
console.log(`Bot name: ${process.env.BOT_NAME}`)
|
||||||
console.log('Daily summary cron job scheduled for 10:00')
|
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 { 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 { checkLimit, increment, MAX_REQUESTS_PER_DAY } = require('./rateLimiter')
|
||||||
const { increment: incrementDaily } = require('./dailyStats')
|
const { increment: incrementDaily } = require('./dailyStats')
|
||||||
const { checkAndRepinPinnedMessage } = require('./pinnedMessageService')
|
const { checkAndRepinPinnedMessage } = require('./pinnedMessageService')
|
||||||
|
|
@ -27,6 +27,10 @@ async function handleWebhook(payload) {
|
||||||
|
|
||||||
incrementDaily()
|
incrementDaily()
|
||||||
|
|
||||||
|
sendChatSeen(ALLOWED_GROUP).catch(err =>
|
||||||
|
console.error('Failed to mark chat as seen:', err.message)
|
||||||
|
)
|
||||||
|
|
||||||
console.log(`Processing dienst ${dienstnummer} for ${message.author}`)
|
console.log(`Processing dienst ${dienstnummer} for ${message.author}`)
|
||||||
|
|
||||||
const author = message.author
|
const author = message.author
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,39 @@ const fs = require('fs')
|
||||||
|
|
||||||
const API_BASE = process.env.WHATSAPP_API_ENDPOINT
|
const API_BASE = process.env.WHATSAPP_API_ENDPOINT
|
||||||
const SESSION = process.env.WHATSAPP_SESSION
|
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) {
|
async function sendTextMessage(chatId, text) {
|
||||||
|
return withRetry(async () => {
|
||||||
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
||||||
const response = await axios.post(url, {
|
const response = await axios.post(url, {
|
||||||
chatId,
|
chatId,
|
||||||
|
|
@ -12,9 +43,11 @@ async function sendTextMessage(chatId, text) {
|
||||||
content: text
|
content: text
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
|
}, `sendTextMessage to ${chatId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendImageMessage(chatId, filePath, filename) {
|
async function sendImageMessage(chatId, filePath, filename) {
|
||||||
|
return withRetry(async () => {
|
||||||
const fileBuffer = fs.readFileSync(filePath)
|
const fileBuffer = fs.readFileSync(filePath)
|
||||||
const base64Data = fileBuffer.toString('base64')
|
const base64Data = fileBuffer.toString('base64')
|
||||||
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
const url = `${API_BASE}/client/sendMessage/${SESSION}`
|
||||||
|
|
@ -28,6 +61,7 @@ async function sendImageMessage(chatId, filePath, filename) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
|
}, `sendImageMessage ${filename} to ${chatId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notifyAdmin(errorText) {
|
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