Build and operate
Safe retries
Retry the right failures, the right way.
In a nutshell
Retry 429 and 5xx with exponential backoff, and network failures on sends with the same reference. Never retry other 4xx errors - fix the request instead.
What to retry
| Outcome | Retry? | How |
|---|---|---|
2xx |
- | Done |
| Timeout / connection reset | Yes | Same request, same reference |
429 rate_limited |
Yes | After the Retry-After seconds |
500 server_error |
Yes | Exponential backoff |
409 not_connected |
After the business reconnects | Offer a connect link first |
422 template_not_approved |
After approval | Wait for a template.status callback |
Other 4xx |
No | Fix the request |
Backoff
Wait longer after each failure and add jitter, for example 1 s, 2 s, 4 s, 8 s... up to a few minutes, then give up and alert. Run retries in a background queue, never in a user's web request.
// A Laravel job: retried by the queue with backoff; the reference makes it safe.
class SendWhatsAppNotification implements ShouldQueue
{
public $tries = 6;
public $backoff = [5, 30, 120, 600, 1800];
public function handle(): void
{
$response = Http::withToken(config('services.paalchat.token'))->acceptJson()->timeout(20)
->post("https://whatsapp.paaltech.org/api/v1/businesses/{$this->tenant}/messages", $this->payload);
if ($response->status() === 429 || $response->serverError()) {
$this->release((int) $response->header('Retry-After') ?: 60);
return;
}
$response->throw(); // other 4xx: fail and surface the error code
}
}
async function sendWithRetry(body, attempt = 0) {
try {
const r = await fetch(`${BASE}/businesses/presec/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify(body) });
if (r.status === 429 || r.status >= 500) throw Object.assign(new Error('retry'), { wait: Number(r.headers.get('Retry-After')) || 0 });
return await r.json();
} catch (e) {
if (attempt >= 5) throw e;
const wait = e.wait || Math.min(2 ** attempt, 300) + Math.random();
await new Promise((ok) => setTimeout(ok, wait * 1000));
return sendWithRetry(body, attempt + 1); // same body, same reference
}
}
import random, time
def send_with_retry(body, attempts=6):
for attempt in range(attempts):
try:
r = requests.post(f"{BASE}/businesses/presec/messages", headers=HEADERS, json=body, timeout=20)
if r.status_code != 429 and r.status_code < 500:
return r.json()
wait = int(r.headers.get("Retry-After", 0)) or min(2 ** attempt, 300)
except requests.RequestException:
wait = min(2 ** attempt, 300)
time.sleep(wait + random.random())
raise RuntimeError("PaalChat unavailable")
Your callback endpoint
PaalChat retries callbacks too. Make your endpoint idempotent (see
Idempotency) and answer 2xx only after the event is safely
recorded.