Mail rate limit (Microsoft Graph)

Dit document legt uit waarom we een eigen rate limiter rond Microsoft Graph hebben, hoe die werkt, en welke ontwerpkeuzes erachter zitten. Voor concrete getallen en log-events, zie de reference.

Probleem

Microsoft Graph hanteert harde limieten op sendMail per mailbox: ongeveer 30 verzoeken per minuut en 10.000 ontvangers per 24 uur, plus een burst-limit. Bij overschrijding antwoordt Graph met een 429 of 503. Een geweigerd verzoek kost ons retries, vertraging, en in het slechtste geval bouncing van een hele batch.

In de oude situatie verstuurden de monitor-systeem-mailbox en tenant-Graph-mailers direct, zonder lokale rem. Bij een grote campagne-uitnodiging stuurde de worker honderden mails in een paar seconden. Dat ging meestal goed, maar een enkele piek (gelijktijdige campagnes, of een retry-storm) liep tegen Graph aan, met onvoorspelbare uitval als gevolg.

Concept

Voor elke uitgaande mail via Microsoft Graph reserveren we eerst een plek binnen het minuut- en dagbudget van de bewuste mailbox. Lukt dat, dan gaat de mail uit. Lukt het niet, dan plannen we de message verderop in de tijd, op een moment waarop het budget weer ruimte heeft.

De reservering is check-and-reserve: één atomaire Redis-operatie die in dezelfde stap controleert en ophoogt. Twee parallelle workers kunnen dus niet allebei “ja, mag” terugkrijgen voor de laatste plek.

Waarom een eigen rem en niet alleen Graph’s 429 afvangen

Reactief reageren op 429 heeft drie problemen:

  • Het is duurder: het verzoek is al gedaan. Daadwerkelijk lichaam, attachments, headers, alles ging over de lijn.
  • Het is grover: Graph geeft een retry-after, maar verdeelt die niet over workers. Twee pods die tegelijk een 429 krijgen, retryen tegelijk, en lopen weer aan.
  • Het loopt te dicht op de rand: Microsoft kan zonder waarschuwing tijdelijk strenger zijn. Een eigen budget houdt veiligheidsmarge.

Een pre-emptieve rem houdt het verkeer onder de drempel, en geeft ons één centrale plek om te tunen, te loggen, en per mailbox te overschrijven.

Architectuur

De rem zit als decorator om de Graph-mailer:

MailProviderService.send
        │
        ▼
mailerFactory.getMailer / getMailerForSystemMailbox
        │
        ▼
RateLimitedGraphMailer.send  ◄── checkAndReserve op Redis
        │
        ▼
MicrosoftGraphMailer.send    ◄── echte Graph-call

MailerFactory.wrapWithRateLimiter wikkelt elke nieuwe Graph-mailer, zowel de tenant-variant (provider: 'microsoft-graph') als de systeem-mailbox-variant. SendGrid wordt niet gewrapt: SendGrid heeft een eigen, ruimer rate-limit-stelsel en eigen 429-afhandeling.

De decorator deelt één gedeelde MailRateLimiter-instance per pod (singleton), die op zijn beurt één gedeelde Redis-verbinding gebruikt. Zo zien alle workers binnen alle pods dezelfde teller per mailbox.

Atomaire reservering met Lua

De checkAndReserve is een Lua-script in Redis. Lua draait server-side en is atomair binnen één Redis-instance. Het script doet in één rondreis:

  1. Lees de huidige minuut-teller en dag-teller voor deze mailbox.
  2. Zou +1 minuut-teller boven de minuut-limiet uitkomen? Geef terug “geweigerd, reden minuut, retry over X ms”.
  3. Zou +aantalOntvangers dag-teller boven de dag-limiet uitkomen? Geef terug “geweigerd, reden dag, retry over X ms”.
  4. Anders: hoog beide tellers op, zet TTL’s, geef terug “toegestaan”.

