APIs throttle. Networks hiccup. Power Query doesn't retry by default — which means your refreshes fail when they don't have to. This deep-dive lesson teaches you to build a production-grade retry and exponential backoff system in M from first principles, complete with Retry-After header parsing and jitter.

You're refreshing a Power BI report at 6 AM before an executive presentation. The dashboard pulls from three different REST APIs — a Salesforce endpoint, a custom internal microservice, and a third-party analytics platform. The refresh starts, runs for two minutes, and then fails. The error? DataSource.Error: Request timeout. The API wasn't down — it was throttled. It would have succeeded if you'd simply waited three seconds and tried again. But Power Query doesn't retry by default. It fails hard, and your data model sits incomplete.
This scenario plays out across enterprise data teams every day. APIs implement rate limiting. VPN connections hiccup. Cloud services throttle burst traffic with 429 responses. Azure SQL databases occasionally return transient errors under connection pressure. These are not bugs in your data model — they're the normal, expected behavior of distributed systems. The gap is that Power Query M, as a data transformation language, was designed around the assumption that your data source will respond correctly on the first try. When it doesn't, you need to build that resilience yourself.
By the end of this article, you'll be able to build a reusable, production-grade retry system in M that handles transient failures, implements exponential backoff with jitter, respects API rate-limit headers like Retry-After, and degrades gracefully when retries are exhausted. This is advanced connector engineering — the kind of work that separates brittle pipelines from ones that run reliably at scale.
What you'll learn:
Retry-After and X-RateLimit-Reset headers to honor server-side throttle windowsThis article assumes you're comfortable with M as a language — not just clicking through the Power Query UI. Specifically, you should understand:
try...otherwise works in M for catching errors (covered in depth in Error Handling and Try-Otherwise Patterns in M: Building Production-Ready Power Query Solutions)Web.Contents and HTTP connector behaviorIf you've built parameterized queries and understand how M evaluates expressions lazily, you're in the right place.
Before we build anything, it's worth understanding exactly why retry logic is both necessary and genuinely difficult in M.
When you call Web.Contents("https://api.example.com/data"), M makes exactly one HTTP request. If that request fails — whether due to a network timeout, a 503 response, or a 429 rate-limit hit — M throws a DataSource.Error and your query step fails. There is no built-in retry mechanism. There is no concept of "try this three times before giving up." The language assumes success or bubbles up the error.
This is fundamentally different from, say, Python's requests library combined with urllib3's retry adapter, or .NET's Polly library. Those runtimes have threading models, sleep/wait primitives, and exception-handling stacks that make retry trivially composable. M has none of these.
The complications in M specifically are:
No sleep primitive. M has no Thread.Sleep() equivalent. You can't tell the engine to pause for two seconds and then continue. Any backoff delay you implement has to be simulated through computational work, which we'll address.
Lazy evaluation and query folding. M evaluates expressions lazily, and the engine may try to fold operations back to the data source. This interacts awkwardly with retry logic because wrapping a network call in retry scaffolding can break the engine's ability to analyze folding boundaries. We'll discuss how to manage this. For a deeper look at how the evaluation model works, see Understanding M Language Query Evaluation: Lazy Evaluation, Dependency Graphs, and Step Ordering in Power Query.
Error recovery scope. try...otherwise in M catches errors at expression evaluation time, but once an error propagates up through a step, recovering it cleanly requires deliberate structuring of your expressions. If you're not careful, you end up catching the wrong errors or masking real data problems.
No mutable state. M is a purely functional language. You can't hold a "retry counter" variable and increment it. Any stateful retry loop must be implemented as recursion with the counter threaded through as a parameter.
Key insight
The absence of a sleep primitive is the central challenge of backoff in M. The workaround is to use List.Generate to create a list whose generation naturally consumes time, but this is an approximation, not a precise delay. We'll cover the practical implications — and when it matters enough to care about.
Retry logic depends on distinguishing retryable errors from permanent errors. A 404 Not Found is permanent — retrying won't help. A 429 Too Many Requests is explicitly retryable. A 503 Service Unavailable might be transient. Before writing a single retry function, you need to understand what M gives you to work with when an error occurs.
When Web.Contents fails, M raises an error record with this structure:
[
Reason = "DataSource.Error",
Message = "...",
Detail = [
DataSourceKind = "Web",
DataSourcePath = "https://api.example.com/data",
HttpStatusCode = 429,
HttpResponseHeaders = [
#"Retry-After" = "30",
#"Content-Type" = "application/json"
]
]
]
The Detail record is where the gold is. It contains the HTTP status code and response headers, which is exactly what you need to decide whether to retry and how long to wait.
Here's how you capture this error record using try:
let
Result = try Web.Contents("https://api.example.com/data", [
Headers = [#"Accept" = "application/json"],
Timeout = #duration(0, 0, 0, 30)
]),
IsError = Result[HasError],
ErrorRecord = if IsError then Result[Error] else null,
StatusCode = if IsError
then try ErrorRecord[Detail][HttpStatusCode] otherwise null
else null
in
StatusCode
When try wraps an expression, it returns a record with two fields: HasError (a logical) and either Value (if no error) or Error (if one occurred). The Error field contains that nested structure we saw above.
Notice the nested try otherwise null when accessing HttpStatusCode — this is defensive programming. Not all errors are HTTP errors. A DNS resolution failure or a certificate error might not populate HttpStatusCode at all, and accessing a missing field in M throws its own error. You need to be safe all the way down.
Warning
A subtle gotcha with Web.Contents and error handling: by default, Web.Contents only throws an error for network-level failures. HTTP 4xx and 5xx responses are returned as successful binary responses with error status codes embedded. To make M throw on bad HTTP status codes, you need to use ManualStatusHandling in the options record. We'll implement this correctly below.
This is a critical point. Without ManualStatusHandling, a 429 response from your API will not trigger a try catch — it'll return the response body as binary content and your query will silently succeed with error data. Here's the corrected call:
let
Response = try Web.Contents("https://api.example.com/data", [
Headers = [#"Accept" = "application/json"],
ManualStatusHandling = {429, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 30)
]),
// Now check the metadata for HTTP status
StatusCode = if not Response[HasError]
then Value.Metadata(Response[Value])[Response][Status]
else Response[Error][Detail][HttpStatusCode]
in
StatusCode
When you use ManualStatusHandling, M returns the response body normally (no thrown error) but marks those status codes in the response metadata instead of throwing. This gives you the opportunity to inspect the status and decide what to do — which is exactly what your retry logic needs.
Now we have enough context to build the retry function itself. Let's design it deliberately before writing code.
What the function needs to accept:
What it needs to return:
Here's the foundation:
let
RetryRequest = (
RequestFn as function,
optional MaxAttempts as number,
optional RetryableCodes as list,
optional BaseDelaySeconds as number
) as any =>
let
ActualMaxAttempts = if MaxAttempts = null then 3 else MaxAttempts,
ActualRetryableCodes = if RetryableCodes = null
then {429, 500, 502, 503, 504}
else RetryableCodes,
ActualBaseDelay = if BaseDelaySeconds = null then 2 else BaseDelaySeconds,
// Recursive attempt function
AttemptRequest = (AttemptNumber as number) as any =>
let
Response = try RequestFn(),
HasError = Response[HasError],
// Extract status code from either error or manual handling metadata
StatusCode = if HasError
then try Response[Error][Detail][HttpStatusCode] otherwise null
else try Value.Metadata(Response[Value])[Response][Status] otherwise 200,
IsRetryable = List.Contains(ActualRetryableCodes, StatusCode),
AttemptsRemaining = ActualMaxAttempts - AttemptNumber,
// Calculate backoff delay using exponential growth
DelaySeconds = ActualBaseDelay * Number.Power(2, AttemptNumber - 1),
Result = if not HasError and (StatusCode = null or StatusCode < 400)
then Response[Value] // Success
else if (HasError or IsRetryable) and AttemptsRemaining > 0
then
let
// Simulate delay via List.Generate
_ = SimulateDelay(DelaySeconds),
NextAttempt = AttemptRequest(AttemptNumber + 1)
in
NextAttempt
else
// All retries exhausted or non-retryable error
error Error.Record(
"RetryExhausted",
"Request failed after " & Number.ToText(ActualMaxAttempts) & " attempts. Last status: " &
(if StatusCode = null then "unknown" else Number.ToText(StatusCode)),
[
Attempts = ActualMaxAttempts,
LastStatusCode = StatusCode,
LastError = if HasError then Response[Error] else null
]
)
in
Result
in
AttemptRequest(1)
in
RetryRequest
This is the skeleton. Notice the recursive call to AttemptRequest(AttemptNumber + 1) — this is how we thread state through without mutation. Each recursive call carries an incremented attempt number, which also drives the exponential backoff calculation.
The _ = SimulateDelay(DelaySeconds) line references a helper we haven't built yet. Let's do that now.
This is where M's functional nature forces creativity. We need SimulateDelay to stall execution for approximately DelaySeconds seconds. The technique that works in practice is List.Generate — generating a large list of computed values consumes CPU time proportional to the list size.
let
SimulateDelay = (Seconds as number) as null =>
let
// Approximate iterations needed: benchmarked at ~50,000 iterations per second
// on average Power BI gateway hardware. Tune this constant for your environment.
IterationsPerSecond = 50000,
TotalIterations = Number.Round(Seconds * IterationsPerSecond),
// Generate a list that does some computational work
_ = List.Generate(
() => 0,
each _ < TotalIterations,
each _ + 1
),
Result = null
in
Result
in
SimulateDelay
Warning
This delay approach has real limitations. The iteration count per second varies significantly depending on whether the query is running in Power BI Desktop, the Power BI Service, an on-premises gateway, or Power BI Premium capacity. The constant 50000 is a rough approximation. For short delays (1-2 seconds), the variance is usually acceptable. For longer delays (10+ seconds), consider whether you actually want to retry in a long-running refresh context at all — a 60-second wait across 3 retries adds 3+ minutes to your refresh time.
There's a secondary approach worth knowing: for cases where you need more precision or longer delays, you can structure your retry logic to log failures and re-run via a separate invocation pattern rather than sleeping inline. But for most API retry use cases (rate limiting with 1-10 second backoffs), the List.Generate approach works adequately in production.
Pure exponential backoff (2s, 4s, 8s, 16s...) has a known problem in distributed systems: when multiple clients all hit the same rate limit at the same time and all implement the same deterministic backoff, they all retry simultaneously. This is called the "thundering herd" problem, and it makes the rate-limiting situation worse.
The solution is jitter — adding randomness to the backoff delay so clients desynchronize naturally. The "Full Jitter" strategy from AWS's well-known backoff writeup calculates the delay as a random value between 0 and the exponential ceiling:
delay = random_between(0, base_delay * 2^attempt)
In M, we don't have a true random number generator with a time-based seed in the standard sense, but we can approximate it using Number.Random() which returns a pseudorandom number between 0 and 1:
let
CalculateBackoffDelay = (
AttemptNumber as number,
BaseDelaySeconds as number,
optional UseJitter as logical
) as number =>
let
ShouldJitter = if UseJitter = null then true else UseJitter,
ExponentialCeiling = BaseDelaySeconds * Number.Power(2, AttemptNumber - 1),
// Cap delay at 60 seconds to prevent excessively long waits
CappedCeiling = Number.Min(ExponentialCeiling, 60),
// Apply full jitter if enabled
DelaySeconds = if ShouldJitter
then Number.Random() * CappedCeiling
else CappedCeiling
in
DelaySeconds
in
CalculateBackoffDelay
Note
Number.Random() in M generates a pseudorandom number, but the seed behavior can be inconsistent across evaluation contexts. In some refresh environments, the same "random" values may appear across concurrent evaluations. For most retry jitter use cases this is fine — you just need desynchronization, not cryptographic randomness. Don't use this for security-sensitive purposes.
The most robust retry logic doesn't use its own backoff calculation at all — it reads the server's explicit instruction. The Retry-After HTTP header tells you exactly how many seconds to wait before retrying. If your API returns this header (Salesforce, GitHub, OpenAI, Stripe, and most well-designed APIs do), you should honor it.
Here's a function that extracts the retry delay from response metadata:
let
GetRetryDelay = (
ResponseValue as binary,
FallbackDelaySeconds as number
) as number =>
let
ResponseMetadata = Value.Metadata(ResponseValue),
Headers = try ResponseMetadata[Response][Headers] otherwise null,
// Check for Retry-After header (seconds as integer)
RetryAfterValue = if Headers = null
then null
else try Record.Field(Headers, "Retry-After") otherwise null,
// Some APIs use X-RateLimit-Reset with an epoch timestamp instead
RateLimitReset = if Headers = null
then null
else try Record.Field(Headers, "X-RateLimit-Reset") otherwise null,
// Convert X-RateLimit-Reset epoch to seconds-from-now
SecondsFromReset = if RateLimitReset = null
then null
else
let
ResetEpoch = Number.FromText(RateLimitReset),
// M doesn't have a direct epoch-to-datetime without some work
// We'll use a reasonable cap: if reset is > 300s away,
// fall back to our own backoff
SecondsUntilReset = Number.Min(
Number.Max(ResetEpoch -
Duration.TotalSeconds(DateTime.LocalNow() - #datetime(1970,1,1,0,0,0)),
0),
300
)
in
SecondsUntilReset,
// Priority: Retry-After > X-RateLimit-Reset > Fallback
FinalDelay = if RetryAfterValue <> null
then try Number.FromText(RetryAfterValue) otherwise FallbackDelaySeconds
else if SecondsFromReset <> null
then SecondsFromReset
else FallbackDelaySeconds
in
FinalDelay
in
GetRetryDelay
The epoch-to-seconds calculation in this function deserves a note. We're computing seconds elapsed since the Unix epoch by subtracting a datetime literal from DateTime.LocalNow(). This works because M's duration arithmetic is well-defined, and Duration.TotalSeconds gives us the floating-point seconds. For more on M's date and time arithmetic, see Working with Dates, Times, and Duration Values in Power Query M.
Now let's assemble all the pieces into a single, clean, production-ready implementation. This version handles the full complexity: ManualStatusHandling, Retry-After header parsing, exponential backoff with jitter, configurable retry codes, and meaningful error reporting on exhaustion.
let
// ============================================================
// SimulateDelay: Approximate delay via CPU-bound list generation
// ============================================================
SimulateDelay = (Seconds as number) as null =>
let
Iterations = Number.Round(Number.Max(Seconds, 0) * 50000),
_Burn = List.Last(List.Generate(() => 0, each _ < Iterations, each _ + 1)),
Result = null
in
Result,
// ============================================================
// CalculateBackoffDelay: Exponential backoff with optional jitter
// ============================================================
CalculateBackoffDelay = (
AttemptNumber as number,
BaseDelaySeconds as number,
UseJitter as logical
) as number =>
let
ExponentialValue = BaseDelaySeconds * Number.Power(2, AttemptNumber - 1),
CappedValue = Number.Min(ExponentialValue, 60),
FinalDelay = if UseJitter then Number.Random() * CappedValue else CappedValue
in
FinalDelay,
// ============================================================
// ExtractStatusCode: Get HTTP status from response metadata
// ============================================================
ExtractStatusCode = (ResponseBinary as binary) as nullable number =>
try Value.Metadata(ResponseBinary)[Response][Status] otherwise null,
// ============================================================
// ExtractRetryDelay: Read Retry-After or X-RateLimit-Reset headers
// ============================================================
ExtractRetryDelay = (ResponseBinary as binary, FallbackSeconds as number) as number =>
let
Headers = try Value.Metadata(ResponseBinary)[Response][Headers] otherwise null,
RetryAfter = if Headers = null then null
else try Record.Field(Headers, "Retry-After") otherwise null,
FinalDelay = if RetryAfter <> null
then try Number.FromText(Text.Trim(RetryAfter)) otherwise FallbackSeconds
else FallbackSeconds
in
FinalDelay,
// ============================================================
// RetryWebRequest: Main entry point for fault-tolerant requests
// ============================================================
RetryWebRequest = (
Url as text,
optional RequestOptions as record,
optional MaxAttempts as number,
optional RetryableCodes as list,
optional BaseDelaySeconds as number,
optional UseJitter as logical
) as binary =>
let
// Apply defaults
Options = if RequestOptions = null then [] else RequestOptions,
Attempts = if MaxAttempts = null then 3 else MaxAttempts,
RetryCodes = if RetryableCodes = null then {429, 500, 502, 503, 504} else RetryableCodes,
BaseDelay = if BaseDelaySeconds = null then 2 else BaseDelaySeconds,
Jitter = if UseJitter = null then true else UseJitter,
// Merge ManualStatusHandling into options — we always need this
FinalOptions = Record.Combine([
Options,
[ManualStatusHandling = RetryCodes]
]),
// Core recursive attempt logic
AttemptRequest = (AttemptNum as number) as binary =>
let
RawResponse = try Web.Contents(Url, FinalOptions),
GotError = RawResponse[HasError],
ResponseBinary = if not GotError then RawResponse[Value] else null,
// Status code from manual handling metadata (non-error path)
// or from the error detail (error path, e.g. genuine timeout)
StatusCode = if GotError
then try RawResponse[Error][Detail][HttpStatusCode] otherwise null
else ExtractStatusCode(ResponseBinary),
IsSuccessful = not GotError and (StatusCode = null or StatusCode < 400),
IsRetryable = List.Contains(RetryCodes, StatusCode),
ShouldRetry = (GotError or IsRetryable) and (AttemptNum < Attempts),
// Determine delay: prefer Retry-After header, fall back to computed backoff
ComputedDelay = CalculateBackoffDelay(AttemptNum, BaseDelay, Jitter),
ActualDelay = if not GotError and IsRetryable and ResponseBinary <> null
then ExtractRetryDelay(ResponseBinary, ComputedDelay)
else ComputedDelay,
Output = if IsSuccessful
then ResponseBinary
else if ShouldRetry
then
let
_Wait = SimulateDelay(ActualDelay),
NextResult = AttemptRequest(AttemptNum + 1)
in
NextResult
else
error Error.Record(
"DataSource.Error",
"RetryWebRequest failed after " & Number.ToText(Attempts) &
" attempt(s). URL: " & Url &
". Final status: " &
(if StatusCode = null then "network error" else Number.ToText(StatusCode)),
[
Url = Url,
Attempts = Attempts,
FinalStatusCode = StatusCode,
FinalError = if GotError then RawResponse[Error] else null
]
)
in
Output
in
AttemptRequest(1)
in
RetryWebRequest
Let's walk through the key design decisions in this implementation.
Why Record.Combine for options merging? We need to inject ManualStatusHandling into whatever options the caller provides, but we shouldn't override their other settings. Record.Combine merges two records, with fields from the second record winning on conflict. This means if the caller already specified ManualStatusHandling, our value replaces it — which is intentional, since we need to control exactly which codes trigger the retry path.
Why is ManualStatusHandling essential here? Without it, a 429 response would be silently returned as binary content. IsSuccessful would evaluate to true because GotError would be false and there'd be no status code to check. Your query would appear to succeed while actually containing error response data. This is the most common mistake when building retry logic in M.
The _Wait = SimulateDelay(ActualDelay) binding. In M's let-in expression, all bindings are evaluated before the final expression. By binding the delay to _Wait, we force the delay computation to happen before AttemptRequest(AttemptNum + 1) is evaluated. The underscore prefix is a convention indicating this value is intentionally unused; its only purpose is the side effect of consuming time.
Here's how you'd use this in a real query that hits the GitHub API (which implements rate limiting):
let
// Assuming RetryWebRequest is defined in a separate query named "fnRetryWebRequest"
RetryFn = fnRetryWebRequest,
// Fetch GitHub repository data with retry
ResponseBinary = RetryFn(
"https://api.github.com/repos/microsoft/PowerBI-DataConnectors/issues?state=open&per_page=100",
[
Headers = [
Authorization = "Bearer " & GitHubToken,
#"User-Agent" = "PowerBI-DataConnector/1.0",
Accept = "application/vnd.github.v3+json"
],
Timeout = #duration(0, 0, 0, 30)
],
4, // Max 4 attempts
{429, 500, 502, 503}, // Retry on these codes
1, // 1 second base delay
true // Use jitter
),
ParsedJson = Json.Document(ResponseBinary),
IssuesTable = Table.FromList(ParsedJson, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ExpandedIssues = Table.ExpandRecordColumn(IssuesTable, "Column1",
{"number", "title", "state", "created_at", "user"},
{"IssueNumber", "Title", "State", "CreatedAt", "User"})
in
ExpandedIssues
This is clean because all the retry complexity is hidden inside fnRetryWebRequest. From the perspective of this query, it's just a function call that returns a binary — exactly the same contract as Web.Contents. That's intentional: your downstream JSON parsing and table transformation logic doesn't need to know anything about retry behavior.
For pagination scenarios where you're calling the same endpoint multiple times with different page parameters, your retry logic applies per-page-request. See Streaming and Pagination Patterns in M: Handling Large APIs and Multi-Page Data Sources with Custom Iterators for how to integrate this with multi-page data fetching.
Production data teams don't rewrite retry logic in every query. They package it once and reference it. There are two strategies for this in Power Query:
Strategy 1: Shared query in the same Power BI file. Define RetryWebRequest as a named query (e.g., fnRetryWebRequest) and reference it from other queries. This works well for single-file reports but requires manually copying the function to new files.
Strategy 2: Custom connector .mez file. Package retry logic inside a custom connector so it's available as a registered data source across any file on the gateway. This is the right choice for enterprise deployments where dozens of reports use the same APIs.
For a full walkthrough of building .mez connectors, see Implementing Custom Connectors with M Language: Building, Packaging, and Deploying .mez Extensions for Enterprise Data Sources.
For function library patterns in a single-file context, the approach recommended in Building a Reusable Function Library in Power Query applies directly — you'd create a single query that returns a record of functions:
// Query named: RetryLibrary
let
SimulateDelay = (Seconds as number) as null => ...,
CalculateBackoffDelay = (AttemptNumber as number, BaseDelay as number, UseJitter as logical) as number => ...,
ExtractStatusCode = (Response as binary) as nullable number => ...,
ExtractRetryDelay = (Response as binary, Fallback as number) as number => ...,
RetryWebRequest = (Url as text, ...) as binary => ...,
// Export as a record
Library = [
RetryWebRequest = RetryWebRequest,
SimulateDelay = SimulateDelay,
CalculateBackoffDelay = CalculateBackoffDelay
]
in
Library
Then in any other query:
let
Retry = RetryLibrary,
Data = Retry[RetryWebRequest]("https://api.example.com/endpoint", [...])
in
Data
Tip
When defining a library query that contains recursive helper functions like AttemptRequest, make sure all helpers are defined within the same let expression that contains the main function. M's lexical scoping means a function can only reference names that are defined in the same or an enclosing let block. If you split helpers across separate top-level queries and reference them in recursive closures, you may hit scoping issues that manifest as unexpected errors.
Retry logic isn't free, and applying it naively has real costs that you need to weigh.
Refresh time multiplication. If your connector makes 50 API calls per refresh and 10% of them hit a 429 response triggering a 5-second retry, you've added 50 × 0.1 × 5 = 25 seconds to your refresh time. With exponential backoff, a single page that retries three times with a 2-second base adds 2 + 4 + 8 = 14 seconds. For reports with time-sensitive SLAs, model the worst-case refresh time before deploying retry logic.
Power BI Service timeout limits. Power BI dataset refreshes time out after 2 hours for Premium and after configured limits for shared capacity. Aggressive retry with long backoffs can push you over these limits. Set your maximum retry delay cap conservatively (60 seconds in our implementation) and keep MaxAttempts to 3-4 for most use cases.
The delay approximation variance. The List.Generate delay method has variance of perhaps ±30% depending on Power BI capacity load. If you're trying to honor an exact Retry-After: 30 instruction, your implementation might actually delay 22 or 38 seconds instead. For most APIs this is acceptable — they care that you waited at least the specified time, not exactly that time. But you should be aware that you're not implementing precise timing.
Query folding implications. Wrapping Web.Contents calls in retry scaffolding prevents Power Query from recognizing the call as a "pure" connector operation. This matters if you're building connectors where query folding is important. For custom connectors built with the M SDK that need folding, you'd implement retry at the C#/ODBC driver level rather than in M itself. For pure M web API connectors, there's nothing to fold anyway, so this isn't an issue. See Implementing Custom Query Folding Logic in M: Keeping Transformations Native to the Data Source for context on when folding matters.
Key insight
The ideal application of M-level retry logic is REST API connectors that use Web.Contents directly. For database connectors (SQL Server, Oracle, etc.) where transient connection failures are the concern, the connection retry belongs in the connection string or driver configuration — not in M. M-level retry adds no value for database connectors and may cause confusing interactions with the native connector's own retry mechanisms.
One critical case to get right: 401 Unauthorized and 403 Forbidden responses. These should never be in your retryable codes list.
A 401 means your auth token is missing or expired. Retrying will just get you three more 401s and waste time. A 403 means you don't have permission — same story. If you're seeing 401s in a live system, that means your OAuth token expired between the refresh starting and hitting this particular API call. The fix is token refresh logic, not request retry.
If your connector uses OAuth tokens, consider a different pattern: a proactive token refresh wrapper that checks token expiry before making the request, rather than reacting to 401 responses. For the full OAuth implementation pattern, see Implementing Custom Data Connector Authentication and OAuth Flows in Power Query M Language.
// Example: Don't include 401/403 in retry codes
RetryFn(
Url,
Options,
3, // MaxAttempts
{429, 500, 502, 503, 504}, // ONLY transient errors — no auth errors
2,
true
)
This is a deliberate design choice. Auth errors should surface immediately so the refresh fails fast and alerts the team, rather than silently retrying three times and wasting three minutes before showing the same failure.
Build a fault-tolerant connector for the JSONPlaceholder API (a free test API) that:
https://jsonplaceholder.typicode.com/postsStep 1: Create a query named fnRetryWebRequest using the full implementation from the "Complete Production-Ready Retry Implementation" section above.
Step 2: Add an attempt counter to the implementation by threading an accumulator through the recursive call. Modify AttemptRequest to return a record [Value = ..., AttemptsUsed = ...] instead of raw binary, then extract the binary at the end.
Here's the modified signature and return structure to guide you:
AttemptRequest = (AttemptNum as number) as record =>
let
// ... all the same logic ...
Output = if IsSuccessful
then [Value = ResponseBinary, AttemptsUsed = AttemptNum]
else if ShouldRetry
then
let
_Wait = SimulateDelay(ActualDelay),
NextResult = AttemptRequest(AttemptNum + 1)
in
NextResult // Will carry the final AttemptNum from recursion
else
error Error.Record(...)
in
Output
Step 3: Create a main query that calls your function, extracts the binary, parses it as JSON, and also surfaces the AttemptsUsed value in a diagnostic step.
Step 4: Test the error path by temporarily pointing your URL at https://jsonplaceholder.typicode.com/posts/9999999 (which returns 404 — not in retry list) and confirm it fails immediately on attempt 1 without retrying.
Step 5: Test the retry path by adding 404 temporarily to your RetryCodes list and confirming that the function retries the full configured number of times before failing.
This exercise gives you hands-on experience with the complete retry cycle, the recursive accumulator pattern, and the distinction between retryable and non-retryable errors — all without needing a real throttled API.
Symptom: Retry logic never triggers. The function always returns on the first attempt, but the returned data contains error response bodies from the API.
Cause: Web.Contents returns 4xx/5xx responses as "successful" binary unless you use ManualStatusHandling.
Fix: Always include ManualStatusHandling = RetryCodes in your Web.Contents options when building retry logic. Without it, GotError is always false, StatusCode is always null, and IsSuccessful is always true.
Symptom: Query hangs or the Power Query engine throws a stack overflow error.
Cause: You added a status code to RetryCodes that the API always returns (e.g., the API returns 503 on a misconfigured endpoint that's permanently down). The retry loop runs until AttemptNum >= Attempts but if Attempts is very large, this takes a long time.
Fix: Keep MaxAttempts to 3-5 for interactive refreshes. For overnight batch refreshes, 5-7 is reasonable. Never set it above 10 unless you've explicitly modeled the worst-case time.
Symptom: Refresh takes much longer than expected before failing with a credential error.
Cause: 401 or 403 added to RetryCodes. Each auth failure triggers the full retry cycle with backoff before the error surfaces.
Fix: Remove 401 and 403 from RetryCodes. Auth errors should fail fast.
Symptom: The SimulateDelay function seems to run much faster or slower in the Power BI Service compared to Desktop.
Cause: The iteration-to-time ratio of List.Generate varies by compute environment. The constant 50000 was calibrated for Desktop on typical hardware.
Fix: Add a calibration parameter to SimulateDelay and test in your target environment. For the Service, the ratio might be closer to 100000 or 200000 iterations per second depending on capacity. Consider adding a IterationsPerSecond parameter to your library with environment-specific defaults.
Symptom: POST requests to a write API are creating duplicate records when the API returns a 503 on write operations.
Cause: Retrying non-idempotent operations (POST, some PATCHes). If the server processed the write but returned 503 on the response, retrying creates a second write.
Fix: Only apply retry logic to GET requests or operations you've confirmed are idempotent. For POST/PATCH operations, fail fast and handle retry at the orchestration layer (e.g., in Azure Data Factory or Power Automate) where you can implement idempotency keys.
Warning
This is the most dangerous mistake in the list. Blindly applying retry to all HTTP calls in a data pipeline that includes write operations can corrupt your data sources. Always audit your retry configuration against the operations you're wrapping. M-level retry is safest on read-only data extraction queries.
You now have a complete implementation of retry and backoff logic in Power Query M, built from first principles. The key architectural decisions were:
ManualStatusHandling to surface HTTP error codes that M would otherwise swallow as successful responsesList.Generate as M's closest approximation to a sleep primitiveAttemptRequest calls with the attempt counter threaded as a parameter — the idiomatic way to handle iteration and state in a purely functional languageRetry-After headers to honor explicit rate-limit windows rather than guessingThe pattern you've built is production-grade. Teams at enterprises using Power BI for mission-critical data pipelines use exactly this structure to handle the messy reality of unreliable APIs and flaky networks.
Where to go next:
.mez connector for enterprise deployment, see Implementing Custom Connectors with M Language: Building, Packaging, and Deploying .mez Extensions for Enterprise Data Sources