Skip to content

Backend error notifications

The cardinal rule of a production backend: an unhandled error should knock on the door. Below are compact wrappers and middleware for three popular stacks.

  • Do not block the request by sending a notification. Do await in the background or fire-and-forget.
  • Throttling. If the backend receives 1000 identical errors per minute — don’t turn Notifly into spam. Minimal protection: send no more than one notification per N seconds for the same key (stack hash).
  • Don’t send secrets. Sanitize the request body of passwords and tokens.
notifly.js
const fetch = require('node-fetch');
const seen = new Map();
const COOLDOWN_MS = 60_000;
async function notify(title, message, priority = 7) {
const key = title + message.slice(0, 200);
if (Date.now() - (seen.get(key) || 0) < COOLDOWN_MS) return;
seen.set(key, Date.now());
try {
await fetch(`${process.env.NOTIFLY_URL}/message?token=${process.env.NOTIFLY_TOKEN}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title, message, priority}),
timeout: 5000,
});
} catch (e) {
console.error('notifly failed:', e.message);
}
}
module.exports = {notify};

Integrate into Express:

const express = require('express');
const {notify} = require('./notifly');
const app = express();
// your routes ...
// last middleware — catch all errors
app.use((err, req, res, next) => {
notify(
`🐛 ${req.method} ${req.path}${err.name}`,
`${err.message}\n\n${err.stack?.slice(0, 1500)}`,
8
);
res.status(500).json({error: 'internal'});
});
// catch things that crashed outside a request context
process.on('unhandledRejection', (reason) => {
notify('💥 unhandledRejection', String(reason).slice(0, 1500), 9);
});
process.on('uncaughtException', (err) => {
notify('💥 uncaughtException', `${err.message}\n${err.stack}`, 10);
});
notifly.py
import os, time, httpx
from collections import OrderedDict
_COOLDOWN = 60
_seen: "OrderedDict[str, float]" = OrderedDict()
def notify(title: str, message: str, priority: int = 7) -> None:
key = title + message[:200]
now = time.time()
if now - _seen.get(key, 0) < _COOLDOWN:
return
_seen[key] = now
if len(_seen) > 200:
_seen.popitem(last=False)
try:
httpx.post(
f"{os.environ['NOTIFLY_URL']}/message",
params={"token": os.environ["NOTIFLY_TOKEN"]},
json={"title": title, "message": message, "priority": priority},
timeout=5,
)
except Exception as e:
print(f"notifly failed: {e}")

FastAPI exception handler:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import traceback
from notifly import notify
app = FastAPI()
@app.exception_handler(Exception)
async def all_exceptions(request: Request, exc: Exception) -> JSONResponse:
notify(
title=f"🐛 {request.method} {request.url.path}{type(exc).__name__}",
message=f"{exc}\n\n{traceback.format_exc()[-1500:]}",
priority=8,
)
return JSONResponse({"error": "internal"}, status_code=500)
notifly.go
package notifly
import (
"bytes"
"encoding/json"
"net/http"
"os"
"sync"
"time"
)
var (
seen = map[string]time.Time{}
seenLock sync.Mutex
cooldown = 60 * time.Second
client = &http.Client{Timeout: 5 * time.Second}
)
func Notify(title, message string, priority int) {
key := title
if len(message) > 200 {
key += message[:200]
} else {
key += message
}
seenLock.Lock()
if time.Since(seen[key]) < cooldown {
seenLock.Unlock()
return
}
seen[key] = time.Now()
seenLock.Unlock()
body, _ := json.Marshal(map[string]any{
"title": title,
"message": message,
"priority": priority,
})
url := os.Getenv("NOTIFLY_URL") + "/message?token=" + os.Getenv("NOTIFLY_TOKEN")
go func() {
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
if err == nil {
resp.Body.Close()
}
}()
}

Middleware for net/http:

import (
"fmt"
"net/http"
"runtime/debug"
)
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := string(debug.Stack())
if len(stack) > 1500 {
stack = stack[:1500]
}
Notify(
fmt.Sprintf("🐛 %s %s — panic", r.Method, r.URL.Path),
fmt.Sprintf("%v\n\n%s", rec, stack),
9,
)
http.Error(w, "internal", 500)
}
}()
next.ServeHTTP(w, r)
})
}
  • Service start. ”🚀 backend v1.42 started on host-A” — the deployment is immediately visible.
  • DB health degradation (timeout > N ms) — separate priority.
  • Suspicious security events. For example, an authentication attempt from an unknown User-Agent for an admin account.
  • Business events. First payment from a new client, quota exceeded.
  • Knowing about 500s within a minute, not an hour later from a user complaint.
  • Context in your pocket. Method, path, short stack trace — usually enough to open your IDE and start fixing.
  • Throttling built-in — no 10,000 notifications due to a broken DB pool.