Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 11 min read

Microsoft Teams Error Guide: '429 TooManyRequests' Microsoft Graph Throttling

Fix Microsoft Graph API 429 TooManyRequests throttling in Teams: read Retry-After headers, identify throttle scope, implement backoff, and design compliant clients.

  • #microsoft-teams
  • #troubleshooting
  • #errors
  • #graph-api

Overview

Microsoft Graph enforces per-app, per-tenant, and per-resource throttling limits. When a client exceeds those limits, Graph returns 429 Too Many Requests with a Retry-After response header specifying the number of seconds to wait before retrying. Ignoring the header and retrying immediately makes throttling worse — Microsoft’s throttling algorithm is leaky-bucket based, so aggressive retries extend the backoff window.

The literal JSON error body from Graph:

{
  "error": {
    "code": "TooManyRequests",
    "message": "Too many requests",
    "innerError": {
      "code": "throttledRequest",
      "date": "2026-06-23T15:01:44",
      "request-id": "f6a7b8c9-6666-7777-8888-99990000aaaa",
      "client-request-id": "f6a7b8c9-6666-7777-8888-99990000aaaa"
    }
  }
}

And the critical response header that must be read and honoured:

HTTP/2 429
Retry-After: 47
Content-Type: application/json

Throttling on Microsoft Graph is scoped — a rate-limited /teams/ call does not block /users/ calls from the same app. Limits are not published as fixed numbers; Microsoft adjusts them dynamically based on service load, tenant size, and call patterns. The only reliable signal is the Retry-After header value on a 429 response.

Symptoms

  • Burst of Graph API calls returns 429 after a period of success.
  • Retry-After header is present on every 429 response with a value between 1 and 600 seconds.
  • Parallel workers hitting the same endpoint simultaneously all begin throttling together.
  • Message send or channel enumeration that works interactively fails under load in automation.
  • Calls to /teams/{id}/channels/{id}/messages throttle earlier than other endpoints due to their lower per-app limits.
curl -si -X GET \
  "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels" \
  -H "Authorization: Bearer $TOKEN" \
  | head -20
HTTP/2 429
retry-after: 47
content-type: application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8
request-id: f6a7b8c9-6666-7777-8888-99990000aaaa
client-request-id: f6a7b8c9-6666-7777-8888-99990000aaaa

{
  "error": {
    "code": "TooManyRequests",
    "message": "Too many requests"
  }
}

Common Root Causes

1. Parallel workers hitting the same endpoint concurrently

Spinning up many workers that all call the same Graph resource simultaneously — such as N threads each calling GET /teams/{id}/channels — concentrates the per-app quota on a single endpoint type. The aggregate request rate exceeds Graph’s per-minute limit for that resource class.

# Observe rate of 429 responses across a burst of parallel requests
for i in $(seq 1 20); do
  curl -si -o /dev/null -w "%{http_code}\n" \
    "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels" \
    -H "Authorization: Bearer $TOKEN" &
done
wait
200
200
200
429
429
429
429
429
429
429
...

The first few succeed; the rest are throttled because the burst exhausted the per-minute window.

2. Retry logic that ignores Retry-After and retries immediately

A client that catches 429 and retries immediately — with no sleep or with a fixed short delay shorter than Retry-After — keeps adding requests to an already-saturated window, extending throttling for all callers sharing the quota.

# Capture the Retry-After value from a 429 response
RETRY_AFTER=$(curl -si \
  "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels" \
  -H "Authorization: Bearer $TOKEN" \
  | grep -i '^retry-after:' | awk '{print $2}' | tr -d '\r')

echo "Must wait: ${RETRY_AFTER} seconds"
Must wait: 47 seconds

If RETRY_AFTER is 47 and the client retries after 1 second, it will receive another 429 and potentially a longer Retry-After on the next response.

3. Enumerating large Teams or channel lists without pagination

Fetching large collections — such as all channels across many teams or all messages in a busy channel — with a page size of 1 generates far more requests than necessary. Using the maximum $top value and following @odata.nextLink reduces the total request count significantly.

# Bad: fetching one item at a time (maximal requests)
curl -s "https://graph.microsoft.com/v1.0/me/joinedTeams?\$top=1" \
  -H "Authorization: Bearer $TOKEN" | jq '{"count": (.value | length), "nextLink": ."@odata.nextLink"}'
{
  "count": 1,
  "nextLink": "https://graph.microsoft.com/v1.0/me/joinedTeams?$top=1&$skiptoken=..."
}

Each page is a separate request against the quota. For a user in 50 teams, this generates 50 requests instead of 2–3 with $top=20.

4. Polling endpoints that do not support change notifications

Applications that poll GET /teams/{id}/channels/{id}/messages on a short interval to detect new messages generate continuous load. Microsoft Graph supports delta queries and change notifications (webhooks) for Teams messages — polling is unnecessary and quota-intensive.

