Bulk SMS API: paralelní odesílání 10 000+ zpráv bez rate limit problémů
Jak architektonicky řešit hromadné odesílání SMS přes API — paralelizace, rate limit handling, retry strategie, idempotence. Code příklady v Node.js a Pythonu.
TL;DR: Pro odeslání 10 000+ SMS přes API použijte batched paralelní volání (10-20 concurrent requests), token bucket rate limiter pro respektování limitu (default 60 SMS/min), exponential backoff pro retry a idempotence přes vlastní externí ID. Tento článek má praktické code patterns v Node.js a Pythonu pro produkční bulk odesílání.
Pokud máte e-shop s 10 000 zákazníky a chcete poslat Black Friday akci v 14:00 přesně, neuděláte to sériově (10 000 × 3 s = 8 hodin). Potřebujete paralelizovat, ale respektovat rate limit API (default 60 SMS/min, lze zvýšit). V tomto článku ukážu produkční patterns.
Architektura — 3 vrstvy
[Vaše DB s kontakty]
↓ batch read
[Job queue (Redis / DB)]
↓ worker pool
[Rate-limited dispatcher]
↓ HTTP POST
[TopSMS API]
↓
[Webhook delivery report]
↓
[Vaše DB — status update]
Vrstva 1: Job queue
- Načte kontakty z DB, vytvoří entries (
message_id,phone,text,status: pending) - Persistent (přežije restart)
- Možnosti: Redis (
BullMQ,RQ), nebo prosté DB tabulka sstatuspolem
Vrstva 2: Worker pool
- N paralelních workerů (typicky 10-20)
- Každý worker drží otevřené HTTP connection
- Plus jeden shared rate limiter
Vrstva 3: Dispatcher s rate limit
- Token bucket: 60 tokenů, doplňuje 1 token/sekundu
- Pokud token není, čeká
- Tím garantuje max 60 SMS/min napříč všemi workery
Token bucket — Node.js implementace
class TokenBucket {
constructor(capacity, refillPerSecond) {
this.capacity = capacity
this.tokens = capacity
this.refillPerSecond = refillPerSecond
this.lastRefill = Date.now()
}
refill() {
const now = Date.now()
const elapsed = (now - this.lastRefill) / 1000
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSecond)
this.lastRefill = now
}
async acquire() {
while (true) {
this.refill()
if (this.tokens >= 1) {
this.tokens -= 1
return
}
// Wait for next token (rough estimate)
const waitMs = ((1 - this.tokens) / this.refillPerSecond) * 1000
await new Promise(r => setTimeout(r, Math.min(waitMs, 100)))
}
}
}
// Usage: max 60 SMS/min = 1 SMS/sec, burst capacity 60
const limiter = new TokenBucket(60, 1)
Worker pool s rate limit (Node.js)
const PARALLEL_WORKERS = 10
const TOPSMS_AUTH = `Bearer ${process.env.TOPSMS_CLIENT_ID}:${process.env.TOPSMS_SECRET}`
async function worker(jobQueue, limiter) {
while (jobQueue.length > 0) {
const job = jobQueue.shift()
if (!job) break
await limiter.acquire() // wait for rate limit slot
try {
const res = await fetch('https://www.topsms.cz/api/sms/send', {
method: 'POST',
headers: {
'Authorization': TOPSMS_AUTH,
'Content-Type': 'application/json',
'X-Idempotency-Key': job.externalId, // optional: prevent duplicates on retry
},
body: JSON.stringify({
to: job.phone,
from: 'Eshop123',
text: job.text,
externalId: job.externalId,
}),
})
const data = await res.json()
if (data.ok) {
await db.message.update({ where: { id: job.id }, data: { status: 'sent', topsmsId: data.messageId } })
} else {
await handleError(job, data.error, res.status)
}
} catch (e) {
await handleError(job, e.message, 0)
}
}
}
async function dispatch(jobs) {
const limiter = new TokenBucket(60, 1) // 60/min
const queue = [...jobs]
const workers = Array.from({ length: PARALLEL_WORKERS }, () => worker(queue, limiter))
await Promise.all(workers)
}
Retry s exponential backoff
async function handleError(job, errorMsg, httpStatus) {
const isRetryable = httpStatus === 429 || httpStatus >= 500 || httpStatus === 0
if (!isRetryable) {
// Permanent failure (400, 401, 402) — don't retry
await db.message.update({
where: { id: job.id },
data: { status: 'failed', error: errorMsg },
})
return
}
job.attempts = (job.attempts || 0) + 1
if (job.attempts >= 5) {
await db.message.update({
where: { id: job.id },
data: { status: 'failed', error: `Max retries: ${errorMsg}` },
})
return
}
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const waitMs = Math.min(1000 * 2 ** (job.attempts - 1), 30_000)
setTimeout(() => {
queue.push(job) // re-enqueue
}, waitMs)
}
Python — asyncio implementace
import asyncio
import aiohttp
import os
from datetime import datetime
class TokenBucket:
def __init__(self, capacity, refill_per_second):
self.capacity = capacity
self.tokens = capacity
self.refill_per_second = refill_per_second
self.last_refill = datetime.now()
self.lock = asyncio.Lock()
async def acquire(self):
async with self.lock:
while True:
now = datetime.now()
elapsed = (now - self.last_refill).total_seconds()
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.refill_per_second
await asyncio.sleep(min(wait, 0.1))
async def send_sms(session, limiter, job):
await limiter.acquire()
auth = f"Bearer {os.environ['TOPSMS_CLIENT_ID']}:{os.environ['TOPSMS_SECRET']}"
try:
async with session.post(
'https://www.topsms.cz/api/sms/send',
headers={'Authorization': auth, 'Content-Type': 'application/json'},
json={'to': job['phone'], 'from': 'Eshop123', 'text': job['text']},
) as response:
data = await response.json()
return {'job_id': job['id'], 'ok': data.get('ok'), 'message_id': data.get('messageId'), 'status_code': response.status}
except Exception as e:
return {'job_id': job['id'], 'ok': False, 'error': str(e), 'status_code': 0}
async def dispatch(jobs, parallel=10):
limiter = TokenBucket(60, 1)
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
results = await asyncio.gather(*[send_sms(session, limiter, j) for j in jobs])
return results
# Usage
jobs = [{'id': i, 'phone': '+420600000001', 'text': f'Message {i}'} for i in range(10000)]
results = asyncio.run(dispatch(jobs))
print(f"OK: {sum(1 for r in results if r['ok'])} / {len(results)}")
Idempotence — proč a jak
Pokud worker crashne mid-request, můžeš po restartu retry → potenciálně odeslat 2× stejnou zprávu. To je drahé a UX katastrofa (klient dostane 2× OTP).
Řešení: pošlete v requestu vlastní externalId:
{
"to": "+420...",
"text": "...",
"externalId": "order-12345-otp"
}
TopSMS pak při retry stejného externalId vrátí původní výsledek místo odeslání nové SMS. Idempotence garantovaná na 24 h.
Měření výkonu
Pro 10 000 SMS s 60/min rate limit:
- Sériově: 10 000 × 3 s = ~8 hodin (nepřijatelné)
- Paralelně bez limitu: 200 s teoreticky, ale dostanete 429 od ~60. zprávy
- Paralelně s token bucket: 10 000 / 60 × 60 = 10 000 s = 2 h 47 min — dimensionálně limitované 60/min
Pokud potřebujete rychleji, máte 2 možnosti:
1. Požádat o vyšší rate limit
V API klíči → "Rate limit" → zvýšit (pro paying users až 600/min). Pak 10 000 SMS / 600 × 60 = 17 min.
2. Použít SMPP
Pro 50k+ SMS/den je SMPP protokol efektivnější — viz REST API vs SMPP. Latence < 1 s, throughput tisíce SMS/s.
Monitoring během běhu
Pro 10k+ kampaně sledujte:
- Success rate — kolik % se odeslalo bez chyby (cíl: > 99 %)
- Failed rate — kolik % failed (typicky < 1 % u čisté databáze)
- Average latency — průměr per SMS (cíl: < 500 ms pro odpověď od API)
- Cost tracking — kolik kreditu jste utratili (z
costv API odpovědi) - Webhook delivery rate — kolik webhook updates dorazilo (klesající = problém)
Pro real-time progress doporučujeme:
- Update DB row na status
sent/failed/retryingpři každém kroku - Cron job (1×/min) co spočítá progress a aktualizuje UI
- Dashboard endpoint pro frontend polling
Co NEdělat
❌ Nepoužívejte Promise.all([1000 fetchů]) — pošlete 1000 requestů najednou, API vás zablokuje (429 nebo IP ban)
❌ Nepoužívejte plain for loop s await — sériové, pomalé
❌ Nevypisujte heslo nebo secret do logů — i v debugu
❌ Nezapomeňte na timeout — aiohttp.ClientTimeout(total=10) v Pythonu, AbortController v Node.js
❌ Nezapomeňte na error handling — minimálně retry pro 429 a 5xx
Vzor: Bulk send service (production-ready)
V repo doporučujeme strukturu:
/services/sms-dispatcher/
src/
queue.ts # job queue (Redis / DB)
rate-limiter.ts # TokenBucket
worker.ts # send one SMS with retry
dispatcher.ts # spawn N workers
metrics.ts # success rate, latency
test/
queue.test.ts
rate-limiter.test.ts
package.json
Plný kód s testy najdete v naších GitHub examples (přibudou — pokud nejsou ještě dostupné, kontaktujte support).