Integrations
Graph throttling: 19 retry-after quirks die de SDK verbergt
De agent had drie dagen lang inbox-triage afgehandeld bij een Haags advocatenkantoor voordat we merkten dat hij stilletjes elk vijfde reply-draadje liet vallen.

Het was dinsdagochtend toen een van de partners onze standup binnenliep met een uitdraai. Een cliënt had in drie dagen twee keer gemaild over een datum voor een depositie. De triage-agent had beide berichten gelezen. Hij had ze ook allebei als afgehandeld gemarkeerd. De daadwerkelijke reply was nooit verstuurd, en niemand in het team had de thread gezien.
Het kantoor heeft ongeveer 52 advocaten en juristen, verdeeld over twee verdiepingen in Den Haag. We hadden vier weken besteed aan het inhaken van een mail-triage-agent op hun Microsoft 365-tenant. Hij las binnenkomende mail, classificeerde op rechtsgebied en urgentie, stelde antwoorden op die de partner alleen nog hoefde te tekenen, en zette een regel weg in een Power Automate-flow die hun zaakbeheersysteem voedde. Op papier werkte de agent. De doorvoer zag er schoon uit op het dashboard.
De werkelijkheid was lelijker. We hadden een Microsoft Graph throttling-probleem, en de officiële Graph SDK verborg het grootste deel ervan.
Wat er eigenlijk gebeurde
De SDK retry-middleware ving de 429's en de 503's op. Hij deed een backoff, herhaalde de call, en gaf een 200 terug aan onze applicatiecode. Logs zagen er groen uit. Wat hij ons niet vertelde, was dat bij bepaalde retry-paden het bericht waarop we werkten een andere conversationId had dan degene die we de eerste keer in onze database hadden weggeschreven. De thread waarop onze agent dacht te antwoorden bestond niet meer. De nieuwe draft kwam tegen een andere conversatie aan, die de partner nooit zag omdat zijn Outlook-weergave op de oorspronkelijke conversation header gesorteerd stond.
We hebben elke quirk die we tegenkwamen gelogd, ze gerangschikt naar hoeveel stille schade elk veroorzaakte, en een custom retry handler uitgerold. Hieronder de cheatsheet. Hang 'm op naast iedereen die in productie aan Microsoft Graph throttling zit.
De cheatsheet, gerangschikt op stille schade
Bovenaan staat alles wat een 200 teruggeeft aan je code terwijl het werk verloren gaat. Onderaan staat alles wat netjes faalt. In het midden zit de echte rommel.
Tier 1: de SDK slikt het op en je verliest state
- conversationId muteert bij retry over folders heen. Als een Power Automate-flow of een regel aan de gebruikerskant het bericht tussen folders verplaatst terwijl je call midden in een retry zit, resolvet de resource naar een andere conversatie. De SDK geeft 200 terug. Je database wijst nu naar een verouderde thread.
- Delta-token ongeldig na een getrottlede write. Een 429 op een
POSTtegen een mailbox die je ook via/deltasynct, kan de delta state token bij de volgende call ongeldig maken. De SDK haalt stilletjes een verse token op en speelt de laatste pagina berichten opnieuw af. Je agent verwerkt ze twee keer. - Application token en delegated token zitten niet in dezelfde throttle pool. Twee diensten die dezelfde mailbox bestoken onder verschillende auth flows, delen op geen enkele gedocumenteerde manier een quota. We hadden een Power Automate-connector en onze agent allebei op delegated permissions draaien en die liepen elkaars limieten in zonder dat een van beide de oorzaak logde.
- Subscription verloopt mid-retry, agent reconnect met een verse
clientState. Change-notification subscriptions leven voor messages maximaal 4230 minuten. Als de SDK retry loop het renewal-window overspant, komt de volgende notificatie binnen met een andere validation token en wijst je gate hem af. - Batch-endpoint verstopt per-request 429's onder een 200. Een
$batchcall met 20 sub-requests kan overall 200 OK teruggeven, terwijl individuele sub-requests in de body status 429 hebben. De SDK retryt die sub-requests standaard niet. Jouw handler loopt door de response en vindt vijf "geslaagde" operaties die nooit zijn uitgevoerd. - Token refresh tijdens een retry laat het request vallen. Als de access token verloopt tussen de oorspronkelijke call en de retry, vragen sommige middleware-versies een nieuwe token op, maar verzenden ze de onderliggende HTTP-request nooit opnieuw. Je code ziet een normale completion met een lege body.
- ETag-mismatch bij de tweede poging veroorzaakt een stille skip. Conditional updates die na een 503 worden geretried, lopen op een andere ETag aan omdat een andere client de resource heeft aangeraakt. De SDK krijgt 412 terug in zijn inner loop, slikt het op als transient, en je
PATCHlandt nooit.
Tier 2: zichtbare fouten die de SDK gek rapporteert
- Retry-After in seconden vs Retry-After-Ms. Graph geeft soms
Retry-Afterterug in hele seconden en soms een customRetry-After-Msin milliseconden. De SDK parset alleen de eerste. Zijn beide aanwezig, dan zit je backoff er drie ordes van grootte naast. - 503 zonder Retry-After. Diverse outage-achtige 503's komen binnen zonder retry-hint. De SDK valt terug op exponential backoff met jitter, die het feitelijke outage-window flink kan overschrijden. We zagen sleeps van 90 seconden voor outages die binnen 4 seconden opgelost waren.
- Per-mailbox 10k/10min sliding window reset midden in een batch. Het window is gedocumenteerd op 10.000 requests per 10 minuten per mailbox, maar het is een sliding window, geen vaste bucket. Een lange batch die op minuut 9:59 begint, kan de reset overlappen en half-throttled worden.
- 4 concurrent requests per mailbox is niet per endpoint. De cap geldt over read- en write-endpoints heen, samen. Een delta-sync die op de achtergrond draait neemt een slot in, ook als hij idle op de lijn ligt.
- Retry-After in HTTP-date format kruist middernacht. Als Graph een absolute HTTP-date teruggeeft in plaats van een delta, parsen bepaalde SDK-versies in niet-UTC-timezones een tijd die al voorbij is en retryen ze direct, waarmee ze precies de throttle hameren die net kwam opzetten.
- /me/messages en /users/{id}/messages vallen in andere throttle-classes. Tijdens een refactor switchten we van de delegated
/me-shortcut naar het expliciete user-ID pad, waarmee onze quota-class veranderde en het throttle-profiel stilletjes verschoof.
Tier 3: de operationele valkuilen
- Mixed batch telt de write-quota voor de hele batch. Een
$batchmet één write en 19 reads telt voor alle 20 mee aan de write-side budget. - x-ms-throttle-limit-percentage wordt niet door de officiële SDK doorgegeven. Graph emit een header die je precies vertelt hoe dicht je tegen een throttle aan zit, maar de SDK abstraheert hem weg. Je moet een custom delegating handler schrijven om hem uit te lezen.
- Exponential backoff overschrijdt Retry-After. De default backoff-curve van de SDK slaapt na drie retries langer dan de server vroeg. Je worker thread staat geparkeerd terwijl de bucket alweer aangevuld is.
- internetMessageId is stabiel,
idniet. Idempotency op basis vanidbreekt zodra een bericht verplaatst of beantwoord wordt. DeinternetMessageId(de RFC 5322-header) overleeft. Index je dedup-tabel daarop. - ClientRequestId-hergebruik over retries botst met idempotency caches. Als je dezelfde
client-request-idover een SDK-retry pad hergebruikt, geeft Graph soms de gecachete response van de eerste poging terug, en dat kan precies die 429 zijn. - Polling van /delta tijdens een getrottled notification-window mist items. Als je daarnaast terugvalt op polling wanneer subscription-notificaties stagneren, schuift de delta-link cursor voorbij items die de subscription wel had gequeued maar nooit afleverde.
Is je monitoring "heeft de SDK 200 teruggegeven", dan monitor je Graph niet. Log x-ms-resource-unit, x-ms-throttle-limit-percentage, en de status codes van de sub-responses per batch. De SDK doet het niet voor je.
De retry handler die wel werkte
We vervingen de default RetryHandler door een custom delegating handler. De vorm zag er in TypeScript ruwweg zo uit:
import { Middleware, Context } from "@microsoft/microsoft-graph-client";
export class HonestRetry implements Middleware {
private next?: Middleware;
setNext(next: Middleware) { this.next = next; }
async execute(ctx: Context): Promise<void> {
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await this.next!.execute(ctx);
const res = ctx.response!;
if (res.status < 429) return;
const ms = parseRetry(res.headers);
const pct = res.headers.get("x-ms-throttle-limit-percentage");
log.warn({ attempt, status: res.status, ms, pct, url: ctx.request });
// Honour the server. Never sleep longer than asked.
await sleep(Math.min(ms, 30_000));
// Re-stamp idempotency so we never replay a cached error.
ctx.options!.headers = {
...ctx.options!.headers,
"client-request-id": crypto.randomUUID(),
};
}
}
}
function parseRetry(h: Headers): number {
const ms = h.get("retry-after-ms");
if (ms) return parseInt(ms, 10);
const s = h.get("retry-after");
if (!s) return 1000;
const asInt = parseInt(s, 10);
if (!isNaN(asInt)) return asInt * 1000;
// HTTP-date format. Compute delta in UTC.
const t = Date.parse(s) - Date.now();
return t > 0 ? t : 1000;
}
Twee details doen ertoe. We slapen nooit langer dan de server vroeg, en we munten een verse client-request-id bij elke retry, zodat de idempotency cache aan de Graph-kant ons geen oude 429 kan terugspelen. Voor batch-calls wikkelen we de response en gooien we elke sub-request die geen 2xx is op als een echte exception, niet als een opgeslokte warning.
Idempotency op het juiste veld
De andere wijziging die direct rendement gaf: we stopten met message.id als dedup-sleutel in onze triage-database en stapten over op internetMessageId. De id roteert bij folder-moves en bij sent-items replays. De RFC 5322-header niet. Antwoordt, forward of routeert je agent mail, dan is dit het veld dat je wilt.
Voor subscriptions vernieuwen we nu op 75% van de gedocumenteerde lifetime, niet op verloop. De officiële richtlijn staat tot 4230 minuten toe voor messages, maar renewal onder throttle-druk kan 30 seconden duren. Vroeg vernieuwen geeft het retry-pad ruimte om te landen voordat de subscription verdampt.
Hoe het dashboard er nu uitziet
We exporteren elke minuut vier getallen naar het ops-dashboard van het kantoor: percentage van de mailbox-throttle bucket verbruikt, aantal sub-request 429's binnen batch-calls, aantal delta-token resets in het laatste uur, en aantal subscription-renewals dat langer dan 5 seconden duurde. Geen van die getallen is zichtbaar als je alleen naar HTTP-statuscodes kijkt.
In de eerste week nadat de nieuwe handler live ging, vonden we twee aanvullende quirks die we nog niet eerder gezien hadden, allebei in tier 1. Die hebben we boven aan de cheatsheet toegevoegd. De lijst groeit. Graph is een bewegend doel en de SDK-abstracties staan niet aan jouw kant zodra je een agent op productievolume draait.
De audit van vijf minuten
Draai je een Graph-gebaseerde agent tegen een mailbox van een klant, grep dan vandaag je code op drie dingen. Eén: elke plek waar je message.id uitleest en wegschrijft. Stap over op internetMessageId, of voeg een tweede kolom toe. Twee: elke plek waar je $batch aanroept. Controleer dat je over de status codes van de sub-responses loopt, niet alleen over de buitenste 200. Drie: elke plek waar je op de default retry van de SDK vertrouwt. Vervang hem, of log op zijn minst bij elke response x-ms-throttle-limit-percentage, zodat je de muur ziet voordat je ertegenaan klapt.
Toen we de mail-triage-agent bouwden voor het Haagse kantoor, was niet het taalwerk of de classificatie op rechtsgebied wat het langst duurde. Het was leren waar de Graph SDK tegen je liegt. Wikkel je AI-agents in Microsoft 365 en zegt je dashboard dat alles in orde is, dan is dat het moment om de headers te checken die de SDK verstopt.
Kern
Bestaat je Graph-monitoring uit alleen 'heeft de SDK 200 teruggegeven', dan monitor je Graph niet. De SDK verbergt minstens zeven failure-modes die je state kosten.
FAQ
Retryt de Microsoft Graph SDK getrottlede requests automatisch?
Ja, maar de default retry handler slikt verschillende failure-modes als succes weg, waaronder per-sub-request 429's binnen batch calls en gevallen waarin een token refresh het request laat vallen. Log altijd zelf de onderliggende response headers.
Wat is de veiligste idempotency-sleutel voor een Graph mail-agent?
Gebruik internetMessageId, de RFC 5322-header. Het id-veld van Graph roteert zodra een bericht tussen folders wordt verplaatst of via sent-items wordt teruggespeeld, dus voor deduplicatie is het niet veilig.
Hoe lang moet ik wachten voordat ik een 429 van Graph opnieuw probeer?
Gebruik de Retry-After of Retry-After-Ms header letterlijk. Laat de exponential backoff van de SDK die waarde niet overschrijden, en slaap nooit langer dan de server vraagt.
Waarom verwerkt mijn agent dezelfde mail twee keer na een throttling-event?
Een 429 op een write tegen een mailbox die je delta-synct, kan de delta state token ongeldig maken, waardoor de volgende sync de laatste pagina berichten opnieuw afspeelt. Bewaar verwerkte internetMessageIds en check ze bij binnenkomst.