# Poll for new messages every 10 seconds (bad pattern — will throttle)
while true; do
  curl -s "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels/19%3Axxx%40thread.tacv2/messages?\$top=5" \
    -H "Authorization: Bearer $TOKEN" | jq '.value | length'
  sleep 10
done
5
5
429   # throttled after repeated polling

5. Multiple apps sharing the same service principal quota

Per-app throttling is keyed on appId. If multiple microservices or jobs authenticate as the same Azure AD application, they share a single throttle budget. One high-volume service can starve another that uses the same app registration.

# Check the appId in the token — all services using this ID share the same throttle quota
echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{appid, tid}'
{
  "appid": "aabb1122-3344-5566-7788-99aabbccddee",
  "tid": "aaaabbbb-1111-2222-3333-ccccddddeeee"
}

If multiple distinct services decode to the same appid, separating them into distinct app registrations will isolate their throttle budgets.

6. Teams message send operations exceeding per-channel limits

POST /teams/{id}/channels/{id}/messages has tighter limits than read operations. Sending many messages to the same channel in rapid succession — such as posting individual lines of a build log — will throttle the send endpoint while the read endpoints on the same app remain unaffected.

# Check if the throttle is specific to the send endpoint by testing a read after a 429 on send
curl -s -X POST \
  "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels/19%3Axxx%40thread.tacv2/messages" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":{"content":"test"}}' | jq .error.code
"TooManyRequests"
# Immediately test a read on the same app token — if this succeeds, throttle is endpoint-scoped
curl -s "https://graph.microsoft.com/v1.0/me/joinedTeams" \
  -H "Authorization: Bearer $TOKEN" | jq .error
null

null error on the read confirms the throttle is scoped to the send endpoint, not the entire app quota.

Diagnostic Workflow

Step 1: Capture and read the Retry-After header from the 429 response

curl -si -X GET \
  "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels" \
  -H "Authorization: Bearer $TOKEN" \
  | grep -iE '^(HTTP|retry-after|request-id)'

Record the Retry-After value. This is the authoritative wait time — do not retry before it elapses.

Step 2: Determine the throttle scope (endpoint-specific vs. app-wide)

# While throttled on /teams, try a different resource class
curl -s "https://graph.microsoft.com/v1.0/me" \
  -H "Authorization: Bearer $TOKEN" | jq '{displayName, mail}'

curl -s "https://graph.microsoft.com/v1.0/me/joinedTeams" \
  -H "Authorization: Bearer $TOKEN" | jq '.value | length'

If /me succeeds during the throttle, the limit is scoped to the Teams resource class, not the entire app. Design your backoff to only pause calls to the throttled endpoint class.

Step 3: Measure your request rate against the failing endpoint

# Count requests per minute to the throttled endpoint from application logs
grep "graph.microsoft.com/v1.0/teams" /var/log/myapp/access.log \
  | awk '{print $1}' | cut -d'T' -f2 | cut -d':' -f1,2 | sort | uniq -c
  12 14:00
  18 14:01
  47 14:02    # spike — throttling begins here
 429 14:03    # all requests throttled
  15 14:04

A request rate spike preceding the 429 storm confirms the trigger. Implement rate limiting at the client to smooth spikes before they hit Graph.

Step 4: Check whether delta query or change notifications can replace polling

# Initialize a delta query to get only changed messages (far fewer requests than polling)
curl -s "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels/19%3Axxx%40thread.tacv2/messages/delta" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '{"messageCount": (.value | length), "deltaLink": ."@odata.deltaLink"}'
{
  "messageCount": 12,
  "deltaLink": "https://graph.microsoft.com/v1.0/.../messages/delta?$deltatoken=..."
}

Store the deltaLink and use it on the next call — Graph returns only changes since the last delta call, drastically reducing request volume.

Step 5: Implement exponential backoff respecting Retry-After

# Shell example: retry with Retry-After respect
call_graph_with_backoff() {
  local url="$1"
  local max_retries=5
  local attempt=0

  while [ $attempt -lt $max_retries ]; do
    response=$(curl -si "$url" -H "Authorization: Bearer $TOKEN")
    status=$(echo "$response" | grep '^HTTP' | awk '{print $2}')

    if [ "$status" = "200" ]; then
      echo "$response" | tail -1
      return 0
    elif [ "$status" = "429" ]; then
      retry_after=$(echo "$response" | grep -i '^retry-after:' | awk '{print $2}' | tr -d '\r')
      echo "Throttled. Waiting ${retry_after}s (attempt $((attempt+1))/$max_retries)" >&2
      sleep "$retry_after"
      attempt=$((attempt+1))
    else
      echo "Error $status" >&2
      return 1
    fi
  done
}

call_graph_with_backoff "https://graph.microsoft.com/v1.0/me/joinedTeams"

Example Root Cause Analysis

