<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Azure Functions for Microsoft 365 on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/azure-functions/</link><description>Recent content in Azure Functions for Microsoft 365 on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Wed, 05 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/tags/azure-functions/index.xml" rel="self" type="application/rss+xml"/><item><title>Every Activity Will Run Twice: Idempotency in Durable Functions</title><link>https://jeppe-spanggaard.dk/blogs/durable-functions-idempotent-activities/</link><pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/durable-functions-idempotent-activities/</guid><description>Learn how to make every Durable Functions activity idempotent so retry policies can safely rerun SharePoint and Graph provisioning steps.</description><content:encoded><![CDATA[<p>My provisioning engine creates SharePoint team sites through a Durable Functions orchestration: create the site, activate features, pull content types, apply templates, seed folders. Every activity call in that orchestrator goes through one shared retry policy:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Outer safety net: retry any activity that throws a transient error (e.g. throttling</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// that slips past HTTP-level retry). Exponential backoff: 5s, 10s, 20s, ... up to 5min,</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 10 attempts total. All activities are idempotent, so retrying the full activity is safe.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> TaskOptions ActivityRetryOptions = <span style="color:#66d9ef">new</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">new</span> RetryPolicy(
</span></span><span style="display:flex;"><span>        maxNumberOfAttempts: <span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>        firstRetryInterval: TimeSpan.FromSeconds(<span style="color:#ae81ff">5</span>),
</span></span><span style="display:flex;"><span>        backoffCoefficient: <span style="color:#ae81ff">2.0</span>,
</span></span><span style="display:flex;"><span>        maxRetryInterval: TimeSpan.FromMinutes(<span style="color:#ae81ff">5</span>)));
</span></span></code></pre></div><p>Read that comment again: &ldquo;All activities are idempotent, so retrying the full activity is safe.&rdquo;</p>
<p>That sentence is either the best thing about the whole engine or a lie that duplicates customer sites at 2am. There&rsquo;s no middle ground. The retry policy reruns the <em>entire activity</em>, not the line that failed. If your activity created a site and then died resolving its ID, the retry creates the site again - unless the activity was written to survive being run twice.</p>
<p>So this post is a catalog of how I actually make that sentence true. Five patterns, all from real provisioning code.</p>
<h2 id="pattern-1-check-before-create">Pattern 1: Check Before Create</h2>
<p>The site creation activity is the highest-stakes one. If a previous attempt created the site but failed a moment later (say, while resolving the Graph site ID), the retry must not create it again. So it probes first:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Idempotency: if the site already exists (e.g. previous attempt created it but</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// failed during Graph ID resolution), skip creation and go straight to Graph lookup.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">await</span> SiteExistsAsync(newSiteUrl))
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    logger.LogInformation(<span style="color:#e6db74">&#34;Site already exists at {SiteUrl}, skipping creation.&#34;</span>, newSiteUrl);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> CreateSiteAsync(parsedType, site, siteAlias, newSiteUrl);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Note what makes the probe possible in the first place: the site URL is deterministic, built from a prefix and alias in the schema. No timestamps, no random suffixes. If the URL contained <code>Guid.NewGuid()</code>, attempt two would probe a different URL, find nothing, and create a second site. Deterministic naming is the quiet prerequisite for check-before-create.</p>
<h2 id="pattern-2-already-exists-is-success">Pattern 2: &ldquo;Already Exists&rdquo; Is Success</h2>
<p>Sometimes you can&rsquo;t probe cheaply, so you attempt the operation and translate the failure. Activating a site feature that&rsquo;s already active comes back from SharePoint as an error - but it means the activity already did its job on a previous run:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (InvalidOperationException ex) when (
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// &#34;Feature already activated&#34; comes back as HTTP 500 with odata.error.code</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// &#34;System.Data.DuplicateNameException&#34; - locale-independent, unlike the human message.</span>
</span></span><span style="display:flex;"><span>    ex.Message.Contains(<span style="color:#e6db74">&#34;System.Data.DuplicateNameException&#34;</span>, StringComparison.OrdinalIgnoreCase))
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    logger.LogInformation(<span style="color:#e6db74">&#34;{Feature} feature is already active, skipping.&#34;</span>, displayName);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The comment is a scar. My first version matched on the English error text, which works great until the code runs against a tenant in another language. Match on error <em>codes</em>, not error <em>messages</em>. The OData error code survives localization; &ldquo;Feature &hellip; is already activated&rdquo; does not.</p>
<p>Graph makes this pattern cleaner because it gives you a real status code. Copying folder structures into the new site:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (ODataError ex) when (ex.ResponseStatusCode == <span style="color:#ae81ff">409</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Item already exists in target - idempotent on retry</span>
</span></span><span style="display:flex;"><span>    logger.LogInformation(<span style="color:#e6db74">&#34;Item &#39;{ItemName}&#39; already exists in target, skipping copy.&#34;</span>, itemName);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>409 Conflict on a create is not a failure. It&rsquo;s a receipt from your previous attempt.</p>
<h2 id="pattern-3-upsert-by-nature">Pattern 3: Upsert by Nature</h2>
<p>The best idempotency is the kind you get for free by choosing the right API. The registration activity writes the new site&rsquo;s URL and ID back to an inventory list, and it does so with SharePoint&rsquo;s <code>ValidateUpdateListItem</code> against a known item ID. Setting the same fields on the same item to the same values twice is indistinguishable from doing it once. There&rsquo;s nothing to guard because the operation has no failure mode for repetition.</p>
<p>When you&rsquo;re designing an activity and you get to pick between &ldquo;add a row&rdquo; and &ldquo;set fields on item X&rdquo;, pick the second. Every <code>Add</code> needs a guard; a keyed <code>Set</code> guards itself.</p>
<h2 id="pattern-4-verify-after-write">Pattern 4: Verify After Write</h2>
<p>The dark twin of idempotency: some SharePoint writes report success and then don&rsquo;t stick. Property bag values on a freshly created site are notorious for this. The activity&rsquo;s answer is to re-read everything it just wrote and throw if reality disagrees:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (mismatches.Count &gt; <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> InvalidOperationException(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">$&#34;Property bag verification failed on {siteUrl}: {string.Join(&#34;</span>; <span style="color:#e6db74">&#34;, mismatches)}. &#34;</span> +
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;Values did not persist to the server; retrying the activity.&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>After writing the property bag values, the activity loads <code>Web.AllProperties</code> fresh from the server.</li>
<li>Each expected key/value is compared against what actually persisted.</li>
<li>Any mismatch throws a plain transient exception, which hands the problem to the orchestrator&rsquo;s retry policy.</li>
<li>The rerun is safe precisely because setting a property bag key is an upsert - pattern 3 again. Verification and idempotency are two halves of the same loop: writes are repeatable, so failed verification can simply demand a repeat.</li>
</ol>
<p>This inverts the usual relationship with retries. Instead of retries being something inflicted on the activity, the activity uses a throw to <em>request</em> one.</p>
<h2 id="pattern-5-idempotent-deletes-too">Pattern 5: Idempotent Deletes Too</h2>
<p>Removal flows have the same problem in the other direction. Removing a user from a site&rsquo;s members group when they&rsquo;re already gone throws a <code>ServerException</code>, and treating that as failure would wedge every cleanup retry:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (ServerException ex)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    logger.LogWarning(ex, <span style="color:#e6db74">&#34;Could not remove {LoginName} from {SiteUrl} ({ErrorType}); treating as already removed.&#34;</span>,
</span></span><span style="display:flex;"><span>        loginName, siteUrl, ex.ServerErrorTypeName);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Desired state: user is not in the group. User is not in the group. Done. Idempotency is about converging on a state, not performing an action.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>One non-idempotent activity poisons the whole pipeline.</strong> I had a security activity that created SharePoint groups without checking for existing ones. Every retry of that step risked duplicate groups, which meant retries weren&rsquo;t safe, which meant the retry policy comment was a lie for the whole orchestration. It&rsquo;s disabled until it earns its way back in. Idempotency is all-or-nothing per pipeline.</li>
<li><strong>The failure window is between success and checkpoint.</strong> The orchestrator records completed steps, but a crash can land after the activity finished and before the record was written. The completed-steps list narrows how often reruns happen; idempotency is what makes the remaining reruns harmless. You need both.</li>
<li><strong><code>catch</code> the narrowest thing you can.</strong> The 409 handler above catches <code>ODataError</code> with status 409, nothing else. A broad <code>catch { return; }</code> also &ldquo;makes retries pass&rdquo;, by swallowing real failures. Idempotency guards should be precise enough that a genuinely broken call still throws.</li>
<li><strong>Test by running it twice, literally.</strong> My smoke test for a new activity is: run the provisioning, then immediately queue the exact same provisioning again. Zero errors and zero duplicates or it doesn&rsquo;t ship.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>A Durable Functions retry policy is a contract: it promises to rerun your activities, and your activities promise not to care. Check before create, read &ldquo;already exists&rdquo; as success, prefer upserts, verify writes that lie. Write every activity as if it will run twice, because on a long enough timeline, it will.</p>
<p>If retry policies and replay are new to you, <a href="https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/">Durable Functions: A Function That Sleeps for a Week</a> covers what an orchestration actually is, and why &ldquo;it will run twice&rdquo; is a feature rather than a bug.</p>
]]></content:encoded></item><item><title>F12 Shows Your API Key: Hiding Third-Party Tokens Behind an Azure Function</title><link>https://jeppe-spanggaard.dk/blogs/spfx-azure-function-api-proxy-hide-token/</link><pubDate>Sat, 25 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/spfx-azure-function-api-proxy-hide-token/</guid><description>Learn how to keep third-party API keys out of the browser by proxying SPFx web part calls through an Azure Function that swaps in the secret server-side.</description><content:encoded><![CDATA[<p>I was building an SPFx web part that shows data from a third-party system - one of those practice-management/CRM-style products with a REST API. The API authenticates the simple way: one tenant-wide key in an <code>X-Api-Key</code> header. Our key, for all of our data.</p>
<p>First instinct: call the API straight from the web part. It works in twenty minutes. Then you press F12, open the network tab, click any request&hellip; and there it is. The company&rsquo;s master API key, in plain text, readable by every single user who ever loads that intranet page.</p>
<p>And it&rsquo;s not &ldquo;they can see the data the web part shows anyway&rdquo; - the web part shows a filtered slice. The <em>key</em> unlocks the whole API: every customer, every record, every write operation the key is licensed for. Anyone who copies it out of the inspector can call the API from Postman on their couch.</p>
<h2 id="why-the-browser-cant-keep-a-secret">Why the Browser Can&rsquo;t Keep a Secret</h2>
<p>It&rsquo;s worth being blunt about this, because the temptation to &ldquo;just obfuscate it a bit&rdquo; is real:</p>
<ul>
<li>Everything the browser <em>sends</em> is in the network tab.</li>
<li>Everything the bundle <em>contains</em> is in the sources tab (source maps or not, strings are strings).</li>
<li>Everything the app <em>holds in memory</em> is one breakpoint away.</li>
</ul>
<p>There is no hiding place in the client. And &ldquo;it&rsquo;s only our internal SharePoint&rdquo; doesn&rsquo;t help - internal users are exactly the people who shouldn&rsquo;t be walking around with the master key to a system they&rsquo;re only supposed to see a corner of.</p>
<h2 id="the-proxy-function">The Proxy Function</h2>
<p>The fix is a small reverse proxy. One catch-all Azure Function fronts the entire third-party API:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> HashSet&lt;<span style="color:#66d9ef">string</span>&gt; _hopByHop = <span style="color:#66d9ef">new</span>(StringComparer.OrdinalIgnoreCase)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Connection&#34;</span>, <span style="color:#e6db74">&#34;Keep-Alive&#34;</span>, <span style="color:#e6db74">&#34;Proxy-Authenticate&#34;</span>, <span style="color:#e6db74">&#34;Proxy-Authorization&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;TE&#34;</span>, <span style="color:#e6db74">&#34;Trailer&#34;</span>, <span style="color:#e6db74">&#34;Transfer-Encoding&#34;</span>, <span style="color:#e6db74">&#34;Upgrade&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Function(&#34;ApiProxy&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;IActionResult&gt; Run(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [HttpTrigger(AuthorizationLevel.Anonymous, &#34;get&#34;, &#34;post&#34;, &#34;put&#34;, &#34;delete&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">                 Route = &#34;api/{*path}&#34;)]</span> HttpRequest request,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> path,
</span></span><span style="display:flex;"><span>    CancellationToken ct)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> targetUri = _settings.BaseUrl.TrimEnd(<span style="color:#e6db74">&#39;/&#39;</span>) + <span style="color:#e6db74">&#34;/&#34;</span> + (path ?? <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>                  + (request.QueryString.Value ?? <span style="color:#e6db74">&#34;&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var upstream = <span style="color:#66d9ef">new</span> HttpRequestMessage(<span style="color:#66d9ef">new</span> HttpMethod(request.Method), targetUri);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (request.Method <span style="color:#66d9ef">is</span> <span style="color:#e6db74">&#34;POST&#34;</span> or <span style="color:#e6db74">&#34;PUT&#34;</span> || request.ContentLength <span style="color:#66d9ef">is</span> &gt; <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        upstream.Content = <span style="color:#66d9ef">new</span> StreamContent(request.Body);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (request.ContentType <span style="color:#66d9ef">is</span> not <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>            upstream.Content.Headers.TryAddWithoutValidation(<span style="color:#e6db74">&#34;Content-Type&#34;</span>, request.ContentType);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> (key, values) <span style="color:#66d9ef">in</span> request.Headers)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Never forward the client&#39;s Host, any API key they try to sneak in,</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// or hop-by-hop headers that belong to *this* connection only.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (key.Equals(<span style="color:#e6db74">&#34;Host&#34;</span>, StringComparison.OrdinalIgnoreCase) ||
</span></span><span style="display:flex;"><span>            key.Equals(<span style="color:#e6db74">&#34;X-Api-Key&#34;</span>, StringComparison.OrdinalIgnoreCase) ||
</span></span><span style="display:flex;"><span>            _hopByHop.Contains(key))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (!upstream.Headers.TryAddWithoutValidation(key, (IEnumerable&lt;<span style="color:#66d9ef">string?</span>&gt;)values!))
</span></span><span style="display:flex;"><span>            upstream.Content?.Headers.TryAddWithoutValidation(key, (IEnumerable&lt;<span style="color:#66d9ef">string?</span>&gt;)values!);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// The swap. The secret exists only here, on the outbound leg.</span>
</span></span><span style="display:flex;"><span>    upstream.Headers.Add(<span style="color:#e6db74">&#34;X-Api-Key&#34;</span>, _settings.ApiKey);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> client = _httpClientFactory.CreateClient(<span style="color:#e6db74">&#34;upstream-api&#34;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var response = <span style="color:#66d9ef">await</span> client.SendAsync(upstream, HttpCompletionOption.ResponseHeadersRead, ct);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    _logger.LogInformation(<span style="color:#e6db74">&#34;Proxy: {Method} {Path} → {StatusCode}&#34;</span>, request.Method, path, (<span style="color:#66d9ef">int</span>)response.StatusCode);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> httpResponse = request.HttpContext.Response;
</span></span><span style="display:flex;"><span>    httpResponse.StatusCode = (<span style="color:#66d9ef">int</span>)response.StatusCode;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> (key, values) <span style="color:#66d9ef">in</span> response.Headers.Concat(response.Content.Headers))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (_hopByHop.Contains(key)) <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>        httpResponse.Headers.Append(key, values.ToArray());
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> response.Content.CopyToAsync(httpResponse.Body, ct);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> EmptyResult();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>Route = &quot;api/{*path}&quot;</code> is a catch-all. <code>GET /api/customers/123/tasks</code> becomes <code>GET https://the-api.example/customers/123/tasks</code>, query string included. One function fronts the whole API surface - when the vendor adds endpoints, the proxy needs zero changes.</li>
<li>The header loop deliberately <strong>strips any inbound <code>X-Api-Key</code></strong>. A caller can&rsquo;t inject their own key or override yours - whatever they send in that header dies at the proxy.</li>
<li>The hop-by-hop set (<code>Connection</code>, <code>Transfer-Encoding</code>, &hellip;) is the detail naive proxies get wrong. Those headers describe <em>one</em> connection, not the request; forwarding them causes wonderfully confusing breakage.</li>
<li>The real key is added to the <strong>outbound request only</strong>. Response headers get copied back to the browser, but request headers are never echoed - so the key physically cannot appear in the inspector. It&rsquo;s not hidden; it&rsquo;s <em>absent</em>.</li>
<li><code>ResponseHeadersRead</code> + <code>CopyToAsync</code> streams the upstream response straight through without buffering it in the function&rsquo;s memory, and upstream status codes pass through untouched so the web part can react to a 404 or a 429 honestly.</li>
</ol>
<h2 id="where-the-key-lives">Where the Key Lives</h2>
<p>Server-side config, strongly typed, validated at boot:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>services.AddOptions&lt;ApiSettings&gt;()
</span></span><span style="display:flex;"><span>    .Bind(configuration.GetSection(<span style="color:#e6db74">&#34;UpstreamApi&#34;</span>))
</span></span><span style="display:flex;"><span>    .Validate(s =&gt; IsConfigured(s.BaseUrl), <span style="color:#e6db74">&#34;UpstreamApi:BaseUrl must be configured.&#34;</span>)
</span></span><span style="display:flex;"><span>    .Validate(s =&gt; IsConfigured(s.ApiKey), <span style="color:#e6db74">&#34;UpstreamApi:ApiKey must be configured.&#34;</span>)
</span></span><span style="display:flex;"><span>    .ValidateOnStart();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">bool</span> IsConfigured(<span style="color:#66d9ef">string</span> <span style="color:#66d9ef">value</span>) =&gt;
</span></span><span style="display:flex;"><span>    !<span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(<span style="color:#66d9ef">value</span>) &amp;&amp; !<span style="color:#66d9ef">value</span>.StartsWith(<span style="color:#e6db74">&#39;&lt;&#39;</span>);
</span></span></code></pre></div><p>The value itself comes from Function App settings - ideally as a Key Vault reference - never from a committed file. <code>ValidateOnStart()</code> plus the <code>&lt;placeholder&gt;</code> guard means a misconfigured secret fails the deployment at boot, not at the first user&rsquo;s click three days later.</p>
<h2 id="hiding--authorizing">Hiding ≠ Authorizing</h2>
<p>Here&rsquo;s the part that&rsquo;s easy to skip and shouldn&rsquo;t be: as shown so far, the proxy hides the key from the browser, but <strong>anyone who discovers the function URL can call it</strong> - and burn your API quota with your key. We&rsquo;ve moved the secret, not secured the door.</p>
<p>Lock the function to Entra ID. Create an app registration for the function API, expose a scope (the convention is <code>access_as_user</code>), and have the function validate incoming JWTs - signature, issuer, and audience, not just parsing the claims out. Parsing is reading the name tag; validating is checking the ID.</p>
<p>On the SPFx side, this is pleasantly little work. Declare the permission in <code>package-solution.json</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;webApiPermissionRequests&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;resource&#34;</span>: <span style="color:#e6db74">&#34;my-function-api&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;scope&#34;</span>: <span style="color:#e6db74">&#34;access_as_user&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>And call the function with <code>AadHttpClient</code> - SPFx acquires and attaches the user&rsquo;s token for you:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">aadHttpClientFactory</span>
</span></span><span style="display:flex;"><span>  .<span style="color:#a6e22e">getClient</span>(<span style="color:#e6db74">&#34;api://&lt;function-app-registration-client-id&gt;&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">response</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">client</span>.<span style="color:#66d9ef">get</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">`https://my-function.azurewebsites.net/api/customers/</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">customerCode</span><span style="color:#e6db74">}</span><span style="color:#e6db74">/tasks`</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">AadHttpClient</span>.<span style="color:#a6e22e">configurations</span>.<span style="color:#a6e22e">v1</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>Now open the inspector again. There <em>is</em> a token in the request - but it&rsquo;s the <strong>user&rsquo;s own</strong> token: short-lived, scoped to your function only, and useless against the third-party API. That&rsquo;s the whole difference between a secret and an identity. A stolen API key is everyone&rsquo;s master key forever; a stolen user token is one person&rsquo;s function access for an hour.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Watch what you log.</strong> Log method, path, and status - never headers. Otherwise the key you carefully kept out of the browser ends up in Application Insights, readable by everyone with portal access.</li>
<li><strong>You now front someone else&rsquo;s quota.</strong> The vendor&rsquo;s rate limits hit <em>your</em> key for <em>all</em> users combined. If the web part is chatty, add caching or throttling in the proxy before the vendor does it for you.</li>
<li><strong>CORS is on you.</strong> The browser is calling your function from a SharePoint origin - configure CORS on the Function App for your tenant&rsquo;s SharePoint domain, not <code>*</code>.</li>
<li><strong>Consider narrowing the surface.</strong> A catch-all proxy forwards <em>everything</em>, including endpoints the web part never needed. If the API has destructive operations, whitelist the methods and paths you actually use.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>A shared API key belongs on a server. Full stop. The moment it ships to a browser, every user has it, and you can&rsquo;t take it back - most vendors will happily rotate the key for you, but you&rsquo;ll be doing that dance on their schedule, not yours.</p>
<p>The proxy costs about fifty lines: catch-all route, header hygiene, one server-side header swap, and Entra ID on the front door. That turns &ldquo;every intranet user carries the master key&rdquo; into &ldquo;every request is a named user, calling a locked endpoint, seeing exactly what the web part shows them&rdquo;. 🔒</p>
]]></content:encoded></item><item><title>Provision Forward, Never Backward: Checkpointing Durable Functions</title><link>https://jeppe-spanggaard.dk/blogs/durable-functions-provisioning-checkpoints/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/durable-functions-provisioning-checkpoints/</guid><description>Learn how to checkpoint a Durable Functions orchestration so a failed SharePoint provisioning run resumes where it stopped instead of starting over.</description><content:encoded><![CDATA[<p>I built a provisioning engine that creates a SharePoint team site for every new customer. Create the site, activate features, apply a template, seed a folder structure, add groups, register the site in an inventory list. Roughly ten steps, several minutes end to end, talking to SharePoint and Microsoft Graph the whole way.</p>
<p>The first time step seven failed, I got to watch my engine try to create a site that already existed.</p>
<p>That run taught me the rule this post is about: in a long provisioning flow, you don&rsquo;t roll back when something fails. You checkpoint, and you resume forward.</p>
<p>If Durable Functions are new to you, start with my intro to <a href="https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/">what they are and what they can do</a> - this post builds on it.</p>
<h2 id="why-rollback-is-the-wrong-instinct">Why Rollback Is the Wrong Instinct</h2>
<p>When provisioning fails at step seven, you have a half-built site. The textbook answer is a compensation saga: undo steps six through one in reverse order. Delete the folders, detach the template, deactivate the features, delete the site, then recreate everything from scratch on the next attempt.</p>
<p>I started sketching that and stopped halfway through the list, because every line was either dangerous or absurd. Deleting a site collection to work around a failed navigation tweak is using a crane to hang a picture. And some steps don&rsquo;t even have an undo - you can&rsquo;t meaningfully &ldquo;unapply&rdquo; a provisioning template that merged fields into existing lists.</p>
<p>Here&rsquo;s the thing rollback ignores: the six completed steps aren&rsquo;t damage. They&rsquo;re progress. The site is fine; what&rsquo;s missing is the steps that haven&rsquo;t run yet. So the only recovery that makes sense is forward: figure out where the run stopped, and continue from there.</p>
<p>That reframes the problem completely. I don&rsquo;t need compensation logic. I need to know, reliably, which steps finished.</p>
<h2 id="carry-the-progress-in-the-state">Carry the Progress in the State</h2>
<p>Durable Functions makes this natural, because an orchestrator already passes state to each activity and gets state back. The trick is to make &ldquo;what&rsquo;s done&rdquo; part of that state. My orchestration state looks something like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">sealed</span> <span style="color:#66d9ef">record</span> <span style="color:#a6e22e">SiteSetupState</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> CustomerId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> SiteUrl,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span>[] CompletedSteps
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>And the orchestrator walks its steps like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> state = context.GetInput&lt;SiteSetupState&gt;() ?? <span style="color:#66d9ef">await</span> CreateSite(context, customerId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">string</span>[] pipeline =
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(ActivateFeatures),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(ApplyTemplate),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(SeedFolders),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(AddMemberGroups),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(RegisterSite),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">]</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> step <span style="color:#66d9ef">in</span> pipeline)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (state.CompletedSteps.Contains(step))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        logger.LogInformation(<span style="color:#e6db74">&#34;{Step} already completed, skipping.&#34;</span>, step);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(step, state, retryOptions);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    state = state with { CompletedSteps = [.. state.CompletedSteps, step] };
</span></span><span style="display:flex;"><span>    context.SetCustomStatus(state);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The pipeline is just an ordered list of activity names. Nothing clever, and that&rsquo;s the point - the interesting machinery is around the calls, not in them.</li>
<li>Before each step, the orchestrator checks whether this step already ran. On a fresh run the array is empty and nothing is skipped. On a resumed run, this check is what fast-forwards past the finished work.</li>
<li>After each successful step, the state is copied with the step name appended. An immutable <code>with</code> copy, so nothing mutates in place, which keeps replays honest.</li>
<li><code>SetCustomStatus</code> publishes the updated state on the orchestration instance. This is the checkpoint. It costs one line.</li>
</ol>
<p>That last line is doing more work than it looks like. Custom status is readable from <em>outside</em> the orchestration - through the management API, without touching the orchestration history. So the same call gives you two things: anyone polling the instance sees live progress (&ldquo;three of six steps done&rdquo;), and if the run fails, the last published state is sitting right there, telling you exactly where it stopped.</p>
<h2 id="resume-is-just-input">Resume Is Just Input</h2>
<p>Because the checkpoint is a plain serializable record, resuming a failed run doesn&rsquo;t need any special framework support. You read the failed instance&rsquo;s custom status, and you start a <em>new</em> orchestration with that state as input:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> failed = <span style="color:#66d9ef">await</span> client.GetInstanceAsync(failedInstanceId, getInputsAndOutputs: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> checkpoint = failed.ReadCustomStatusAs&lt;SiteSetupState&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> client.ScheduleNewOrchestrationInstanceAsync(
</span></span><span style="display:flex;"><span>    nameof(SiteSetupOrchestrator),
</span></span><span style="display:flex;"><span>    checkpoint);
</span></span></code></pre></div><p>The new run enters the same loop, finds five steps in <code>CompletedSteps</code>, skips them in about a millisecond, and picks up at step six. No site deletion, no re-creation, no duplicate template apply. The half-built site becomes a five-sixths-built site, then a finished one.</p>
<p>I like how little there is to this. The &ldquo;resume feature&rdquo; is the skip-check in the loop plus the fact that the input type and the checkpoint type are the same type. That&rsquo;s it.</p>
<h2 id="the-gap-that-idempotency-covers">The Gap That Idempotency Covers</h2>
<p>One honest caveat. The checkpoint is written <em>after</em> the activity succeeds, so there&rsquo;s a window: the activity finishes, the process dies before the checkpoint lands. On resume, that step&rsquo;s name isn&rsquo;t in <code>CompletedSteps</code>, and it runs again.</p>
<p>You can&rsquo;t close that window - it&rsquo;s inherent to doing the work and recording the work as two operations. What you do instead is make every activity safe to run twice: check before create, treat &ldquo;already exists&rdquo; as success, write with upserts. That&rsquo;s a full topic on its own, but the division of labor is worth stating plainly: <strong>the checkpoint decides how often steps rerun, idempotency decides whether reruns hurt.</strong> You need both. The checkpoint alone has a crash window; idempotency alone means re-executing ten minutes of finished work on every hiccup.</p>
<p>And never checkpoint <em>before</em> the call to close the window from the other side - then a crashed step gets skipped on resume, which is far worse. A step that runs twice is a wasted minute; a step that runs zero times is a broken site that says &ldquo;Completed&rdquo;. Ask me how I know.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Use the replay-safe logger.</strong> Orchestrator code replays from history every time the instance wakes up. <code>context.CreateReplaySafeLogger(...)</code> keeps your logs from repeating every completed step on each replay. A regular <code>ILogger</code> in an orchestrator will gaslight you.</li>
<li><strong>No clocks, no GUIDs in the orchestrator.</strong> <code>DateTime.UtcNow</code>, <code>Guid.NewGuid()</code>, and <code>Random</code> produce different values on replay and corrupt the history. Anything nondeterministic belongs inside an activity, including generated names and timestamps you want in the state.</li>
<li><strong>Custom status has a size limit</strong> (16 KB of JSON). A record with a customer ID, a URL, and an array of step names fits hundreds of times over, but don&rsquo;t stuff a whole template or file manifest in there. Checkpoint the <em>position</em>, not the <em>payload</em>.</li>
<li><strong>Step names are a contract.</strong> The moment <code>CompletedSteps</code> is persisted anywhere - a failed instance you might resume next week - renaming an activity breaks the match and the step silently reruns (fine, if idempotent) or the resume misbehaves (not fine). Rename with the same care you&rsquo;d give a database column.</li>
<li><strong>Report progress somewhere humans look.</strong> Custom status is great for machines; my engine also writes the current step name to a status column in the site inventory list, inside a try/catch that logs and swallows. A cosmetic status write must never kill a provisioning run.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Long provisioning flows fail in the middle, so design for resuming instead of undoing: carry a list of completed steps in the orchestration state, publish it as custom status after every step, skip completed steps on rerun, and feed the saved state back in to resume. Rollback is for databases. Provisioning goes forward.</p>
]]></content:encoded></item><item><title>Durable Functions: A Function That Sleeps for a Week</title><link>https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/</link><pubDate>Wed, 15 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/</guid><description>Learn what Azure Durable Functions are and what they can do, using a SharePoint site provisioning workflow as a real-world example.</description><content:encoded><![CDATA[<p>I had to build a provisioning engine. Every time a new customer lands in the business system, it creates a SharePoint team site for them: create the site, activate features, apply a template, seed a folder structure, add member groups, register the site in an inventory list. Roughly ten steps, several minutes end to end, and every single step talks to an API that can throttle, hiccup, or time out.</p>
<p>My first instinct was a plain Azure Function. One trigger, one method, ten steps in a row.</p>
<p>That instinct survived about a day. A plain function has a timeout measured in minutes. It holds its state in local variables, so when the process restarts mid-run (deployments, scale-in, Azure having a Tuesday), everything it knew is gone. Step six fails and your options are &ldquo;run all ten steps again&rdquo; or &ldquo;reconstruct what happened from logs like a crime scene&rdquo;.</p>
<p>And it&rsquo;s not like the step-sequencing part was new to me. I&rsquo;d already built <a href="https://github.com/Pinksky-ApS/provisioning-pipeline-dotnet">ProvisioningPipeline</a>, an open-source .NET library for composing provisioning workflows from middleware-style steps, with hooks and parallel execution. It solves the &ldquo;run these steps in order, cleanly&rdquo; problem well. What no in-process pipeline can solve is the other half: a pipeline in memory still dies with the process, and all its progress dies with it.</p>
<p>What I actually needed was a function that can run for a long time, remember how far it got, and pick up where it left off. That&rsquo;s not a pattern you bolt onto a normal function. It&rsquo;s a different tool, and Azure ships it: Durable Functions.</p>
<h2 id="three-functions-one-flow">Three Functions, One Flow</h2>
<p>A durable workflow is made of three kinds of functions, and the separation is the whole idea.</p>
<p>The <strong>client</strong> is a normal function with a normal trigger - HTTP, queue, timer, whatever starts your process. Its only durable job is to schedule an orchestration and get out of the way:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(nameof(StartProvisioning))]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task Run(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [ServiceBusTrigger(&#34;provisioning&#34;)]</span> ProvisioningMessage message,
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [DurableClient]</span> DurableTaskClient client)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> client.ScheduleNewOrchestrationInstanceAsync(
</span></span><span style="display:flex;"><span>        nameof(SiteSetupOrchestrator),
</span></span><span style="display:flex;"><span>        message.ToSetupRequest());
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <strong>orchestrator</strong> is the recipe. It calls the steps in order and holds the workflow logic, but does no real work itself:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(nameof(SiteSetupOrchestrator))]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task RunOrchestrator(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [OrchestrationTrigger]</span> TaskOrchestrationContext context)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> request = context.GetInput&lt;SiteSetupRequest&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> site = <span style="color:#66d9ef">await</span> context.CallActivityAsync&lt;SiteInfo&gt;(nameof(CreateSite), request);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(nameof(ActivateFeatures), site);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(nameof(ApplyTemplate), site);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(nameof(SeedFolders), site);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(nameof(AddMemberGroups), site);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.CallActivityAsync(nameof(RegisterSite), site);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <strong>activities</strong> are where the actual SharePoint and Graph calls live. Each one is a small, self-contained function: <code>CreateSite</code> creates a site, <code>ApplyTemplate</code> applies a template, and so on.</p>
<p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The client schedules an orchestration instance and returns immediately. Nothing waits around; the run now has a life of its own, with an instance ID you can use to check on it later.</li>
<li>The orchestrator reads like a script: six awaited steps, top to bottom. This is the part that would be a state machine diagram in Logic Apps or a pile of queues in a hand-rolled solution. Here it&rsquo;s just code, with loops and try/catch available when you need them.</li>
<li>Every <code>CallActivityAsync</code> is a checkpoint boundary. The runtime records that the step completed, and what it returned, before moving on.</li>
<li>Activities take input, do I/O, return output. They don&rsquo;t know they&rsquo;re part of a workflow, which keeps them easy to write and easy to test.</li>
</ol>
<h2 id="the-durable-part-is-a-history-table">The &ldquo;Durable&rdquo; Part Is a History Table</h2>
<p>Here&rsquo;s what separates this from a normal async method: the orchestrator&rsquo;s progress doesn&rsquo;t live in memory. Every scheduled activity and every result gets written to a history table in storage as an event.</p>
<p>When the process dies mid-run - and over a multi-minute workflow across enough customers, it will - the runtime picks the orchestration up on another instance and <em>replays</em> it. The orchestrator code runs again from the top, but every activity that already completed doesn&rsquo;t re-execute; its recorded result comes straight back from history. The replay fast-forwards through the finished steps in milliseconds and resumes real work at the first step without a recorded result.</p>
<p>It&rsquo;s a save file in a video game. The console losing power doesn&rsquo;t send you back to level one; you reload and continue from the last save point. The orchestrator saves after every activity, and it never plays a level twice.</p>
<p>That same mechanism is why an orchestration can wait for days without costing you anything. Awaiting a durable timer or an external event just means &ldquo;write the wait to history and unload from memory&rdquo;. No process is alive, no compute is billed, and when the timer fires next week, replay reconstructs the state and continues. A function that sleeps for a week, literally.</p>
<h2 id="what-else-is-in-the-box">What Else Is in the Box</h2>
<p>Sequential steps are the basics. A few more capabilities, through the provisioning lens:</p>
<p><strong>Per-step retries.</strong> Every activity call can carry a retry policy, and this is the built-in retry logic worth knowing about. Mine looks like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> TaskOptions ActivityRetryOptions = <span style="color:#66d9ef">new</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">new</span> RetryPolicy(
</span></span><span style="display:flex;"><span>        maxNumberOfAttempts: <span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>        firstRetryInterval: TimeSpan.FromSeconds(<span style="color:#ae81ff">5</span>),
</span></span><span style="display:flex;"><span>        backoffCoefficient: <span style="color:#ae81ff">2.0</span>,
</span></span><span style="display:flex;"><span>        maxRetryInterval: TimeSpan.FromMinutes(<span style="color:#ae81ff">5</span>)));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> context.CallActivityAsync(nameof(ApplyTemplate), site, ActivityRetryOptions);
</span></span></code></pre></div><p>If <code>ApplyTemplate</code> throws because SharePoint returned a 429, the runtime waits 5 seconds and reruns the activity. Then 10, then 20, backing off exponentially up to 5 minutes, for up to 10 attempts. The orchestration just sits there durably waiting between attempts. For workflows built on APIs that throttle, this one feature pays for the whole ticket.</p>
<p><strong>Fan-out/fan-in.</strong> Steps that don&rsquo;t depend on each other can run as parallel activities and be awaited together with <code>Task.WhenAll</code>. Seeding twelve folders one by one is a for-loop; seeding them as twelve parallel activities is the same loop without the awaits until the end.</p>
<p><strong>Durable timers.</strong> <code>context.CreateTimer(...)</code> gives you delays and polling loops that survive restarts. Waiting 30 seconds for SharePoint to finish creating a site is a timer, not a <code>Task.Delay</code> and a prayer.</p>
<p><strong>External events.</strong> An orchestration can pause until something outside signals it - <code>WaitForExternalEvent</code> is how you build &ldquo;wait for an admin to approve, then continue&rdquo;, even if the admin takes four days.</p>
<p><strong>Status from the outside.</strong> Every instance can be queried by ID: is it running, what step is it on, did it fail and why. My provisioning engine exposes this so the business system can show &ldquo;site is being created&rdquo; instead of a shrug.</p>
<h2 id="what-it-doesnt-do-for-you">What It Doesn&rsquo;t Do for You</h2>
<p>An honest intro should include the bill.</p>
<p><strong>Retries are per step, not per run.</strong> The retry policy reruns a failing <em>activity</em>. If an activity exhausts all ten attempts, the orchestration fails, and a failed run stays failed - nothing in the box restarts it from where it stopped. Resuming a dead run is a pattern you build yourself on top of these primitives.</p>
<p><strong>Retried activities rerun whole.</strong> When attempt two starts, it starts from the activity&rsquo;s first line, not from where attempt one died. An activity that created a site and then failed will try to create the site again. Every activity has to be written to survive running twice, and that&rsquo;s on you.</p>
<p><strong>Orchestrator code lives under house rules.</strong> Because the orchestrator replays, it must be deterministic: same history in, same decisions out. No <code>DateTime.UtcNow</code>, no <code>Guid.NewGuid()</code>, no HTTP calls, no reading config that might change between replays. Anything nondeterministic belongs in an activity. The rules are simple but unforgiving, and the failure mode is a corrupted run, not a compile error.</p>
<p>None of these are dealbreakers. They&rsquo;re the shape of the tool: the runtime guarantees your progress is never lost, and in exchange you write steps that tolerate being repeated.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Use the replay-safe logger in orchestrators.</strong> <code>context.CreateReplaySafeLogger(...)</code> suppresses log lines during replay. A regular <code>ILogger</code> logs every completed step again on every replay, and the duplicated log stream will have you debugging problems you don&rsquo;t have.</li>
<li><strong>Keep activities small and single-purpose.</strong> One activity per step means one checkpoint per step. A mega-activity that does five things gives you one checkpoint for five steps, and a failure at thing four reruns things one through three.</li>
<li><strong>The retry policy can&rsquo;t tell a 429 from a typo.</strong> It retries every exception the same way, including the ones that will never succeed, like a broken config value. Distinguishing &ldquo;try again&rdquo; from &ldquo;stop, a human needs to fix this&rdquo; takes extra work.</li>
<li><strong>Don&rsquo;t sneak I/O into the orchestrator.</strong> It compiles, it works on the happy path, and it breaks determinism the first time a replay gets a different answer. If it touches the network or the clock, it&rsquo;s an activity.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>A Durable Function is a workflow that remembers where it was: an orchestrator sequences the steps, activities do the work, and the runtime checkpoints every step so crashes, restarts, and week-long waits don&rsquo;t lose progress. If your process has numbered steps, talks to flaky APIs, and takes longer than a request, it&rsquo;s a fit - provisioning was mine.</p>
]]></content:encoded></item><item><title>Stop Re-Downloading Unchanged Blobs: Let Azure Answer 304 for You</title><link>https://jeppe-spanggaard.dk/blogs/azure-blob-storage-etag-304/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/azure-blob-storage-etag-304/</guid><description>Learn how to forward the browser's If-None-Match header to Azure Blob Storage with BlobRequestConditions, so unchanged files never leave storage at all.</description><content:encoded><![CDATA[<p>I have an Azure Functions backend whose job, among other things, is serving a web app&rsquo;s static files out of Blob Storage: JS bundles, fonts, icons, an <code>index.html</code>. Same files, same users, many times a day.</p>
<p>And for a while, every single request did the same dumb thing: download the blob from storage, push the bytes to the browser. The file hadn&rsquo;t changed since five seconds ago. The user&rsquo;s browser literally had an identical copy already. Didn&rsquo;t matter - full download from storage, full response to the client, every time. I was paying latency and moving bytes just to deliver files nobody actually needed re-delivered.</p>
<p>The fix was already sitting in every storage response I&rsquo;d been ignoring: the ETag.</p>
<h2 id="thirty-seconds-on-etags">Thirty Seconds on ETags</h2>
<p>Every blob response (and every properties call) includes an <code>ETag</code> header, something like <code>&quot;0x8DC5F3A2B1E4D70&quot;</code>. Storage changes it whenever the blob&rsquo;s content or metadata changes. It costs nothing, it&rsquo;s always there.</p>
<p>On the HTTP side, the handshake is old and boring and great: the server sends <code>ETag</code> with a response; the browser saves it; next time the browser asks for the same URL it includes <code>If-None-Match: &lt;that etag&gt;</code>; if the server still has the same version, it answers <code>304 Not Modified</code> with no body, and the browser uses its cached copy.</p>
<p>Boring. Reliable. Built into every browser since forever.</p>
<h2 id="the-naive-version">The Naive Version</h2>
<p>My first pass was the obvious one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(cancellationToken: ct);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(response.Value.Content, contentType);
</span></span></code></pre></div><p>Works fine. But look at what the function actually <em>is</em> in this setup: a photocopier standing between storage and the browser, dutifully copying files that both sides already agree on. The browser has the file. Storage knows the file hasn&rsquo;t changed. And my function in the middle is the only one who never asked.</p>
<h2 id="the-trick-be-a-pipe-not-a-cache">The Trick: Be a Pipe, Not a Cache</h2>
<p>The Azure SDK supports conditional requests natively. So instead of checking anything myself, I just hand the browser&rsquo;s ETag straight to storage:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;AssetResponse?&gt; GetAsync(<span style="color:#66d9ef">string</span> path, <span style="color:#66d9ef">string?</span> ifNoneMatch, CancellationToken ct)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> blob = _container.GetBlobClient(path);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> BlobDownloadOptions();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Pass the client&#39;s ETag to the storage service as a conditional request.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Azure Blob Storage will short-circuit at the network level and return a 304,</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// meaning we never transfer the file body when the client is up to date.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!<span style="color:#66d9ef">string</span>.IsNullOrEmpty(ifNoneMatch))
</span></span><span style="display:flex;"><span>        options.Conditions = <span style="color:#66d9ef">new</span> BlobRequestConditions { IfNoneMatch = <span style="color:#66d9ef">new</span> ETag(ifNoneMatch) };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(options, ct);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (response.GetRawResponse().Status == <span style="color:#ae81ff">304</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Client&#39;s copy is current - signal it without a body.</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> AssetResponse { ETag = ifNoneMatch!, IsNotModified = <span style="color:#66d9ef">true</span> };
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> details = response.Value.Details;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> AssetResponse
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            ETag = details.ETag.ToString(),
</span></span><span style="display:flex;"><span>            Content = response.Value.Content,
</span></span><span style="display:flex;"><span>            ContentType = details.ContentType,
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (RequestFailedException ex) when (ex.Status == <span style="color:#ae81ff">404</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The browser&rsquo;s <code>If-None-Match</code> value goes into <code>BlobRequestConditions.IfNoneMatch</code>. That turns the download into a conditional request, the same ETag handshake, just one hop deeper.</li>
<li>The comparison happens <strong>inside Azure Storage</strong>, not in my code. On a match, storage answers 304 and the response has no body. The file bytes never even reach my function.</li>
<li>On a miss (file changed, or first visit), it&rsquo;s a normal download, and <code>DownloadStreamingAsync</code> gives me a live stream I pass straight through without buffering the whole file in memory.</li>
<li>Missing blob → the <code>RequestFailedException</code> filter turns a 404 into a <code>null</code>, which the endpoint maps to a proper NotFound.</li>
</ol>
<p>There is no dictionary of ETags, no memory cache, no invalidation logic. I didn&rsquo;t build a cache, I <em>connected two caches that already existed</em>: the browser&rsquo;s and storage&rsquo;s own knowledge of its blobs.</p>
<h2 id="finishing-the-loop-toward-the-browser">Finishing the Loop Toward the Browser</h2>
<p>The function endpoint relays the result:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>req.HttpContext.Response.Headers[HeaderNames.ETag] = asset.ETag;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (asset.IsNotModified)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> StatusCodeResult(StatusCodes.Status304NotModified);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>req.HttpContext.Response.Headers[HeaderNames.CacheControl] = asset.IsImmutable
</span></span><span style="display:flex;"><span>    ? <span style="color:#e6db74">&#34;public, max-age=31536000, immutable&#34;</span>
</span></span><span style="display:flex;"><span>    : <span style="color:#e6db74">&#34;no-cache&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(asset.Content!, asset.ContentType!);
</span></span></code></pre></div><p>Two details worth pausing on:</p>
<ul>
<li><strong><code>no-cache</code> doesn&rsquo;t mean &ldquo;don&rsquo;t cache&rdquo;.</strong> It means &ldquo;cache it, but revalidate before using it&rdquo;. That revalidation is exactly the <code>If-None-Match</code> round trip, which the pass-through just made nearly free - a header-only 304 instead of a file download. This is what <code>index.html</code> gets.</li>
<li><strong>Content-hashed files skip the conversation entirely.</strong> My build tool outputs filenames like <code>app.ByJ3R0Az.js</code> - the hash <em>is</em> the version. Those get <code>max-age=31536000, immutable</code>, so the browser never revalidates them at all. A new deploy produces a new filename, which is simply a different URL. The ETag dance is only for files whose names stay stable while their content changes.</li>
</ul>
<h2 id="why-i-like-this-better-than-a-memory-cache">Why I Like This Better Than a Memory Cache</h2>
<p>My first instinct was an <code>IMemoryCache</code> of blob contents in the function. I&rsquo;m glad I resisted:</p>
<ul>
<li><strong>Nothing to size.</strong> No &ldquo;how many MB of blobs do I keep in memory&rdquo; question.</li>
<li><strong>Nothing to invalidate.</strong> The ETag comparison is against live storage, so a deploy is visible on the very next request. Stale-cache bugs can&rsquo;t exist because there&rsquo;s no cache to go stale.</li>
<li><strong>Scale-out safe.</strong> Ten function instances behave identically because none of them hold state. A per-instance memory cache would give ten different answers for 60 seconds after every deploy.</li>
</ul>
<p>The thing that owns the data does the validation. Everyone else just forwards headers. 📮</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Round-trip the ETag string untouched.</strong> The quotes are part of the value. Trim them, &ldquo;clean them up&rdquo;, or re-wrap them and the comparison silently never matches again - everything still works, you just download every file every time and never notice.</li>
<li><strong>No ETag out, no <code>If-None-Match</code> back.</strong> Browsers only revalidate if your response included the <code>ETag</code> header in the first place. Forget it on one branch (error paths are a classic) and that file is a full download forever.</li>
<li><strong><code>no-cache</code> still costs one round trip per file per load.</strong> That&rsquo;s the deal: a header-only 304 instead of a body. For files that never change, don&rsquo;t negotiate - content-hash the filename and go <code>immutable</code>.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The cheapest download is the one that never happens. Blob Storage already fingerprints every blob and already speaks conditional requests - most backends just never pass the browser&rsquo;s question along. Forward <code>If-None-Match</code>, relay the 304, and let the two parties who actually know the answer talk to each other.</p>
]]></content:encoded></item><item><title>One Endpoint, Whole Website: Serving a Static Site Through an Azure Function</title><link>https://jeppe-spanggaard.dk/blogs/azure-function-blob-storage-static-site-proxy/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/azure-function-blob-storage-static-site-proxy/</guid><description>Learn how a single Azure Function with a catch-all route can proxy an entire static website out of a private Blob Storage container, with auth in front of every file.</description><content:encoded><![CDATA[<p>I needed to host an internal handbook. Nothing fancy: a static site full of onboarding guides and how-tos, built with a static site generator into a folder of HTML, CSS, JS, and images. The one hard requirement: <strong>only signed-in employees get to see it.</strong></p>
<p>And that requirement quietly kills all the easy hosting options. Blob Storage&rsquo;s static website feature? Public. A plain CDN? Public. The moment &ldquo;who is asking&rdquo; matters for every single file - not just the pages, the images and search index too - the files have to live somewhere private, and <em>something</em> with auth has to sit in front and hand them out.</p>
<p>My something is one Azure Function. One endpoint. It serves the entire site.</p>
<h2 id="the-blob-container-is-the-filesystem">The Blob Container Is the Filesystem</h2>
<p>There&rsquo;s no clever mapping layer. I upload the build output into the container exactly as the generator produced it:</p>
<pre tabindex="0"><code>index.html
guides/onboarding/index.html
guides/expenses/index.html
assets/main.css
assets/search.js
images/office-map.png
</code></pre><p>The container layout <em>is</em> the URL space. Deploying a new version of the site is one CLI command:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>az storage blob upload-batch --source ./public --destination site-content --overwrite
</span></span></code></pre></div><h2 id="the-catch-all-function">The Catch-All Function</h2>
<p>Here&rsquo;s the whole thing, trimmed to its skeleton:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(&#34;Site&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;IActionResult&gt; Run(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [HttpTrigger(AuthorizationLevel.Anonymous, &#34;get&#34;, Route = &#34;{*path}&#34;)]</span> HttpRequest req,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> path,
</span></span><span style="display:flex;"><span>    CancellationToken ct)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Every file goes through this gate - pages, scripts, images, all of it.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!<span style="color:#66d9ef">await</span> _auth.IsSignedInAsync(req))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> UnauthorizedResult();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> blobPath = MapToBlobPath(path);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> blob = _container.GetBlobClient(blobPath);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(cancellationToken: ct);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(response.Value.Content, contentType);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (RequestFailedException ex) when (ex.Status == <span style="color:#ae81ff">404</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> NotFoundResult();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">string</span> MapToBlobPath(<span style="color:#66d9ef">string?</span> path)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrEmpty(path))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;index.html&#34;</span>;                    <span style="color:#75715e">// &#34;/&#34; → the front page</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (path.EndsWith(<span style="color:#e6db74">&#39;/&#39;</span>) || !Path.HasExtension(path))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">$&#34;{path.TrimEnd(&#39;/&#39;)}/index.html&#34;</span>; <span style="color:#75715e">// &#34;/guides/onboarding/&#34; → its index</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> path;                                 <span style="color:#75715e">// &#34;/assets/main.css&#34; → as-is</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>Route = &quot;{*path}&quot;</code> is the whole trick. The <code>*</code> makes it a catch-all: one route binding matches <code>/</code>, <code>/guides/onboarding/</code>, <code>/assets/main.css</code>, anything. One function, every file on the site.</li>
<li>The auth guard runs before anything touches storage. That&rsquo;s the entire reason this setup exists - a static host can protect a <em>site</em>, this protects every <em>byte</em>. (How <code>IsSignedInAsync</code> works - cookies, Entra ID, whatever fits - is its own topic; the point is it&rsquo;s one <code>if</code> at the top.)</li>
<li><code>MapToBlobPath</code> does the job a web server normally does silently: default documents. <code>/</code> becomes <code>index.html</code>, extension-less routes like <code>/guides/onboarding</code> become <code>guides/onboarding/index.html</code>. Forget this and your front page is a 404.</li>
<li><code>DownloadStreamingAsync</code> returns a live stream, and <code>FileStreamResult</code> pipes it straight to the response. The function never buffers a whole file in memory - a 4 MB image flows through, it doesn&rsquo;t <em>land</em> here.</li>
<li>A missing blob throws <code>RequestFailedException</code> with status 404; the exception filter turns that into a clean <code>NotFoundResult</code> instead of a pre-flight existence check (which would just be a second storage call).</li>
</ol>
<h2 id="content-types-the-unglamorous-part-that-breaks-everything">Content Types: The Unglamorous Part That Breaks Everything</h2>
<p>If you serve HTML with the wrong <code>Content-Type</code>, the browser doesn&rsquo;t render your page, it <em>downloads</em> it. Ask me how I know.</p>
<p>Best option: set correct content types on the blobs at upload time (<code>upload-batch</code> infers most of them). But blobs uploaded by hand or by older scripts often end up as <code>application/octet-stream</code>, so I keep a fallback map:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">string</span> ContentTypeFor(<span style="color:#66d9ef">string</span> path) =&gt; Path.GetExtension(path).ToLowerInvariant() <span style="color:#66d9ef">switch</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.html&#34;</span> =&gt; <span style="color:#e6db74">&#34;text/html&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.css&#34;</span>  =&gt; <span style="color:#e6db74">&#34;text/css&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.js&#34;</span>   =&gt; <span style="color:#e6db74">&#34;text/javascript&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.json&#34;</span> =&gt; <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.svg&#34;</span>  =&gt; <span style="color:#e6db74">&#34;image/svg+xml&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.png&#34;</span>  =&gt; <span style="color:#e6db74">&#34;image/png&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.woff2&#34;</span> =&gt; <span style="color:#e6db74">&#34;font/woff2&#34;</span>,
</span></span><span style="display:flex;"><span>    _ =&gt; <span style="color:#e6db74">&#34;application/octet-stream&#34;</span>,
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><h2 id="what-you-get-for-free">What You Get for Free</h2>
<ul>
<li><strong>Auth on every file.</strong> Not just page-level protection - the org chart PNG and the search index JSON are exactly as protected as the pages.</li>
<li><strong>One deploy target.</strong> Build the site, <code>upload-batch</code> the folder, done. No web server to configure, nothing to restart.</li>
<li><strong>Generator-agnostic.</strong> The function doesn&rsquo;t know or care if the folder came from Hugo, Astro, Docusaurus, or hand-written HTML.</li>
<li><strong>Cheap environments.</strong> Staging is just a second container and one config value. Rollback is re-uploading yesterday&rsquo;s build folder.</li>
</ul>
<h2 id="one-more-trick-stop-re-downloading-unchanged-files">One More Trick: Stop Re-Downloading Unchanged Files</h2>
<p>There&rsquo;s an elephant in the version above: every request downloads the full file from storage and pushes it to the browser - even when the file hasn&rsquo;t changed in weeks and the browser has a perfect copy from five minutes ago.</p>
<p>The fix is already sitting in Blob Storage: every blob has an <strong>ETag</strong>, a version fingerprint that changes on every write. Browsers already know the game - once they&rsquo;ve seen an <code>ETag</code> header, they send it back as <code>If-None-Match</code> on the next request. All the function has to do is forward that header into the SDK call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> BlobDownloadOptions();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ifNoneMatch = req.Headers.IfNoneMatch.ToString();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (!<span style="color:#66d9ef">string</span>.IsNullOrEmpty(ifNoneMatch))
</span></span><span style="display:flex;"><span>    options.Conditions = <span style="color:#66d9ef">new</span> BlobRequestConditions { IfNoneMatch = <span style="color:#66d9ef">new</span> ETag(ifNoneMatch) };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(options, ct);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (response.GetRawResponse().Status == <span style="color:#ae81ff">304</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Browser&#39;s copy is current - Azure never sent us the body at all.</span>
</span></span><span style="display:flex;"><span>    req.HttpContext.Response.Headers[HeaderNames.ETag] = ifNoneMatch;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> StatusCodeResult(StatusCodes.Status304NotModified);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>req.HttpContext.Response.Headers[HeaderNames.ETag] = response.Value.Details.ETag.ToString();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(response.Value.Content, contentType);
</span></span></code></pre></div><p>The beautiful part: the ETag comparison happens <strong>inside Azure Storage</strong>, not in your code. On a match, storage answers 304 with no body - the file bytes never reach the function, and the function relays a bare 304 to the browser. No server-side cache, nothing to invalidate, and it works identically across scaled-out instances because nobody holds any state. The browser is the cache, Azure is the validator, and the function stays what it was: a pipe.</p>
<p>Two rules to make it stick: always set the <code>ETag</code> response header (no ETag out means the browser never asks conditionally again), and pass the value through untouched - the quotes are part of it, and &ldquo;cleaning them up&rdquo; silently breaks the match forever.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>You are the web server now.</strong> Default documents, trailing slashes, 404 pages - all the invisible things a real web server does are your job. <code>MapToBlobPath</code> above is the minimum, not the maximum.</li>
<li><strong>SPA? Then unknown routes need a fallback.</strong> For a client-side-routed app, a route with no matching blob should serve <code>index.html</code> (200, not 404) and let the router sort it out. For a docs site like mine, a real 404 is correct.</li>
<li><strong>Cold starts sit in front of your CSS.</strong> The function is in the path of <em>every byte</em>, so a consumption-plan cold start delays the whole page, not just an API call. For an internal tool I can live with it; know your tolerance.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>One catch-all route, a private container, and a twenty-line function: a whole authenticated website with no web server to run. Add the <code>If-None-Match</code> pass-through and unchanged files stop moving entirely. The container is the filesystem, the function is the doorman. 🚪</p>
]]></content:encoded></item><item><title>One Big PnP Template or Many Small Ones?</title><link>https://jeppe-spanggaard.dk/blogs/pnp-template-sizing-resilience/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/pnp-template-sizing-resilience/</guid><description>After testing with DevProxy, I found that monolithic PnP templates silently destroy your retry strategy. Here's what I learned.</description><content:encoded><![CDATA[<p>Every time I sit down to build a SharePoint provisioning engine, I hit the same dilemma: do I put everything into one big PnP template, or do I split it into many smaller ones — one per feature?</p>
<p>Both approaches work. Both have real trade-offs. And for a long time I did not have a strong opinion either way. Then I started testing with <a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/overview">Dev Proxy</a> and suddenly the answer became very clear to me.</p>
<h2 id="what-is-a-pnp-provisioning-template">What Is a PnP Provisioning Template?</h2>
<p>If you have not worked with PnP provisioning before: a PnP Provisioning Template is an XML (or JSON) file that describes the structure of a SharePoint site. It can contain:</p>
<ul>
<li><strong>Lists and libraries</strong> — custom columns, views, and settings</li>
<li><strong>Content types</strong> — site or list content types with their fields</li>
<li><strong>Pages and web parts</strong> — modern pages, hero sections, text blocks</li>
<li><strong>Navigation and branding</strong> — site navigation structure, themes, logos</li>
</ul>
<p>You apply a template to a site using the <a href="https://github.com/pnp/pnpframework">PnP Framework</a> library for .NET, which reads the file and provisions everything described in it. The library handles the order of operations, dependencies between objects, and a lot of edge cases you would rather not think about.</p>
<p>The typical entry point looks like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>web.ApplyProvisioningTemplate(template, applyInfo);
</span></span></code></pre></div><p>One call. One template. PnP handles the rest — until throttling enters the picture.</p>
<h2 id="the-monolith-approach">The Monolith Approach</h2>
<p>The simplest way to structure your provisioning is one template that contains everything. All lists, all content types, all pages, all navigation — in a single file.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>✅ Simple to manage — one file, one version, one deployment artifact</li>
<li>✅ Easy to apply — a single <code>ApplyProvisioningTemplate</code> call</li>
<li>✅ PnP handles dependencies between objects internally</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>❌ All-or-nothing retry — if anything fails, you restart from the beginning</li>
<li>❌ Long templates take a long time to apply, which means long retries too</li>
<li>❌ Hard to reason about what failed and where</li>
</ul>
<p>The all-or-nothing behavior is the killer. SharePoint throttles aggressively under load, and <code>ApplyProvisioningTemplate</code> does not pick up where it left off. Under a sustained throttle — the kind where even 15 retries at the CSOM call level are not enough to get through — the template application eventually throws a <code>MaximumRetryAttemptedException</code> — a PnP-specific exception signalling that all retry attempts were used up. At that point your outer retry logic has no choice but to start the whole template over from the very first list.</p>
<h2 id="the-modular-approach">The Modular Approach</h2>
<p>The alternative is splitting your template into multiple smaller files, each representing a logical feature or concern. For example:</p>
<ul>
<li><code>01-lists-and-libraries.xml</code></li>
<li><code>02-content-types.xml</code></li>
<li><code>03-pages.xml</code></li>
<li><code>04-navigation.xml</code></li>
</ul>
<p>You apply them in sequence, and after each one completes successfully you record that fact in memory. If a template fails, you retry only from the failed template — not from the beginning.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>✅ Granular retry — only the failed feature is re-applied</li>
<li>✅ Faster recovery from throttling</li>
<li>✅ Easier to reason about failures (&ldquo;pages failed, lists are fine&rdquo;)</li>
<li>✅ Easier to test individual features in isolation</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>❌ More files to manage and version</li>
<li>❌ Ordering matters — you need to think about dependencies</li>
<li>❌ Slightly more orchestration code on your end</li>
</ul>
<p>The trade-off is real. Modular means more moving parts. But for resilience, the payoff is significant.</p>
<h2 id="testing-with-devproxy--where-it-got-interesting">Testing With DevProxy — Where It Got Interesting</h2>
<p>I wanted to put both approaches through their paces under realistic throttling conditions. For that I used Dev Proxy with two plugins:</p>
<ul>
<li><strong>GenericRandomErrorPlugin</strong> — injects random 429 responses to simulate SharePoint throttling unpredictably</li>
<li><strong>RateLimitingPlugin</strong> — caps requests per second to trigger organic throttling at volume</li>
</ul>
<p>With these configured, I ran my provisioning engine against both template structures.</p>
<p>The result with the <strong>monolithic</strong> template: <code>PnPClientContext</code> did its job — it absorbed a lot of 429s silently and kept going. But under sustained throttling, the retries ran out, PnP threw a <code>MaximumRetryAttemptedException</code>, and my outer retry had to restart it from scratch. From list number one. Even though we were deep into page provisioning when it failed.</p>
<p>The result with <strong>modular</strong> templates: the same CSOM-level retry absorbed the same throttling. But when a sustained burst finally exhausted PnP&rsquo;s retries on the pages template, my retry loop skipped straight to <code>03-pages.xml</code> — lists and content types had already been marked done in memory and were left alone.</p>
<blockquote>
<p>This is exactly the kind of thing that looks fine in a happy-path test, but silently destroys your provisioning SLA in production.</p>
</blockquote>
<h2 id="pnpclientcontext-bump-the-retry-count-for-provisioning">PnPClientContext: Bump the Retry Count for Provisioning</h2>
<p>The PnP Framework&rsquo;s internal CSOM calls already use <code>ExecuteQueryRetry()</code> — an extension method that handles 429 throttling with exponential backoff, respecting the <code>Retry-After</code> header SharePoint returns. You get this for free with a plain <code>ClientContext</code>. The default is 10 retries.</p>
<p>If you expect heavier throttling during provisioning — and a large site with lots of lists, content types, and pages is a reasonable place to expect it — you can easily raise that limit by converting to a <code>PnPClientContext</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">using</span> var siteCtx = CSOMClientFactory.Create(provCtx.NewSiteUrl, credential);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> pnpCtx = PnPClientContext.ConvertFrom(siteCtx, retryCount: <span style="color:#ae81ff">15</span>);
</span></span></code></pre></div><p>That is the entire change. The provisioning code picks up the higher retry count automatically. Your outer retry logic only comes into play if throttling is so sustained that even 15 attempts on a single CSOM call are not enough — at which point PnP throws a <code>MaximumRetryAttemptedException</code>.</p>
<h2 id="idempotency-strategy">Idempotency Strategy</h2>
<p>The key property that makes modular templates safe to retry is idempotency — applying a template that has already been applied should not cause errors or duplicate data. PnP handles most of this for you: before creating a list, content type, or page, it checks whether it already exists.</p>
<p>A very simple example of how this looks in practice:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> source <span style="color:#66d9ef">in</span> config.Templates)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> template = LoadTemplate(source);
</span></span><span style="display:flex;"><span>    pnpCtx.Web.ApplyProvisioningTemplate(template, applyInfo);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>If this loop is re-invoked after a failure (for example by an Azure Function retry policy), templates that already completed will be re-applied — but because each template is idempotent, that is safe. The already-provisioned lists and pages are left untouched. The only cost is the time it takes to re-apply them.</p>
<p>That wasted time is the whole point. With a monolithic template, a retry means re-applying everything from scratch. With modular templates, it means re-applying a small number of already-done features before reaching the one that actually failed.</p>
<h2 id="comparison">Comparison</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>One Big Template</th>
          <th>Many Small Templates</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Retry granularity</strong></td>
          <td>Entire template restarts</td>
          <td>Only failed feature retries</td>
      </tr>
      <tr>
          <td><strong>Restart cost</strong></td>
          <td>High — full re-apply</td>
          <td>Low — one feature re-applies</td>
      </tr>
      <tr>
          <td><strong>Complexity</strong></td>
          <td>Low — one file, one call</td>
          <td>Medium — ordering, orchestration</td>
      </tr>
      <tr>
          <td><strong>Idempotency needed</strong></td>
          <td>Once per template</td>
          <td>Once per template (still needed)</td>
      </tr>
      <tr>
          <td><strong>DevProxy testability</strong></td>
          <td>Easy to test the whole flow</td>
          <td>Easier to isolate a specific feature</td>
      </tr>
      <tr>
          <td><strong>Best for</strong></td>
          <td>Simple sites, low throttle risk</td>
          <td>Complex sites, production resilience</td>
      </tr>
  </tbody>
</table>
<h2 id="verdict">Verdict</h2>
<p>Neither approach is universally correct. If you are provisioning simple sites with low template complexity and you are not worried about throttling, a monolithic template is perfectly fine and a lot less work to maintain.</p>
<p>But if you are building a production provisioning engine that needs to be resilient — where throttling is a real risk, where retries need to be efficient, and where you care about how long a failure takes to recover from — modular templates win clearly.</p>
<p>The combination that works best for me is: <strong>modular templates</strong> + <strong>PnPClientContext</strong> with conservative retry settings. PnPClientContext handles the low-level CSOM throttling so most 429s never surface at all. When they do surface (and eventually they will), modular templates ensure your retry loop does the minimum amount of work to get back on track.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The biggest takeaway for me is not even the monolith-vs-modular debate. It is that <strong>I never would have found this out without DevProxy</strong>. Happy-path testing showed no difference between the two approaches. It was only when I introduced realistic throttling that the behavior diverged in a way that actually matters.</p>
<p>If you are building any kind of SharePoint automation that makes CSOM or Graph calls under load, DevProxy should be a standard part of your testing setup. It is free, it integrates with your local development environment, and it reveals exactly the kind of subtle failure modes that only appear in production.</p>
<hr>
<h3 id="references">References</h3>
<ul>
<li><a href="https://github.com/pnp/pnpframework">PnP Framework on GitHub</a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/overview">Dev Proxy overview</a></li>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">SharePoint throttling and retry guidance</a></li>
<li><a href="https://pnp.github.io/pnpframework/">PnPClientContext — ExecuteQueryRetry docs</a></li>
</ul>
]]></content:encoded></item></channel></rss>