Het alternatief, een GET gevolgd door INCR vanuit de applicatie, is niet atomair. Twee workers kunnen tegelijk lezen “we zijn op 24/25”, besluiten “mag”, en allebei naar 26 ophogen. Dat overschrijdt het budget. Lua sluit dat dichte race-window.

De minuut-key heeft een TTL van 120 seconden zodat hij vanzelf verdwijnt na de minuut. De dag-key heeft een TTL van 26 uur, zodat de teller per kalender-dag-bucket zelf opruimt.

Reschedulen in plaats van blokkeren

Wanneer de reservering wordt geweigerd, throwt de decorator MailRateLimitExceededError met de reden (minute of day) en retryAfterMs. MailProviderService.send vangt dat op en doet:

  1. Bereken een spread van 0 tot 30 seconden via een FNV-1a hash van het message-id.
  2. Zet scheduledOn = now + retryAfterMs + spread, status SCHEDULED.
  3. Log mail.rateLimit.deferred.

Geen exception, geen ERROR-status, geen handmatige retry. De BullMQ-worker send-scheduled-messages pakt de message vanzelf weer op zodra scheduledOn voorbij is.

De spread per message-id voorkomt dat alle uitgestelde messages exact gelijk hervat worden. Als 200 messages tegelijk om 12:00:00 een minuut-deny krijgen, worden ze niet allemaal om 12:00:30 opnieuw geprobeerd, maar gespreid over 12:00:30 tot 12:01:00. De hash is deterministisch per message-id, dus dezelfde message krijgt bij retry dezelfde extra delay.

Waarom fail-open bij Redis-uitval

Als Redis niet bereikbaar is, geeft checkAndReserve “toegestaan” terug en logt mail.rateLimit.redisError. De mail gaat dus alsnog uit.

De afweging:

  • Fail-closed: bij een Redis-storing zou geen enkele Graph-mail meer uitgaan. De rem zou een outage van mail veroorzaken die er zonder de rem niet was.
  • Fail-open: bij een Redis-storing valt de pre-emptieve rem weg, maar Graph’s eigen 429-bescherming blijft staan. We krijgen dus tijdelijk de oude situatie terug, niet erger.

We kiezen fail-open. Mailaflevering is belangrijker dan een tijdelijke breaker, en de impact is begrensd door Graph’s eigen limieten. De warn-log maakt zichtbaar dát we open lopen, zodat we het kunnen herstellen.

Release bij faal van de echte Graph-call

Als de inner Graph-call gooit (netwerkfout, 5xx, validatie), draait de decorator de reservering terug via release. Anders zou een mislukte send het minuut- en dagbudget consumeren zonder dat er werkelijk verzonden is. Dat zou bij een Graph-storing onze eigen budget-uitputting versnellen.

Een succesvolle send laat de reservering staan: het budget is werkelijk verbruikt.

Limieten per mailbox

De defaults staan iets onder de Microsoft-limieten (zie reference) zodat we marge houden voor burst en for systemen die hetzelfde Azure-account delen.

Voor systeem-mailboxen kun je via de admin-UI per mailbox rateLimitPerMinute en rateLimitRecipientsPerDay overschrijven. Bijvoorbeeld een mailbox die op een Office 365-licentie zit met aangepaste throttling kan ruimer worden gezet, of een gevoelige mailbox juist strenger.

Tenant-Graph-mailers hebben vandaag geen override en draaien altijd op de defaults. Daar speelt de limiet minder, omdat de meeste tenants in lager volume mailen.

Verhouding tot het bestaande mailstelsel

Het tenant-SendGrid-pad blijft volledig ongewrapt. De rate-limit-decorator zit alleen op Microsoft Graph, en is voor de aanroeper onzichtbaar: de Mailer.send-interface is identiek. MailProviderService ziet alleen de Mailer, weet niets over Redis of Lua, en gedraagt zich qua post-send identiek.

De factory-cache invalideert per systeem-mailbox als rateLimitPerMinute of rateLimitRecipientsPerDay wijzigt, want die zitten in de cache-hash. Aanpassingen in de admin-UI werken dus zonder pod-restart door.

Verwante documenten