A deployment pipeline sends one Teams channel message per test result at the end of CI runs. With a test suite of 200 tests, the pipeline fires 200 POST /teams/{id}/channels/{id}/messages requests in 8 seconds. The first 60–80 succeed; the remainder return 429 with Retry-After: 120. The pipeline has no retry logic and marks the deployment notification step as failed, even though the deployment succeeded.

Checking the endpoint throttle scope shows reads are still working:

curl -s "https://graph.microsoft.com/v1.0/me/joinedTeams" \
  -H "Authorization: Bearer $TOKEN" | jq '.value | length'
8

Reads succeed. The throttle is scoped to POST /messages on that channel. The fix has two parts: aggregate all test results into a single message body (one POST instead of 200), and add Retry-After-aware retry logic for the remaining single send call:

# Aggregate results into one message body
SUMMARY=$(cat test-results.json | jq -r '"Pass: \(.pass) | Fail: \(.fail) | Skip: \(.skip)"')

curl -s -X POST \
  "https://graph.microsoft.com/v1.0/teams/aaaabbbb-1111-2222-3333-ccccddddeeee/channels/19%3Axxx%40thread.tacv2/messages" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"body\":{\"content\":\"$SUMMARY\"}}"

One request instead of 200 eliminates the throttle trigger entirely.

Prevention Best Practices

  • Always read and honour the Retry-After header on every 429 response — it is the only authoritative signal. Implement it as a hard requirement, not a suggestion.
  • Aggregate message content before sending: one Teams message with a formatted table beats 50 individual messages.
  • Use delta queries (/delta) for change detection instead of polling. For real-time needs, register a change notification subscription (/subscriptions) to receive push webhooks from Graph.
  • Apply client-side rate limiting — track your request count per endpoint class per minute and insert sleep before exceeding a conservative threshold well below Graph’s limits.
  • Separate high-volume services into distinct Azure AD app registrations so their throttle budgets are isolated from each other.
  • Use $top with the maximum allowed value and follow @odata.nextLink for paginated results to minimize total request count per enumeration.
  • For incident correlation when throttling spikes coincide with deployments or schedule changes, the incident assistant can pinpoint which service or job triggered the quota exhaustion from log timestamps.

Quick Command Reference

# Capture Retry-After from a 429 response
curl -si "https://graph.microsoft.com/v1.0/teams/<TEAM_ID>/channels" \
  -H "Authorization: Bearer $TOKEN" \
  | grep -iE '^(HTTP|retry-after|request-id)'

# Test throttle scope — check if /me still responds during Teams throttle
curl -s "https://graph.microsoft.com/v1.0/me" \
  -H "Authorization: Bearer $TOKEN" | jq .displayName

# Initialize a delta query for Teams channel messages
curl -s "https://graph.microsoft.com/v1.0/teams/<TEAM_ID>/channels/<CHANNEL_ID>/messages/delta" \
  -H "Authorization: Bearer $TOKEN" | jq '{"count": (.value|length), "deltaLink": ."@odata.deltaLink"}'

# Paginate joined teams with maximum page size
curl -s "https://graph.microsoft.com/v1.0/me/joinedTeams?\$top=20" \
  -H "Authorization: Bearer $TOKEN" | jq '{"count": (.value|length), "nextLink": ."@odata.nextLink"}'

# Send a single aggregated message instead of many
curl -s -X POST \
  "https://graph.microsoft.com/v1.0/teams/<TEAM_ID>/channels/<CHANNEL_ID>/messages" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":{"contentType":"html","content":"<b>Summary:</b> ..."}}'

# Register a change notification subscription (replace polling)
curl -s -X POST "https://graph.microsoft.com/v1.0/subscriptions" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "changeType": "created",
    "notificationUrl": "https://your-webhook.example.com/graph-notify",
    "resource": "/teams/<TEAM_ID>/channels/<CHANNEL_ID>/messages",
    "expirationDateTime": "2026-06-24T00:00:00Z"
  }' | jq '{id, resource, expirationDateTime}'

Conclusion

A 429 TooManyRequests from Microsoft Graph means the per-app or per-resource throttle budget for a given endpoint class was exhausted. The Retry-After header is the only authoritative recovery signal — the root causes in order of frequency:

  1. Parallel workers bursting requests to the same endpoint simultaneously, exhausting the per-minute quota in seconds.
  2. Retry logic that ignores Retry-After and retries immediately, compounding the throttling window.
  3. Pagination not used or used with a tiny $top value, generating many more requests than necessary.
  4. Polling endpoints repeatedly instead of using delta queries or change notification subscriptions.
  5. Multiple services sharing the same appId, so one high-volume service consumes quota shared with others.
  6. High-frequency POST /messages calls sending one message per event instead of batching into a single aggregated message.

The fastest fix is always to read Retry-After, sleep for that duration, then retry once. The lasting fix is to reduce total request volume through batching, pagination, and delta queries. See more troubleshooting guides in Microsoft Teams guides.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.