Wifiv2 / src /services /sms.js
Mbonea's picture
Back off SMS queue after gateway throttling
f14d7e5
Raw
History Blame Contribute Delete
4.92 kB
const axios = require('axios');
const crypto = require('crypto');
const db = require('../config/db');
const SEND_INTERVAL_MS = Number(process.env.SMS_SEND_INTERVAL_MS || 5000);
const MAX_ATTEMPTS = Number(process.env.SMS_MAX_ATTEMPTS || 4);
const RATE_LIMIT_BACKOFF_MS = Number(process.env.SMS_RATE_LIMIT_BACKOFF_MS || 60000);
const MAX_BACKOFF_MS = Number(process.env.SMS_MAX_BACKOFF_MS || 180000);
const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
const DEFAULT_PRIORITY = 0;
const HIGH_PRIORITY = 100;
let sendQueue = Promise.resolve();
let lastSendAt = 0;
let rateLimitUntil = 0;
function normalizePhone(phone) {
const d = phone.replace(/\D/g, '');
if (d.startsWith('255')) return '+' + d;
if (d.startsWith('0')) return '+255' + d.slice(1);
return '+255' + d;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function errorDetails(err) {
const statusCode = err.response?.status;
const body = err.response?.data;
const bodyText = body
? ` body=${typeof body === 'string' ? body : JSON.stringify(body).slice(0, 180)}`
: '';
return `${err.message}${statusCode ? ` status=${statusCode}` : ''}${bodyText}`.slice(0, 255);
}
function retryAfterMs(err) {
const retryAfter = err.response?.headers?.['retry-after'];
if (!retryAfter) return null;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
return null;
}
async function waitForTurn() {
const cooldownMs = Math.max(0, rateLimitUntil - Date.now());
if (cooldownMs > 0) await delay(cooldownMs);
const elapsed = Date.now() - lastSendAt;
const waitMs = Math.max(0, SEND_INTERVAL_MS - elapsed);
if (waitMs > 0) await delay(waitMs);
lastSendAt = Date.now();
}
function boundedNumber(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(number)));
}
function buildPayload(to, message, options = {}) {
const ttl = boundedNumber(options.ttl, DEFAULT_TTL_SECONDS, 60, 7 * 24 * 60 * 60);
const priority = boundedNumber(options.priority, DEFAULT_PRIORITY, -128, 127);
const payload = {
id: options.id || crypto.randomUUID(),
textMessage: { text: message },
phoneNumbers: [to],
ttl,
priority,
withDeliveryReport: options.withDeliveryReport === true,
};
if (options.deviceId) payload.deviceId = options.deviceId;
if (options.simNumber !== undefined) {
payload.simNumber = boundedNumber(options.simNumber, 1, 1, 3);
}
return payload;
}
async function postSMS(payload) {
await waitForTurn();
const res = await axios.post(
process.env.SMS_GATEWAY_URL,
payload,
{
auth: {
username: process.env.SMS_GATEWAY_USER,
password: process.env.SMS_GATEWAY_PASS,
},
timeout: 10000,
}
);
return res.data?.id || payload.id || null;
}
async function sendWithRetry(to, message, options) {
let lastError = null;
const payload = buildPayload(to, message, options);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
return { sent: true, gatewayId: await postSMS(payload), error: null };
} catch (err) {
lastError = err;
const statusCode = err.response?.status;
const isRateLimited = statusCode === 429;
const canRetry = isRateLimited && attempt < MAX_ATTEMPTS;
const gatewayWait = retryAfterMs(err);
const backoffMs = Math.min(gatewayWait || RATE_LIMIT_BACKOFF_MS * attempt, MAX_BACKOFF_MS);
if (isRateLimited) {
rateLimitUntil = Math.max(rateLimitUntil, Date.now() + backoffMs);
}
if (!canRetry) break;
console.warn(`[SMS] Rate limited for ${to}; retrying attempt ${attempt + 1}/${MAX_ATTEMPTS} in ${backoffMs}ms`);
await delay(backoffMs);
}
}
return { sent: false, gatewayId: null, error: errorDetails(lastError) };
}
async function sendNow(phone, message, options = {}) {
const to = normalizePhone(phone);
const result = await sendWithRetry(to, message, options);
const status = result.sent ? 'sent' : 'failed';
if (result.sent) {
console.log(`[SMS] Sent to ${to}: "${message.slice(0, 40)}..."`);
} else {
console.error(`[SMS] Failed to ${to}:`, result.error);
}
db.query(
'INSERT INTO sms_log (phone, message, status, gateway_id, error) VALUES (?, ?, ?, ?, ?)',
[to, message, status, result.gatewayId, result.error]
).catch(() => {});
return result.sent;
}
async function sendSMS(phone, message, options = {}) {
const queued = sendQueue.then(() => sendNow(phone, message, options));
sendQueue = queued.catch(() => {});
return queued;
}
module.exports = {
sendSMS,
priorities: {
high: HIGH_PRIORITY,
normal: DEFAULT_PRIORITY,
low: -50,
},
};