A Dataverse integration gets 429 Too Many Requests when one user account, including an application user, exceeds a service protection limit on a web server: 6,000 requests or 20 minutes of combined execution time in a five-minute sliding window, or more than 52 concurrent requests. The fix is to pause for the number of seconds in the Retry-After response header, then design the load so it stays near the limit instead of exceeding it: cap parallelism at the x-ms-dop-hint value, disable the server affinity cookie, use bulk messages sensibly and keep plug-ins fast. Retrying blindly or adding more threads makes the Retry-After periods longer.
Who this is for and what you will have at the end
This guide is for developers and integration architects who load or synchronise Dataverse data from custom code, PowerShell, middleware or Azure Data Factory and see 429 responses, throttling faults, or loads that stall for minutes.
At the end you will have:
- A clear picture of the three service protection limits and how they differ from Power Platform request limits.
- A way to identify which limit a failing request hit.
- Retry logic that respects
Retry-Afterin PowerShell and .NET. - A throughput design: degree of parallelism, affinity, batching and bulk operation messages.
- Azure Data Factory copy activity settings that work with the limits rather than against them.
How Dataverse service protection limits work
Dataverse evaluates service protection limits per user. Every authenticated identity has an independent limit, and Microsoft states that application users get the same limits as everyone else. The limits are also enforced per web server: most environments have more than one, trial environments have only one, and the number depends on factors Dataverse manages, including how many user licences you purchase.
Three facets are measured:
| Measure | Default limit per web server | Window | Error code | Hex code |
|---|---|---|---|---|
| Number of requests | 6,000 | Five-minute sliding window | -2147015902 | 0x80072322 |
| Combined execution time | 20 minutes (1,200 seconds) | Five-minute sliding window | -2147015903 | 0x80072321 |
| Concurrent requests | 52 or higher | Immediate | -2147015898 | 0x80072326 |
The three facets close each other's loopholes. Bundling operations in batches reduces the request count, so the execution time limit applies. Sending many requests at once before the window catches up is stopped by the concurrency limit. Microsoft notes that these are default values, they can change, and they might vary between environments.
Two behaviours matter for design:
- You can exceed the request and execution time limits for a five-minute period before enforcement starts, but exceeding the concurrent request limit returns an error immediately.
- If a client keeps sending demanding requests after being throttled, Dataverse extends the
Retry-Afterduration. Aggressive retrying lengthens the pauses and lowers total throughput.
What doesn't count
Data operations performed by plug-ins and custom workflow activities run in the sandbox service and don't use the public API endpoints, so their requests don't count towards the request limit. Their computation time does count: it's added to the execution time of the request that triggered them. Dataverse search (api/search) has separate rules, with a limit of one request per second per user.
Service protection limits versus Power Platform request limits
These are two different systems, and teams often confuse them.
| Area | Service protection limits | Power Platform request limits |
|---|---|---|
| Purpose | Protect availability from sudden surges | Entitlement based on licences |
| Window | Five minutes, plus concurrency | 24 hours |
| Scope | Per user, per web server | Per licensed user; non-licensed identities share a tenant pool |
| Symptom | 429 or throttling fault with Retry-After | Possible throttling for sustained overage |
| Effect of batching | Batch counts as one request, but execution time accrues | Every create, update or delete inside a batch or bulk request counts |
Application users, non-interactive users, administrative users and the SYSTEM user all draw from a shared tenant-level pool of non-licensed requests. Microsoft's FAQ is explicit that these identities don't get their own tenant-level limit each. Batching is not a way around entitlement limits.
Prerequisites
- An identity that calls the Web API or the SDK for .NET, normally an application user. If you haven't set one up, see Dataverse application users for server-to-server auth.
- The environment URL, for example
https://contoso.crm.dynamics.com. - Access to the integration's logs, including HTTP status codes and response headers.
- For PowerShell examples: PowerShell 7.4 or later and the Az PowerShell module 11.1.0 or later, as listed in Microsoft's Web API PowerShell guidance.
Step 1: Identify which limit you hit
Read the error code from the response body, not just the status code. With the Web API the hex code is in the error payload; with the SDK for .NET the numeric code is on the OrganizationServiceFault.
The messages are specific:
0x80072322 Number of requests exceeded the limit of 6000 over time window of 300 seconds.
0x80072321 Combined execution time of incoming requests exceeded limit of 1,200,000 milliseconds over time window of 300 seconds. Decrease number of concurrent requests or reduce the duration of requests and try again later.
0x80072326 Number of concurrent requests exceeded the limit of 52.What each one usually means:
- Number of requests: many small sequential or parallel calls from one identity. Consider batching or bulk messages.
- Execution time: large batches, complex queries, solution imports, or slow synchronous plug-ins on the target table. Reduce batch size or plug-in cost.
- Concurrent requests: too many threads. Cap parallelism.
While debugging, the Web API returns two headers that show the remaining budget:
| Header | Meaning |
|---|---|
x-ms-ratelimit-burst-remaining-xrm-requests | Remaining number of requests for this connection |
x-ms-ratelimit-time-remaining-xrm-requests | Remaining combined duration for all connections using the same user account |
Microsoft says not to use these values to control how many requests you send; they are for debugging, and they reset if you connect to a different server.
Step 2: Honour Retry-After
Every service protection error carries a wait time. The Web API returns a Retry-After header in seconds; the SDK for .NET puts a TimeSpan in OrganizationServiceFault.ErrorDetails under the key Retry-After. Wait that long, then resend the same request.
SDK for .NET
Use ServiceClient (Microsoft.PowerPlatform.Dataverse.Client) or CrmServiceClient. These classes handle service protection errors for you; CrmServiceClient versions after 9.0.2.16 automatically pause and resend after the Retry-After period. If your code still uses OrganizationServiceProxy or OrganizationWebProxyClient, replace them; OrganizationServiceProxy is deprecated.
PowerShell
Invoke-RestMethod retries status codes from 400 to 599 when you set -MaximumRetryCount, and for a 429 with a Retry-After header it uses that value instead of -RetryIntervalSec. Retrying every 4xx slows scripts down, so Microsoft's sample wraps the call and only retries 429:
function Invoke-ResilientRestMethod {
param (
[Parameter(Mandatory)]
$request,
[bool]
$returnHeader
)
try {
if ($returnHeader) {
Invoke-RestMethod @request -ResponseHeadersVariable rhv | Out-Null
return $rhv
}
Invoke-RestMethod @request
}
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
$statuscode = $_.Exception.Response.StatusCode
if ($statuscode -eq 'TooManyRequests') {
if (!$request.ContainsKey('MaximumRetryCount')) {
$request.Add('MaximumRetryCount', 3)
}
if ($returnHeader) {
Invoke-RestMethod @request -ResponseHeadersVariable rhv | Out-Null
return $rhv
}
Invoke-RestMethod @request
}
else {
throw $_
}
}
}Pass the request as a hashtable (Invoke-ResilientRestMethod $request), not with splatting.
HttpClient with the Web API
If you write your own Web API client, retry on 429 and read the header. Microsoft's sample uses Polly:
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy(int maxRetries)
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
retryCount: maxRetries,
sleepDurationProvider: (count, response, context) =>
{
HttpResponseHeaders headers = response.Result.Headers;
int seconds = headers.Contains("Retry-After")
? int.Parse(headers.GetValues("Retry-After").FirstOrDefault())
: (int)Math.Pow(2, count);
return TimeSpan.FromSeconds(seconds);
},
onRetryAsync: (_, _, _, _) => Task.CompletedTask);
}Step 3: Set parallelism from x-ms-dop-hint and disable affinity
Parallel requests are the main lever for throughput, as long as you control them.
Use the recommended degree of parallelism. Dataverse returns an x-ms-dop-hint response header with the recommended degree of parallelism for the environment. Call WhoAmI once, read the header and use it as your maximum concurrency. With ServiceClient or CrmServiceClient, the same value is exposed as RecommendedDegreesOfParallelism:
serviceClient.EnableAffinityCookie = false;
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = serviceClient.RecommendedDegreesOfParallelism
};
await Parallel.ForEachAsync(entityList, parallelOptions, async (entity, token) =>
{
ids.Add(await serviceClient.CreateAsync(entity, token));
});Without this cap, .NET's default parallelism depends on the number of CPU cores on the client, which has nothing to do with what the environment can handle.
Disable server affinity. By default the service returns an affinity cookie that routes your requests to the same web server. Because limits apply per web server, turning affinity off spreads parallel requests across all eligible servers. Set EnableAffinityCookie = false on the SDK client, add <add key="PreferConnectionAffinity" value="false" /> to AppSettings in App.config, or set UseCookies = false on the HttpClientHandler when you use HttpClient.
Raise .NET Framework connection defaults. The default connection limit to a remote service is 2, so raise ServicePointManager.DefaultConnectionLimit to at least your planned concurrency. In .NET Core and later, HttpClientHandler.MaxConnectionsPerServer defaults to int.MaxValue.
Ramp up gradually. Microsoft recommends starting with a lower request rate and increasing it until you begin to receive limit errors, then letting Retry-After pace you. That keeps the retry-after duration short and total throughput high.
Step 4: Choose the right bulk pattern
Dataverse offers several ways to move many records. They interact with the limits differently.
| Pattern | What it is | Effect on limits | When to use |
|---|---|---|---|
| Single requests in parallel | One operation per request, concurrency capped at x-ms-dop-hint | Uses request budget; short execution time per request | Microsoft's default recommendation for most scenarios |
$batch (Web API) / ExecuteMultiple (SDK) | Up to 1,000 requests in one HTTP call | One request towards the count; execution time of all operations accrues | Reducing payload when network latency matters; start with batches of about 10 |
Change sets / ExecuteTransaction | Atomic group inside a batch | Same as batch | Related records that must succeed or fail together |
CreateMultiple, UpdateMultiple, UpsertMultiple | Bulk messages for records of one table | One request; longer execution time | Large loads to tables that support them; 100 to 1,000 records per request on standard tables |
DeleteMultiple | Bulk delete | One request | Elastic tables only; use BulkDelete on standard tables |
A few rules from the documentation:
- Avoid large batches. Larger batches make you more likely to hit the execution time limit instead of the request limit. Start with 10 operations per batch and increase concurrency first.
- Bulk messages on standard tables are all-or-nothing. Any error rolls back the entire request. Use them when you're confident most operations will succeed. On elastic tables partial success is possible, and Microsoft recommends 100 records per request.
- Check availability. Not every standard table supports
CreateMultipleandUpdateMultiple. Querysdkmessagefiltersfor the message and table before you build on it. A table that supports both also supportsUpsertMultiple. - Keep the batch size configurable. Bulk requests can hit message size limits (the sandbox context limit is 116.85 MB when a plug-in is registered) and timeouts (
ServiceClientdefaults to four minutes). Design the job so you can send fewer records per request without a redeploy.
For error handling in $batch, add Prefer: odata.continue-on-error so one bad record doesn't stop the rest of the batch; the response then contains per-item errors.
Step 5: Tune Azure Data Factory copies into Dataverse
The Azure Data Factory and Synapse Dataverse connector (sink type DynamicsSink, DynamicsCrmSink or CommonDataServiceForAppsSink) uses upsert and exposes the settings that matter:
| Property | Default | Notes |
|---|---|---|
writeBatchSize | 10 | Rows written per batch |
parallelCopies (copy activity) | 10 for the Dynamics sink | 10 x 10 means 100 records submitted concurrently by default |
maxConcurrentConnections | Not set | Upper limit on concurrent connections; set it only when you want to cap |
alternateKeyName | Not set | Alternate key used for the upsert |
ignoreNullValues | false | When true, nulls in the source don't overwrite existing values |
bypassBusinessLogicExecution | Not set | CustomSync, CustomAsync or both; requires the prvBypassCustomBusinessLogic privilege |
bypassPowerAutomateFlows | Not set | Bypasses Power Automate flows for the write |
The connector documentation recommends keeping writeBatchSize at 10 or less to avoid throttling of concurrent calls, and describes the default 10 x 10 combination as the Dynamics service's recommendation. The best values depend on the table's column count, row size, and the plug-ins and workflows registered on it, so tune from the defaults rather than starting large.
A sink fragment with explicit values:
"sink": {
"type": "DynamicsSink",
"writeBehavior": "Upsert",
"alternateKeyName": "contoso_externalid_key",
"writeBatchSize": 10,
"ignoreNullValues": true
}The linked service should use AADServicePrincipal (Microsoft Entra service principal) or ManagedIdentity (user-assigned managed identity) authentication. The connector documentation notes that Office 365 authentication doesn't work when Conditional Access or multifactor authentication applies to the account.
Step 6: Reduce execution time on the server side
If you're hitting the execution time limit, the cost is often on the server, not in your client.
- Synchronous plug-ins add their computation time to your request. Microsoft's guidance is to keep plug-in execution under 2 seconds and to prefer asynchronous registration where possible. The whole message operation, including synchronous plug-ins, must complete within 2 minutes.
- Bypass custom logic for migrations. For one-off loads where downstream logic shouldn't fire, the ADF
bypassBusinessLogicExecutionproperty skips custom plug-ins and workflows (Microsoft-published ones still run), and SDK or Web API callers can send a bypass optional parameter such asBypassCustomPluginExecution, which needs its own bypass privilege. The ADF property requires theprvBypassCustomBusinessLogicprivilege, which by default only the System Administrator role has. - Move from nightly bulk to near real time. Microsoft's guidance is that limits exist to smooth out large demand spikes. If a job processes a day's changes in minutes, consider event-driven synchronisation; see Dataverse integration choices: webhooks, Service Bus, plug-ins and flows.
Verification
After changing the client:
- Log
x-ms-dop-hintat startup and confirm your maximum concurrency matches it. - Log every
429with its hex code andRetry-Aftervalue. A healthy high-throughput job sees occasional shortRetry-Aftervalues; long or growing values mean you're retrying too aggressively. - During a test load, record the two
x-ms-ratelimit-*headers to see which budget runs out first. The target is a steady rate with few throttling errors, not zero errors at a much lower rate.
Troubleshooting
Number of concurrent requests exceeded the limit of 52. Your concurrency is above what one web server accepts for this user. Cap parallelism at x-ms-dop-hint, disable the affinity cookie, and in Azure Data Factory reduce writeBatchSize or parallelCopies, or set maxConcurrentConnections.
Combined execution time of incoming requests exceeded limit of 1,200,000 milliseconds... Requests are too expensive. Reduce batch or bulk sizes, review synchronous plug-ins and workflows on the target table, and simplify heavy queries.
Number of requests exceeded the limit of 6000 over time window of 300 seconds. Too many small calls from one identity. Combine operations with small batches or bulk messages, and confirm the job isn't running with affinity pinned to one server.
Throttling gets worse after adding retries. Retrying before Retry-After expires extends the penalty. Make sure every retry waits the full duration, and don't retry other 4xx errors in the same loop.
The request channel timed out attempting to send after 00:04:00. A bulk request took longer than the ServiceClient timeout. Send fewer records per request before you consider raising ServiceClient.MaxConnectionTimeout.
Different integrations throttle each other. If several jobs run as the same application user, they share one budget. Run unrelated integrations as separate application users; remember they still share the tenant's non-licensed request entitlement.
Checklist
- Every client retries
429and throttling faults afterRetry-After. - SDK code uses
ServiceClientorCrmServiceClient, notOrganizationServiceProxy. - Maximum concurrency comes from
x-ms-dop-hintorRecommendedDegreesOfParallelism. - Affinity cookie disabled for parallel back-end clients.
- Batches start small (about 10); bulk messages sized 100 to 1,000 on standard tables, 100 on elastic tables, configurable at run time.
- Azure Data Factory sinks keep
writeBatchSizeat 10 or less unless testing proves otherwise. - Synchronous plug-ins on bulk-loaded tables reviewed for execution time.
- Power Platform request entitlement tracked separately from service protection limits.
References
- Service protection API limits (Microsoft Dataverse)
- Send parallel requests to Dataverse
- Use bulk operation messages
- Execute batch operations using the Web API
- Use PowerShell and Visual Studio Code with the Dataverse Web API
- Requests limits and allocations
- Copy and transform data in Dynamics 365 (Microsoft Dataverse) or Dynamics CRM
- Analyze plug-in performance