Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Query

Implementing Custom Connection Retry and Backoff Logic in Power Query M: Building Fault-Tolerant Data Source Connectors for Unreliable Networks and Throttled APIs

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.

🔥 Expert28 min readNov 13, 2026Updated Nov 13, 2026
Implementing Custom Connection Retry and Backoff Logic in Power Query M: Building Fault-Tolerant Data Source Connectors for Unreliable Networks and Throttled APIs
On this page
  • Introduction
  • Prerequisites
  • Why M's Default Behavior Fails Under Real-World Conditions
  • Understanding M's Error Structure Before You Handle It
  • Building the Core Retry Function
  • Implementing the Delay Mechanism
  • Adding Exponential Backoff with Jitter
  • Respecting `Retry-After` Headers
  • The Complete Production-Ready Retry Implementation
  • Practical Usage: Calling RetryWebRequest
  • Packaging Into a Reusable Function Library
Performance Considerations and When Not to Use This Pattern
  • Handling Authentication Errors Correctly
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Not using `ManualStatusHandling`
  • Mistake 2: Infinite recursion on unexpected status codes
  • Mistake 3: Catching authentication errors
  • Mistake 4: Delay not working in Power BI Service
  • Mistake 5: Retry logic causing duplicate side effects
  • Summary & Next Steps
  • Implementing Custom Connection Retry and Backoff Logic in Power Query M: Building Fault-Tolerant Data Source Connectors for Unreliable Networks and Throttled APIs

    Introduction

    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:

    • How M's error handling model works and why it makes retry logic non-trivial to implement
    • Building a recursive retry function with configurable attempt limits and delay strategies
    • Implementing exponential backoff and jitter to avoid thundering herd problems
    • Parsing Retry-After and X-RateLimit-Reset headers to honor server-side throttle windows
    • Composing retry logic into reusable connector abstractions using function libraries
    • Testing and diagnosing retry behavior in Power Query's evaluation environment

    Prerequisites

    This article assumes you're comfortable with M as a language — not just clicking through the Power Query UI. Specifically, you should understand:

    • How 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)
    • Writing and invoking custom M functions (Writing Custom M Functions from Scratch in Power Query)
    • How M handles recursive patterns and accumulators (Advanced M: Iterators, Accumulators, and Recursive Patterns)
    • Basic familiarity with Web.Contents and HTTP connector behavior

    If you've built parameterized queries and understand how M evaluates expressions lazily, you're in the right place.


    Why M's Default Behavior Fails Under Real-World Conditions

    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.


    Understanding M's Error Structure Before You Handle It

    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.


    Building the Core Retry Function

    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:

    • A function to call (the actual data request)
    • Maximum number of attempts
    • A base delay duration
    • A list of HTTP status codes that are retryable
    • A flag to enable/disable exponential backoff

    What it needs to return:

    • The successful response value on success
    • A meaningful error after all retries are exhausted

    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.


    Implementing the Delay Mechanism

    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.


    Adding Exponential Backoff with Jitter

    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.


    Respecting `Retry-After` Headers

    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.


    The Complete Production-Ready Retry Implementation

    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.


    Practical Usage: Calling RetryWebRequest

    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.


    Packaging Into a Reusable Function Library

    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.


    Performance Considerations and When Not to Use This Pattern

    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.


    Handling Authentication Errors Correctly

    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.


    Hands-On Exercise

    Build a fault-tolerant connector for the JSONPlaceholder API (a free test API) that:

    1. Attempts to fetch https://jsonplaceholder.typicode.com/posts
    2. Handles 429, 500, 502, 503, 504 responses
    3. Uses 3 retries with exponential backoff and jitter
    4. Reports, in the query metadata or a separate diagnostic step, how many attempts were made

    Step 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.


    Common Mistakes & Troubleshooting

    Mistake 1: Not using `ManualStatusHandling`

    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.

    Mistake 2: Infinite recursion on unexpected status codes

    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.

    Mistake 3: Catching authentication errors

    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.

    Mistake 4: Delay not working in Power BI Service

    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.

    Mistake 5: Retry logic causing duplicate side effects

    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.


    Summary & Next Steps

    You now have a complete implementation of retry and backoff logic in Power Query M, built from first principles. The key architectural decisions were:

    • Using ManualStatusHandling to surface HTTP error codes that M would otherwise swallow as successful responses
    • Implementing delay via List.Generate as M's closest approximation to a sleep primitive
    • Designing the core logic as recursive AttemptRequest calls with the attempt counter threaded as a parameter — the idiomatic way to handle iteration and state in a purely functional language
    • Extracting server-provided delay hints from Retry-After headers to honor explicit rate-limit windows rather than guessing
    • Adding jitter to exponential backoff to prevent thundering herd behavior in environments with concurrent refreshes
    • Carefully limiting retry codes to genuinely transient errors, explicitly excluding auth failures and permanent errors

    The 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:

    • If your retry logic needs to interact with complex authentication flows including token refresh on 401, read Implementing Custom Data Connector Authentication and OAuth Flows in Power Query M Language
    • For multi-page APIs where retry logic applies to each page fetch, combine this pattern with Streaming and Pagination Patterns in M: Handling Large APIs and Multi-Page Data Sources with Custom Iterators
    • To package this into a shareable .mez connector for enterprise deployment, see Implementing Custom Connectors with M Language: Building, Packaging, and Deploying .mez Extensions for Enterprise Data Sources
    • For diagnosing exactly how many retries are happening in production and profiling the time cost of your retry strategy, Implementing Custom Query Diagnostics and Step-Level Profiling in Power Query M provides the measurement framework you need
    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Advanced M Language

    Previous

    Implementing Custom Table Sampling and Statistical Estimation Pipelines in Power Query M: Stratified, Systematic, and Random Row Selection for Large Dataset Profiling

    Related Insights

    Power QueryExpert

    Implementing Custom Locale-Aware Number and Date Parsing Pipelines in Power Query M: Resolving Regional Format Conflicts When Combining Multi-Country Data Sources

    26 min
    Power QueryPractitioner

    Implementing Custom Table Sampling and Statistical Estimation Pipelines in Power Query M: Stratified, Systematic, and Random Row Selection for Large Dataset Profiling

    24 min
    Power QueryPractitioner

    Unpivoting Cross-Tab and Matrix-Formatted Excel Reports in Power Query: Reshaping Wide Data into Analysis-Ready Tables

    23 min

    On this page

    • Introduction
    • Prerequisites
    • Why M's Default Behavior Fails Under Real-World Conditions
    • Understanding M's Error Structure Before You Handle It
    • Building the Core Retry Function
    • Implementing the Delay Mechanism
    • Adding Exponential Backoff with Jitter
    • Respecting `Retry-After` Headers
    • The Complete Production-Ready Retry Implementation
    • Practical Usage: Calling RetryWebRequest
    • Packaging Into a Reusable Function Library
    • Performance Considerations and When Not to Use This Pattern
    • Handling Authentication Errors Correctly
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Not using `ManualStatusHandling`
    • Mistake 2: Infinite recursion on unexpected status codes
    • Mistake 3: Catching authentication errors
    • Mistake 4: Delay not working in Power BI Service
    • Mistake 5: Retry logic causing duplicate side effects
    • Summary & Next Steps