<?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>Microsoft Graph in C# on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/microsoft-graph/</link><description>Recent content in Microsoft Graph in C# 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/microsoft-graph/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>CSOM vs SharePoint REST vs Graph: My Pick-One Playbook</title><link>https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/</guid><description>Learn when to use CSOM, SharePoint REST or Microsoft Graph for SharePoint work, and which operations each one silently refuses to do.</description><content:encoded><![CDATA[<p>You start a SharePoint feature the sensible way. Graph first, because it&rsquo;s the modern API and because that&rsquo;s what PnP Core does by default. You write the upload. You write the metadata. You write the listing code. It works.</p>
<p>Then you hit the one field, the one property, the one checkbox Graph doesn&rsquo;t do. And you hit it on day three, with the feature already shaped around Graph.</p>
<p>Sometimes rewiring it is cheap. Sometimes it&rsquo;s a rewrite. Either way you paid.</p>
<p>I&rsquo;ve walked into that wall enough times now that I stopped calling it bad luck and started writing down which tool I reach for when. This post is that list.</p>
<h2 id="the-200-that-meant-nothing">The 200 That Meant Nothing</h2>
<p>The worst one was metadata on uploaded documents. My add-in uploads a file with Graph, then applies the library&rsquo;s columns to the resulting list item with a <code>PATCH</code> to <code>/listItem/fields</code>. Text columns, choice columns, dates, all good.</p>
<p>Then a managed metadata column.</p>
<p><code>200 OK</code>. Empty field.</p>
<p>Not a 400. Not &ldquo;column type not supported&rdquo;. Graph accepted the payload, answered like everything was fine, and wrote nothing. The <a href="https://learn.microsoft.com/en-us/graph/api/listitem-update">Learn page for updating a listItem</a> shows you <code>Color</code> and <code>Quantity</code> and never mentions which column types don&rsquo;t make it through.</p>
<p>A thrown error costs you an hour. A silent success costs you until someone notices the taxonomy column is blank on documents that were archived weeks ago, and then it costs you a data migration.</p>
<p>That&rsquo;s the real argument for having a playbook. Not elegance. The cases where the wrong choice fails quietly.</p>
<p>Be careful about which columns you write off, though. I had multi-value lookups and person columns on my &ldquo;Graph can&rsquo;t&rdquo; list for a long time, and both were wrong. They work fine, they just don&rsquo;t take the shape you&rsquo;d guess coming from CSOM, and that&rsquo;s its own post. Managed metadata is the one that genuinely has no Graph write path.</p>
<h2 id="graph-first-is-a-default-not-a-plan">Graph First Is a Default, Not a Plan</h2>
<p>To be clear, &ldquo;Graph first&rdquo; is a good instinct. PnP Core builds it in: the SDK favours Graph when reading SharePoint data and falls back to SharePoint REST when the requested properties aren&rsquo;t available there, and you can flip <code>GraphFirst</code> off if you disagree. I&rsquo;ve <a href="https://jeppe-spanggaard.dk/blogs/pnp-core-vs-pnp-framework-migration-blockers/">written before about why I&rsquo;m still on PnP.Framework</a>, and that per-operation routing is the thing I most want from PnP Core.</p>
<p>Until I have it, I&rsquo;m the router. So here&rsquo;s how I route.</p>
<h2 id="csom-owns-list-items">CSOM Owns List Items</h2>
<p>Roughly 95% of my list item CRUD is CSOM, and it&rsquo;s not nostalgia. Three capabilities keep it there.</p>
<p><strong>Batching without a hard ceiling.</strong> CSOM queues operations until you call <code>ExecuteQueryAsync()</code>, and around <a href="https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/">100 operations per batch</a> is the reliable sweet spot. Graph&rsquo;s <code>$batch</code> caps at 20 requests, and <a href="https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/">a failed batch needs picking apart</a> before you retry it. When I&rsquo;m updating 500 items, that difference is 5 round trips versus 25.</p>
<p><strong>CAML joins.</strong> One query across lists connected by lookup columns, filtered on a field two lists away, merged server-side. There&rsquo;s no Graph equivalent, and I&rsquo;ve never found a way to fake it that didn&rsquo;t end in merging rows in C#.</p>
<p><strong>A way past the list view threshold.</strong> Query a big list and CSOM throws <code>The attempted operation is prohibited because it exceeds the list view threshold</code>. There&rsquo;s a flag on <code>CamlQuery</code> that gets you through it, combined with paging and indexed columns. That one deserves its own post and it&rsquo;s on my list.</p>
<p>The rest of my CSOM reasoning is in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">the CSOM performance playbook</a>, and the join pattern has <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">its own post</a>.</p>
<h2 id="graph-owns-files">Graph Owns Files</h2>
<p>For files it flips completely. Uploading and downloading through Graph is fewer requests and noticeably faster in every project I&rsquo;ve measured it in, and large files are the clearest case: <code>createUploadSession</code> gives you a resumable, chunked upload with a pre-authenticated URL, and the chunks don&rsquo;t carry an <code>Authorization</code> header at all.</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:#75715e">// Small files: straight PUT to the content endpoint
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">size</span> <span style="color:#f92672">&lt;=</span> <span style="color:#a6e22e">SIMPLE_UPLOAD_LIMIT</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">folder</span>.<span style="color:#a6e22e">concat</span>(<span style="color:#e6db74">`:/</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">filename</span><span style="color:#e6db74">}</span><span style="color:#e6db74">:/content`</span>).<span style="color:#a6e22e">put</span>(<span style="color:#a6e22e">file</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:#75715e">// Large files: a session, then sequential chunks
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">session</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">folder</span>.<span style="color:#a6e22e">createUploadSession</span>({ <span style="color:#a6e22e">name</span>: <span style="color:#66d9ef">filename</span> });
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunk</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">chunks</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">session</span>.<span style="color:#a6e22e">resumableUpload</span>.<span style="color:#a6e22e">upload</span>(<span style="color:#a6e22e">chunk</span>.<span style="color:#a6e22e">length</span>, <span style="color:#a6e22e">chunk</span>, <span style="color:#a6e22e">contentRange</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The rules that matter: chunk sizes must be a multiple of 320 KiB, they go up sequentially, and each <code>PUT</code> extends the session expiry. Get the multiple wrong and the upload fails at the <em>last</em> chunk, which is a fun way to spend an afternoon.</p>
<p>Downloads get the same treatment - <a href="https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/">zipping a whole folder out of SharePoint</a> is a Graph job for me, not a CSOM one. Graph&rsquo;s JSON batching maps cleanly onto &ldquo;fetch these 20 files&rsquo; content&rdquo;, though matching responses back to requests has its own quirks that I covered in <a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph batching for file content</a>.</p>
<h2 id="sharepoint-rest-is-the-escape-hatch">SharePoint REST Is the Escape Hatch</h2>
<p>I almost never <em>choose</em> SharePoint REST. I end up there when it&rsquo;s the only thing that works, which turns out to be more often than the modern-API story suggests. From my current projects:</p>
<ul>
<li>Setting a navigation link to open in a new tab. It&rsquo;s a checkbox in the UI, it&rsquo;s not in the PnP provisioning schema, and it&rsquo;s not on CSOM&rsquo;s <code>NavigationNode</code>. It&rsquo;s <code>MenuState</code>/<code>SaveMenuState</code>. I have a whole post coming about that one.</li>
<li>Reading the sites a user follows: <code>/_api/social.following/my/followed(types=4)</code>.</li>
<li>Joining a hub site, activating site features, applying a site design, adding an available content type to a library. All <code>_api</code> calls in my provisioning engine.</li>
<li><code>ValidateUpdateListItem</code>, which is what rescues the metadata story from the top of this post.</li>
<li>And <code>/_api/web/ensureuser</code>, because Graph has no <code>EnsureUser</code> and no <code>/sites/{id}/users</code> endpoint at all.</li>
</ul>
<p>The pattern is consistent: the older and more SharePoint-specific the concept, the more likely REST is the only place it lives.</p>
<p><code>ValidateUpdateListItem</code> is worth knowing by name. It takes form values in SharePoint&rsquo;s own wire format - taxonomy as <code>Label|GUID</code>, people as a JSON array of claim keys - and it&rsquo;s the same payload the classic edit form posts, which is exactly why it accepts the column types Graph won&rsquo;t touch. In a C# backend it&rsquo;s a method on <code>ListItem</code> in the client library; in a browser add-in with no CSOM available, it&rsquo;s the REST endpoint. Either way, read the response: rejected fields come back with <code>HasException: true</code> inside an otherwise successful call, so you can recreate the silent-200 problem from the other direction if you don&rsquo;t look.</p>
<p>The <code>EnsureUser</code> gap is the more interesting one, because it&rsquo;s what stops person columns from being a pure Graph story. The ids in <code>ReviewersLookupId</code> are site-collection user ids from the User Information List. Not Entra object ids, not Graph user ids. If a user has never been referenced on that site, they simply have no id, and Graph gives you no way to create one. So you <code>POST /_api/web/ensureuser</code> with a <code>logonName</code> first, then write the item with Graph. Or you stay in CSOM and let <code>Web.EnsureUser()</code> plus a <code>FieldUserValue</code> do both in one place, which is what I usually do in a backend.</p>
<p>One nice asymmetry on the taxonomy side: <em>reading</em> the term store works fine over Graph (<code>/sites/{id}/termStore/sets/{id}</code>, with <code>TermStore.Read.All</code>). It&rsquo;s only writing a term onto a list item that Graph won&rsquo;t do. So my term picker is Graph and my term save is not.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>A <code>200</code> is not proof.</strong> This is the whole post in one bullet. Graph accepts unsupported column types and writes nothing. Assert on the value you wrote, at least once per column type, in a real library. Otherwise you find out at migration time.</li>
<li><strong>Mixing APIs means mixing tokens.</strong> A Graph token does not work against <code>/_api</code>. SharePoint REST wants an audience of <code>https://&lt;tenant&gt;.sharepoint.com/.default</code>, so a flow that uses both acquires two tokens and your app registration needs both sets of permissions. Plan the consent, not just the code.</li>
<li><strong>Lookups are <code>FieldNameLookupId</code>, not <code>FieldName</code>.</strong> Graph names the writable property differently from the column. Writing to the column name silently does nothing. Yes, silently.</li>
<li><strong>Switching to <code>ValidateUpdateListItem</code> needs the list item id.</strong> Drive item ids don&rsquo;t work, so <code>$select=sharepointIds</code> on the Graph item first and use <code>sharepointIds.listItemId</code>.</li>
<li><strong>It&rsquo;s <code>logonName</code>, not <code>loginName</code>.</strong> The <code>ensureuser</code> parameter is spelled the unintuitive way, some docs get it wrong, and the wrong spelling gets you an <code>InvalidClientQueryException</code> that says nothing useful.</li>
<li><strong>Chunk sizes are a multiple of 320 KiB or nothing.</strong> Graph upload sessions fail on the final commit, not on the offending chunk, so the error points at the wrong place.</li>
<li><strong>&ldquo;Unsupported&rdquo; is sometimes just undocumented.</strong> Multi-value lookups and person columns sat on my can&rsquo;t-do list for far too long. Before you route an operation to the older API, check whether the modern one only lacks a doc page.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>There&rsquo;s no winner here. CSOM, SharePoint REST and Graph are three drawers in the same toolbox, and picking per operation beats picking per project.</p>
<p>My rule of thumb: <strong>Graph for files, CSOM for list items, SharePoint REST when nothing else can do it - and verify the write whenever you cross a boundary.</strong> The failure mode that actually hurt me wasn&rsquo;t choosing the slower API. It was choosing the API that said yes and did nothing.</p>
]]></content:encoded></item><item><title>Your Add-in Gets a Free Folder in Everyone's OneDrive (Use It)</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</guid><description>Learn how to store Outlook add-in user preferences in the OneDrive App Folder via the Graph approot endpoint, so settings roam across every device.</description><content:encoded><![CDATA[<p>My Outlook add-in has a handful of user preferences: which tab to open by default, language, a &ldquo;don&rsquo;t show this tip again&rdquo; flag. Small stuff. A single JSON blob.</p>
<p>So where do you put it? <code>localStorage</code> is per-device, per-browser, and mobile WebViews evict it whenever they feel like it. A backend with a database is a lot of infrastructure for one JSON file per user. And then it hit me: every single one of my users already <em>has</em> cloud storage my add-in can reach. Their OneDrive.</p>
<h2 id="what-the-app-folder-actually-is">What the App Folder Actually Is</h2>
<p>First time your app touches <code>approot</code>, OneDrive creates a folder under <code>Apps/&lt;your app's name&gt;</code> (the name comes from your Entra app registration). It&rsquo;s the user&rsquo;s storage, visible to them in OneDrive, but sandboxed for you: request <code>Files.ReadWrite.AppFolder</code> and that folder is <em>all</em> your app can see. No scary &ldquo;this app can read all your files&rdquo; consent screen.</p>
<p>One endpoint to remember:</p>
<pre tabindex="0"><code>/me/drive/special/approot
</code></pre><h2 id="saving-settings">Saving Settings</h2>
<p>I use PnPjs (<code>@pnp/graph</code>), so a save is three lines:</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">import</span> { <span style="color:#a6e22e">SpecialFolder</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@pnp/graph/files&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">saveUserSettings</span>(<span style="color:#a6e22e">settings</span>: <span style="color:#66d9ef">UserDefinedSettings</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">void</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">upload</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">content</span>: <span style="color:#66d9ef">JSON.stringify</span>(<span style="color:#a6e22e">settings</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">filePathName</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">contentType</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No PnPjs? The raw Graph equivalent is a single PUT: <code>PUT /me/drive/special/approot:/user-settings.json:/content</code>.</p>
<h2 id="reading-settings-and-surviving-the-first-run">Reading Settings (and Surviving the First Run)</h2>
<p>Reading has one twist: the very first time a user opens your add-in, the file doesn&rsquo;t exist yet. That&rsquo;s not an error, that&rsquo;s a new user. Plan for it:</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">defaultSettings</span>: <span style="color:#66d9ef">UserDefinedSettings</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">false</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">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getUserSettings</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">UserDefinedSettings</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">children</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">children</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">fileItem</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">children</span>.<span style="color:#a6e22e">find</span>((<span style="color:#a6e22e">item</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">name</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">fileItem</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// first run - no file yet
</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">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">getItemById</span>(<span style="color:#a6e22e">fileItem</span>.<span style="color:#a6e22e">id</span><span style="color:#f92672">!</span>).<span style="color:#a6e22e">getContent</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">json</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">parse</span>(<span style="color:#66d9ef">await</span> <span style="color:#a6e22e">blob</span>.<span style="color:#a6e22e">text</span>());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">json.openPreviewInNewTab</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">openPreviewInNewTab</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">json.returnToHomeAfterArchive</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">returnToHomeAfterArchive</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">json.hasSeenPinTip</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">hasSeenPinTip</span>,
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">error</span>(<span style="color:#e6db74">&#34;Error getting user settings:&#34;</span>, <span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// any failure - the add-in still works
</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>Missing file → defaults, not an exception. New users get a working add-in, not an error toast.</li>
<li>Every field is parsed with a <code>??</code> fallback. When I ship a new setting next month, users with an old <code>user-settings.json</code> don&rsquo;t break, the new field just gets its default.</li>
<li>The whole thing is wrapped in try/catch that returns defaults. Settings are a nice-to-have; they should never take the add-in down with them.</li>
</ol>
<p>Two practical habits: load the settings once at boot and cache them (don&rsquo;t re-read OneDrive on every render), and save optimistically - update your local state immediately, fire the upload without awaiting it. And the best part comes for free: change a setting on desktop, open the phone, it&rsquo;s there. 🎁</p>
<h2 id="not-just-outlook-add-ins-spfx-too">Not Just Outlook Add-ins: SPFx Too</h2>
<p>This pattern isn&rsquo;t tied to Outlook at all, it just needs a user context and a Graph token. That makes it a great fit for <strong>SPFx web parts</strong> as well.</p>
<p>Web part properties in SPFx are per-instance and per-page, and usually something an editor configures, not the end user. If you want <em>user</em>-level preferences - a collapsed/expanded state, a preferred view, a dismissed banner - that follow the user across every page and site where your web part lives, the App Folder solves it with zero extra infrastructure. Grab <code>MSGraphClientV3</code> from the SPFx context and hit the same <code>/me/drive/special/approot:/user-settings.json:/content</code> endpoint. Same folder, same JSON file, same permission model.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Ask for <code>Files.ReadWrite.AppFolder</code>, not <code>Files.ReadWrite</code>.</strong> If the app folder is all you need, the scoped permission gets you a much friendlier consent prompt and a much smaller blast radius.</li>
<li><strong>The folder is named after your app registration&rsquo;s display name.</strong> Rename the registration and users get a <em>new</em> empty folder, your settings file stays behind in the old one.</li>
<li><strong>Users can see (and delete) the folder.</strong> It&rsquo;s their OneDrive. Treat a missing file as a normal state, always, not just on first run.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>User preferences don&rsquo;t need a database, and they deserve better than <code>localStorage</code>. The OneDrive App Folder is the middle ground that&rsquo;s easy to miss: zero infrastructure on your side, real cloud persistence on theirs, and a permission model that only exposes what your app actually needs. Outlook add-in, SPFx web part, anything with a Graph token - same trick everywhere.</p>
]]></content:encoded></item><item><title>Same Email, Different Source: Falling Back to Microsoft Graph on Outlook Mobile</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</guid><description>Learn how to fall back to the Microsoft Graph MIME endpoint when getAsFileAsync isn't available in Outlook on mobile, so your add-in keeps working everywhere.</description><content:encoded><![CDATA[<p>In <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">my last post</a> I showed how declaring a low Mailbox requirement set in the manifest got my Outlook add-in to <em>show up</em> on mobile. Great. Button&rsquo;s there, task pane opens, everything looks alive.</p>
<p>Then the user taps &ldquo;Archive to SharePoint&rdquo; and the whole feature stands on one API: <code>getAsFileAsync()</code>, which hands you the entire email as a file. That API lives in Mailbox 1.14. Outlook mobile doesn&rsquo;t have it.</p>
<p>So now I had the opposite problem from last time: instead of an invisible add-in, I had a visible add-in with a dead button. Honestly, that&rsquo;s worse.</p>
<p>Visible ≠ functional. This post is about the second half of the mobile story: getting the same email bytes through a different door.</p>
<h2 id="the-desktop-path-just-ask-outlook">The Desktop Path: Just Ask Outlook</h2>
<p>On desktop and web, Office.js does all the work. You ask the host for the current message as a file, and it hands you the EML as base64. No network call, no token, no permissions dance, Outlook already <em>has</em> the email.</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">function</span> <span style="color:#a6e22e">getMessageAsBlob</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Promise</span>((<span style="color:#a6e22e">resolve</span>, <span style="color:#a6e22e">reject</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.14&#34;</span>)) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;This client does not support Mailbox 1.14 (getAsFileAsync).&#34;</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">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">getAsFileAsync</span>((<span style="color:#a6e22e">asyncResult</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">AsyncResultStatus</span>.<span style="color:#a6e22e">Failed</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">error</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:#75715e">// getAsFileAsync returns the EML as base64
</span></span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Uint8Array</span>.<span style="color:#66d9ef">from</span>(<span style="color:#a6e22e">atob</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">value</span>), (<span style="color:#a6e22e">c</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">charCodeAt</span>(<span style="color:#ae81ff">0</span>));
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">bytes</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> });
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Notice the first thing this function does: check <code>isSetSupported(&quot;Mailbox&quot;, &quot;1.14&quot;)</code> and reject if it&rsquo;s not there. That reject is not an error case, it&rsquo;s a <em>signal</em>. Remember it, it becomes important in a minute.</p>
<h2 id="the-graph-path-ask-exchange-instead">The Graph Path: Ask Exchange Instead</h2>
<p>Here&rsquo;s the realization that saved the mobile experience: <strong>Outlook doesn&rsquo;t own your email, Exchange does.</strong> The host app is just one way to get at it. Microsoft Graph is another, and Graph has an endpoint that returns the raw MIME content of any message:</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">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">item</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">itemId</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;No item ID available for Graph-based archive.&#34;</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">const</span> <span style="color:#a6e22e">token</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getToken</span>([<span style="color:#e6db74">&#34;Mail.ReadWrite.Shared&#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">mimeUrl</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`https://graph.microsoft.com/v1.0/me/messages/</span><span style="color:#e6db74">${</span>encodeURIComponent(<span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">itemId</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/$value`</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">resp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">mimeUrl</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;GET&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">Authorization</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</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">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">ok</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">`Error </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">status</span><span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">statusText</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</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">const</span> <span style="color:#a6e22e">buffer</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">arrayBuffer</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Uint8Array</span>(<span style="color:#a6e22e">buffer</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">buffer</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> { <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</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>item.itemId</code> is all we need from Office.js, and reading it only requires <strong>Mailbox 1.1</strong>. That&rsquo;s the beautiful part: the manifest floor from the last post promised almost nothing, and this fallback only <em>needs</em> almost nothing.</li>
<li>The <code>$value</code> segment on <code>/me/messages/{id}</code> tells Graph to skip the JSON representation and return the raw RFC 822 message, the same EML you&rsquo;d get from <code>getAsFileAsync</code>.</li>
<li>We wrap it in a <code>Blob</code> with <code>message/rfc822</code>, the exact same shape the desktop path produces. Same email, different source.</li>
<li>The token comes from MSAL with the <code>Mail.ReadWrite.Shared</code> scope. And yes, getting a token <em>inside Outlook mobile</em> is its own adventure, the magic words are Nested App Auth (NAA), where sign-in goes through the Microsoft Authenticator app instead of a browser popup. That one deserves its own post someday.</li>
</ol>
<p>One honest caveat: I pass <code>item.itemId</code> straight to Graph without converting it. That works because modern hosts (including mobile) hand out REST-format IDs. If your add-in also runs in older Outlook clients that still produce EWS-format IDs, run the ID through <code>Office.context.mailbox.convertToRestId()</code> first, or Graph will give you a very confusing 404.</p>
<h2 id="dont-branch-fall-back">Don&rsquo;t Branch, Fall Back</h2>
<p>So we have two functions. The tempting way to pick between them is <code>if (isMobile) { ... } else { ... }</code>. I did it differently, and I&rsquo;m glad I did:</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">let</span> <span style="color:#a6e22e">messageAsBlob</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlob</span>();
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Office.js getAsFileAsync unavailable (e.g. mobile) - fall back to Graph
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">getMessageAsBlobViaGraph</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>();
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;Email export is not available on this platform.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Always try Office.js first, and let the failure route you to Graph.</strong> Remember that reject on the 1.14 check? On mobile it fires every time, and the catch block quietly takes the Graph road instead.</p>
<p>Why is this better than platform-branching? Because it&rsquo;s <em>capability</em>-based, not <em>platform</em>-based. If some desktop client out there is running an older Outlook without 1.14, it gets the fallback for free. I never had to predict which platforms are broken, the code just asks &ldquo;did the good path work?&rdquo; and moves on.</p>
<p>I do add one belt-and-suspenders detail: the Graph function is only injected into this flow when platform detection says we&rsquo;re on mobile. On desktop it&rsquo;s <code>undefined</code>, so a genuinely broken desktop fails loudly with a clear message instead of silently making Graph calls I didn&rsquo;t expect.</p>
<h2 id="same-blob-same-pipeline">Same Blob, Same Pipeline</h2>
<p>This is the part I want you to steal. Both functions return the same thing:</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:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }  <span style="color:#75715e">// type: &#34;message/rfc822&#34;
</span></span></span></code></pre></div><p>Everything after this point - naming the file, uploading it to SharePoint, progress reporting, conflict handling - is <strong>one code path</strong>. The upload logic has no idea whether the bytes came from Office.js or from a Graph call. There&rsquo;s no <code>if (isMobile)</code> sprinkled through the upload code, no duplicated pipeline, nothing.</p>
<p>That&rsquo;s the whole trick, really. A good fallback isn&rsquo;t a second feature, it&rsquo;s a second <em>source</em> feeding the same feature. The moment your fallback needs its own downstream handling, you&rsquo;ve built two features and doubled your bugs.</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>ID formats will bite you.</strong> REST IDs and EWS IDs look similar enough (long base64-ish strings) that you won&rsquo;t spot the difference by eye. If Graph returns 404 for a message you&rsquo;re literally looking at, check the ID format before questioning your sanity. <code>convertToRestId()</code> is the fix.</li>
<li><strong>Graph only knows what the server knows.</strong> <code>getAsFileAsync</code> reads from the host, Graph reads from Exchange. For a message that <em>just</em> arrived, the server side can lag a beat behind what Outlook is already showing you. Rare, but real.</li>
<li><strong>No retry for free.</strong> Office.js calls fail locally and instantly. The Graph call is a network request that can hit throttling or transient errors, and a bare <code>fetch</code> won&rsquo;t retry anything. Mine throws on non-OK responses and lets the surrounding archive flow surface the error; depending on your feature, a retry with backoff might be worth it.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">manifest post</a> got the add-in through the door on mobile. This post made it actually earn its place there: use Office.js when the host can deliver, fall back to Graph when it can&rsquo;t, and make both paths hand over identical bytes so the rest of your app never has to care.</p>
<p>Outlook is just one door to the mailbox. When it&rsquo;s locked, Graph is around the back. 🚪</p>
]]></content:encoded></item><item><title>SharePoint News Links via Graph SDK: Filling in the Gaps the Docs Left Behind</title><link>https://jeppe-spanggaard.dk/blogs/graph-beta-sdk-news-link-with-banner-image/</link><pubDate>Tue, 03 Mar 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-beta-sdk-news-link-with-banner-image/</guid><description>Learn how to create SharePoint News Link pages with a banner image using the Microsoft Graph Beta SDK in C# — including the multipart upload the official documentation leaves completely undocumented.</description><content:encoded><![CDATA[<h2 id="the-problem-with-the-documentation">The Problem With the Documentation</h2>
<p>News Link pages in SharePoint — those cards that link to external news articles — are only available through the Graph API&rsquo;s beta endpoint. That&rsquo;s fine, beta APIs are part of life.</p>
<p>What&rsquo;s <em>not</em> fine is that Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/graph/api/newslinkpage-create?view=graph-rest-beta">official documentation</a> covers the simple case well, but the moment you want to add a banner image, the C# code snippet disappears and is replaced with:</p>
<p><strong>&ldquo;Snippet not available.&rdquo;</strong></p>
<p>Great. Thanks.</p>
<p>The banner image upload requires a multipart request. Figuring out how to do that with the Graph Beta SDK means piecing together documentation about <code>MultipartBody</code>, some Kiota internals, and a few quirks you&rsquo;ll only discover by actually trying it.</p>
<p>This post covers the full working solution.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Install these two NuGet packages:</p>
<pre tabindex="0"><code>Microsoft.Graph.Beta
Microsoft.Kiota.Serialization.Multipart
</code></pre><p><code>Microsoft.Graph.Beta</code> is the Beta SDK — it contains <code>NewsLinkPage</code> and all the beta models. <code>Microsoft.Kiota.Serialization.Multipart</code> provides the <code>MultipartBody</code> class needed to construct multipart requests. Without it, you don&rsquo;t have the types to send binary data alongside the JSON payload.</p>
<h2 id="the-simple-case-no-banner-image">The Simple Case: No Banner Image</h2>
<p>If you just want a News Link without a banner image, the Beta SDK fluent API handles it cleanly:</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> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>    Title = <span style="color:#e6db74">&#34;Contoso Unveils First Self-Driving Car&#34;</span>,
</span></span><span style="display:flex;"><span>    NewsWebUrl = <span style="color:#e6db74">&#34;https://someexternalnewssite.com/article&#34;</span>,
</span></span><span style="display:flex;"><span>    Description = <span style="color:#e6db74">&#34;A brief description of the article.&#34;</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> result = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages.PostAsync(page, requestConfiguration =&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    requestConfiguration.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>Two things to note:</p>
<p>The <code>Prefer: include-unknown-enum-members</code> header is required. Without it, the API won&rsquo;t return <code>newsLink</code> as a valid <code>pageLayoutType</code> value — it&rsquo;s an evolvable enum that hasn&rsquo;t been promoted to v1.0 yet, so Graph treats it as unknown by default.</p>
<p>The page is also created as a draft. You still need to publish it separately before it shows up in the news feed.</p>
<h2 id="the-full-solution-with-banner-image">The Full Solution: With Banner Image</h2>
<p>Here&rsquo;s the complete implementation including banner image upload and publishing:</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">internal</span> <span style="color:#66d9ef">async</span> Task CreateNewsLink(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> siteId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> title,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> url,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> description,
</span></span><span style="display:flex;"><span>    Stream? bannerImageContent)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>        Title = title,
</span></span><span style="display:flex;"><span>        NewsWebUrl = url,
</span></span><span style="display:flex;"><span>        Description = description,
</span></span><span style="display:flex;"><span>        AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">            [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</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">var</span> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>    multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>    multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>        UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages&#34;</span>
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    requestInfo.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>    requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, <span style="color:#e6db74">&#34;multipart/form-data&#34;</span>, multipartBody);
</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> GraphClient.RequestAdapter.SendAsync&lt;NewsLinkPage&gt;(
</span></span><span style="display:flex;"><span>        requestInfo,
</span></span><span style="display:flex;"><span>        NewsLinkPage.CreateFromDiscriminatorValue
</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> createdNewslink = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages[response.Id].GetAsync(
</span></span><span style="display:flex;"><span>        requestConfiguration =&gt;
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            requestConfiguration.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</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">var</span> publishRequestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>        UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish&#34;</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> GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It looks like a lot, but each part has a specific reason for being there. Let me explain the non-obvious bits.</p>
<h2 id="breaking-down-the-code">Breaking Down the Code</h2>
<h3 id="the-microsoftgraphbannerimageweburlcontent-annotation">The <code>@microsoft.graph.bannerImageWebUrlContent</code> Annotation</h3>
<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>AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is the glue between the JSON part and the image bytes. The value <code>&quot;name:content&quot;</code> tells the Graph API: <em>&ldquo;find the image bytes in the multipart part named &lsquo;content&rsquo;&rdquo;</em>.</p>
<p>When the API processes the request, it reads this annotation, locates the <code>content</code> part in the multipart body, saves the image to the site&rsquo;s assets library, and sets the <code>bannerImageWebUrl</code> property on the created page. The naming has to match — the part you add with <code>AddOrReplacePart(&quot;content&quot;, ...)</code> is what the annotation references.</p>
<h3 id="why-multipartbody-instead-of-the-fluent-api">Why <code>MultipartBody</code> Instead of the Fluent API</h3>
<p>The normal fluent API — <code>GraphClient.Sites[siteId].Pages.PostAsync(...)</code> — only supports JSON payloads. There&rsquo;s no overload that accepts binary data or constructs a multipart request.</p>
<p><code>MultipartBody</code> fills that gap. You compose the request from named parts, each with their own content type:</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> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span></code></pre></div><p>The <code>&quot;request&quot;</code> part carries the JSON, the <code>&quot;content&quot;</code> part carries the image bytes. The names are what you reference in <code>@microsoft.graph.bannerImageWebUrlContent</code>.</p>
<h3 id="why-requestinformation-directly">Why <code>RequestInformation</code> Directly</h3>
<p>Since the fluent API can&rsquo;t send multipart requests, we drop down one level to <code>RequestInformation</code> — the underlying request abstraction that all Kiota-generated clients use internally:</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> requestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>    UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, <span style="color:#e6db74">&#34;multipart/form-data&#34;</span>, multipartBody);
</span></span></code></pre></div><p><code>SetContentFromParsable</code> serializes the <code>MultipartBody</code> and sets the correct <code>Content-Type</code> header — including the boundary parameter that multipart requests require. This is one of those things you have to discover by reading the Kiota source code rather than the docs.</p>
<h3 id="the-extra-get-after-creation">The Extra GET After Creation</h3>
<p>You might notice the code fetches the page again right after creating it:</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> createdNewslink = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages[response.Id].GetAsync(...);
</span></span></code></pre></div><p>This is a quirk of the multipart POST. The response from a raw <code>RequestInformation</code>-based call doesn&rsquo;t go through the same deserialization pipeline as the fluent API, which means the returned <code>NewsLinkPage</code> object isn&rsquo;t fully populated. Rather than fighting with it, a quick GET on the newly created page — with the proper <code>prefer</code> header — gives you a cleanly deserialized object with the correct <code>Id</code> to use for publishing.</p>
<h2 id="publishing-the-news-link">Publishing the News Link</h2>
<p>Pages are created as drafts. To make the News Link appear in the SharePoint news feed, you have to explicitly publish it:</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> publishRequestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>    UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish&#34;</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> GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo);
</span></span></code></pre></div><p>Again, <code>RequestInformation</code> directly — the Graph Beta SDK&rsquo;s fluent API doesn&rsquo;t expose a typed publish method for <code>newsLinkPage</code>.</p>
<h2 id="what-about-the-v10-sdk">What About the v1.0 SDK?</h2>
<p>Not supported yet. The <code>NewsLinkPage</code> type, the <code>pageLayout: newsLink</code> enum value, and the publish endpoint are all beta-only. When the API graduates to v1.0, the approach should be almost identical — just swap <code>Microsoft.Graph.Beta</code> for <code>Microsoft.Graph</code>.</p>
<p>Until then, you&rsquo;re on beta. Microsoft officially cautions against using beta APIs in production, but in practice this particular API has been stable for a while. Use your own judgment.</p>
]]></content:encoded></item><item><title>Stop Spamming Your Users: Create Microsoft 365 Groups Without Welcome Emails</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</link><pubDate>Sat, 14 Feb 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</guid><description>Learn how to create Microsoft 365 groups programmatically with the Graph SDK in C# without sending a welcome email flood to every member.</description><content:encoded><![CDATA[<h2 id="the-problem">The Problem</h2>
<p>When building automated site provisioning, every Microsoft 365 group creation triggers a welcome email to each member by default. In an automated scenario where multiple groups are created at once, that quickly turns into a flood of emails your users didn&rsquo;t ask for — and they&rsquo;re going to call it spam.</p>
<p>Microsoft <a href="https://learn.microsoft.com/en-us/graph/group-set-options">documents</a> <code>WelcomeEmailDisabled</code> as a supported option, but doesn&rsquo;t show you how to actually set it from the Graph SDK in C#. That&rsquo;s what this post is about.</p>
<h2 id="the-weird-part-additionaldata">The Weird Part: AdditionalData</h2>
<p>If you look at the typed <code>Group</code> object in the SDK, you won&rsquo;t find a <code>ResourceBehaviorOptions</code> property anywhere. It has to be set through <code>AdditionalData</code> — a catch-all dictionary for properties that exist in the Graph API but aren&rsquo;t modeled as first-class typed properties in the SDK.</p>
<p>It works fine, but it does mean you lose IntelliSense and compile-time safety. The same pattern applies to <code>owners@odata.bind</code> and <code>members@odata.bind</code>, which let you assign owners and members at creation time without separate follow-up calls.</p>
<h2 id="the-solution">The Solution</h2>
<p>Here&rsquo;s the complete group creation request with welcome emails disabled, owners set, and members added — all in a single API 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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ownerId = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{ownerObjectId}&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> groupMembers = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId1}&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId2}&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group requestBody = <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Description = description,
</span></span><span style="display:flex;"><span>    DisplayName = displayName,
</span></span><span style="display:flex;"><span>    GroupTypes = [<span style="color:#e6db74">&#34;Unified&#34;</span>],
</span></span><span style="display:flex;"><span>    MailEnabled = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    MailNickname = siteName,
</span></span><span style="display:flex;"><span>    SecurityEnabled = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span> } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;owners@odata.bind&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { ownerId } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;members@odata.bind&#34;</span>, groupMembers }
</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>Group? <span style="color:#66d9ef">group</span> = <span style="color:#66d9ef">await</span> _graphClient.Groups.PostAsync(requestBody);
</span></span></code></pre></div><h3 id="resourcebehavioroptions">resourceBehaviorOptions</h3>
<p><code>resourceBehaviorOptions</code> controls specific group behaviors at creation. <code>WelcomeEmailDisabled</code> simply stops Microsoft 365 from sending the welcome email to members.</p>
<p>You can also combine multiple options in the same list if needed:</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-cs" data-lang="cs"><span style="display:flex;"><span>{ <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span>, <span style="color:#e6db74">&#34;HideGroupInOutlook&#34;</span> } }
</span></span></code></pre></div><p>Other supported values are documented <a href="https://learn.microsoft.com/en-us/graph/group-set-options">here</a>.</p>
<h3 id="and"><a href="mailto:owners@odata.bind">owners@odata.bind</a> and <a href="mailto:members@odata.bind">members@odata.bind</a></h3>
<p>The <code>@odata.bind</code> syntax binds users to the group by their full resource URL at creation time, so you don&rsquo;t need separate <code>POST /groups/{id}/members</code> calls afterward. The URL format must be the full Graph v1.0 path:</p>
<pre tabindex="0"><code>https://graph.microsoft.com/v1.0/users/{objectId}
</code></pre>]]></content:encoded></item><item><title>Stop Retrying Everything: Smart Graph Batch Retry Logic</title><link>https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/</link><pubDate>Mon, 22 Sep 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/</guid><description>Learn smart retry logic for Microsoft Graph batching to optimize API calls, reduce throttling, and enhance user experience.</description><content:encoded><![CDATA[<h2 id="the-day-my-batch-requests-started-fighting-back">The Day My Batch Requests Started Fighting Back</h2>
<p>Picture this: It&rsquo;s 2 AM, you&rsquo;re on your third cup of coffee, and you&rsquo;re watching your perfectly crafted Microsoft Graph batch request fail spectacularly. Again.</p>
<p>You&rsquo;ve got 25 files to download from SharePoint. Your batch processes 24 of them perfectly, then one lonely file decides to throw a throttling tantrum. What does your retry logic do? It throws away all 24 successful downloads and starts over. From scratch. Like a digital Groundhog Day, but less amusing and more soul-crushing.</p>
<p>Sound familiar? Welcome to the &ldquo;retry everything&rdquo; club – where perfectly good API calls go to die unnecessarily. 😅</p>
<h2 id="the-grocery-cart-problem-or-why-were-doing-this-wrong">The Grocery Cart Problem (Or: Why We&rsquo;re Doing This Wrong)</h2>
<p>Let me paint you a picture. You&rsquo;re at the grocery store with a cart full of 20 items. You get to checkout, and the cashier says, &ldquo;Sorry, we&rsquo;re out of milk.&rdquo;</p>
<p>What would you do?</p>
<ul>
<li><strong>Option A:</strong> Put back everything, go home, and come back later to shop for all 20 items again</li>
<li><strong>Option B:</strong> Buy the 19 items you can get, then come back just for the milk</li>
</ul>
<p>If you picked Option A, congratulations – you think like most API retry logic! If you picked Option B, you&rsquo;re ready to learn about smart retries.</p>
<p><strong>The &ldquo;retry everything&rdquo; approach is like Option A, and here&rsquo;s why it&rsquo;s bonkers:</strong></p>
<ul>
<li>🔄 <strong>Wasted effort</strong>: You&rsquo;re re-requesting stuff that already worked perfectly</li>
<li>🐌 <strong>Slower performance</strong>: Users wait longer while you redo successful work</li>
<li>📈 <strong>Throttling amplification</strong>: You&rsquo;re actually making the problem worse by hitting successful endpoints again</li>
<li>🔍 <strong>Poor debugging</strong>: Can&rsquo;t easily identify which specific requests are the real troublemakers</li>
</ul>
<p>I learned this the hard way when I watched a simple file sync turn into an API call avalanche. 20 requests became 40, then 80, then&hellip; well, let&rsquo;s just say Microsoft&rsquo;s throttling system got very acquainted with my application.</p>
<h2 id="the-aha-moment-its-simpler-than-you-think">The &ldquo;Aha!&rdquo; Moment (It&rsquo;s Simpler Than You Think)</h2>
<p>The solution hit me during one of those 2 AM debugging sessions: <strong>What if we only retry the stuff that actually failed?</strong></p>
<p>Revolutionary, right? 😏</p>
<p>Here&rsquo;s the beautiful thing – this isn&rsquo;t some PhD-level computer science. It&rsquo;s just common sense applied to code. Keep the winners, retry the losers. Simple.</p>
<p>But (there&rsquo;s always a &ldquo;but&rdquo;), there&rsquo;s one sneaky technical challenge that makes this trickier than it sounds. Microsoft&rsquo;s Graph SDK has a helpful method called <code>NewBatchWithFailedRequests()</code>, but it has a quirk: it generates brand new request IDs. This breaks your ability to map responses back to your original data.</p>
<p>Think of it like this: You order pizza for table 5, but when they bring the replacement slice, they call it table 23. Good luck figuring out who ordered what!</p>
<p>If you&rsquo;re new to Graph batching or request mapping, I&rsquo;d recommend checking out my post on <a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph Batching for File Content: Mapping Requests to Responses</a> first. It&rsquo;s like the prequel to this story – explains how to keep track of what&rsquo;s what when dealing with batch responses.</p>
<h2 id="quick-win-summary-for-the-impatient-developers">Quick Win Summary (For the Impatient Developers)</h2>
<p><strong>The Problem:</strong> Your retry logic is like that friend who starts the entire conversation over when they missed one word. Inefficient and annoying.</p>
<p><strong>The Solution:</strong> A drop-in extension method that only retries the actual failures while keeping successful responses safe and sound.</p>
<p><strong>The Payoff:</strong></p>
<ul>
<li>⚡ Faster operations (no more re-downloading working files)</li>
<li>📉 Fewer API calls (your rate limits will thank you)</li>
<li>🎯 Less throttling (stop beating dead endpoints)</li>
<li>😌 Happier users (and happier you at 2 AM)</li>
</ul>
<p><strong>The Catch:</strong> You need to understand request-to-response mapping. Don&rsquo;t worry, it&rsquo;s not rocket science, and I&rsquo;ve got a whole post about it.</p>
<p><strong>Time Investment:</strong> About 5 minutes to implement, countless hours of frustration saved.</p>
<p>Ready for the nitty-gritty? Let&rsquo;s dive in! 👇</p>
<h2 id="the-hero-of-our-story-the-smart-retry-extension">The Hero of Our Story: The Smart Retry Extension</h2>
<p>Okay, here&rsquo;s where we get our hands dirty. The main challenge isn&rsquo;t just filtering out successful requests – it&rsquo;s that pesky <code>NewBatchWithFailedRequests</code> method that scrambles your request IDs like eggs at Sunday brunch.</p>
<p>Here&rsquo;s the extension method that saves the day:</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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">GraphServiceClientExtensions</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;(IReadOnlyDictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; Statuses, Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; BatchResponse)&gt; 
</span></span><span style="display:flex;"><span>        PostBatchWithFailedDependencyRetriesAsync(<span style="color:#66d9ef">this</span> GraphServiceClient graphClient, BatchRequestContentCollection originalBatch) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">int</span> maxRetries = <span style="color:#ae81ff">5</span>;
</span></span><span style="display:flex;"><span>        TimeSpan delay = TimeSpan.FromSeconds(<span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; allResponses = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt;();
</span></span><span style="display:flex;"><span>        Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; allStatuses = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection batchToSend = originalBatch;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> attempt = <span style="color:#ae81ff">1</span>; attempt &lt;= maxRetries; attempt++) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            BatchResponseContentCollection batchResponse = <span style="color:#66d9ef">await</span> graphClient.Batch.PostAsync(batchToSend);
</span></span><span style="display:flex;"><span>            Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesStatusCodesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Filter out failures (excluding redirects which are normal for file content)</span>
</span></span><span style="display:flex;"><span>            Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; failedRequests = responses
</span></span><span style="display:flex;"><span>                .Where(kvp =&gt; !BatchResponseContent.IsSuccessStatusCode(kvp.Value) &amp;&amp; kvp.Value != HttpStatusCode.Found)
</span></span><span style="display:flex;"><span>                .ToDictionary(kvp =&gt; kvp.Key, kvp =&gt; kvp.Value);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Collect all responses from this attempt</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> responses) 
</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> batchResponse.GetResponseByIdAsync(kvp.Key);
</span></span><span style="display:flex;"><span>                allResponses[kvp.Key] = response;
</span></span><span style="display:flex;"><span>                allStatuses[kvp.Key] = kvp.Value;
</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">if</span> (failedRequests.Count == <span style="color:#ae81ff">0</span> || attempt == maxRetries) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</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> Task.Delay(delay);
</span></span><span style="display:flex;"><span>            delay = TimeSpan.FromSeconds(delay.TotalSeconds * <span style="color:#ae81ff">2</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// The key problem: NewBatchWithFailedRequests creates new request IDs!</span>
</span></span><span style="display:flex;"><span>            batchToSend = batchToSend.NewBatchWithFailedRequests(responses);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// This is why we need this method - restore the original request IDs</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> RestoreOriginalRequestIdsAsync(batchToSend, originalBatch);
</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">return</span> (allStatuses, allResponses);
</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">async</span> Task RestoreOriginalRequestIdsAsync(
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection newBatch, 
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection originalBatch) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> stepsSnapshot = newBatch.BatchRequestSteps.ToArray();
</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> kvp <span style="color:#66d9ef">in</span> stepsSnapshot) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> oldStepId = kvp.Key;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> step = kvp.Value;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> requestPath = step.Request.RequestUri!.AbsolutePath;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Find the original request ID by matching the request path</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> matchingOriginal = originalBatch.BatchRequestSteps
</span></span><span style="display:flex;"><span>                .First(x =&gt; x.Value.Request.RequestUri!.AbsolutePath == requestPath);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> originalStepId = matchingOriginal.Key;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (oldStepId != originalStepId) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                newBatch.RemoveBatchRequestStepWithId(oldStepId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> newStep = <span style="color:#66d9ef">new</span> BatchRequestStep(
</span></span><span style="display:flex;"><span>                    requestId: originalStepId,
</span></span><span style="display:flex;"><span>                    httpRequestMessage: step.Request,
</span></span><span style="display:flex;"><span>                    dependsOn: step.DependsOn);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                newBatch.AddBatchRequestStep(newStep);
</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></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong> Think of it as a diplomatic negotiator for your API calls:</p>
<ol>
<li><strong>The ID Shuffle Problem</strong>: <code>NewBatchWithFailedRequests</code> gives failed requests shiny new IDs, like witness protection for HTTP requests</li>
<li><strong>The Detective Work</strong>: <code>RestoreOriginalRequestIdsAsync</code> plays detective, matching requests by their paths to find their original identities</li>
<li><strong>The Happy Reunion</strong>: Failed requests get their original IDs back, so your mapping dictionary doesn&rsquo;t break down in tears</li>
</ol>
<p>It&rsquo;s like having a really good wedding planner who makes sure everyone sits at the right table, even after the venue changes.</p>
<h2 id="showtime-watching-smart-retries-in-action">Showtime: Watching Smart Retries in Action</h2>
<p>Now let&rsquo;s see our smart retry logic work its magic in a real-world scenario. Imagine you&rsquo;re building a document sync tool and need to download 25 files from SharePoint. Some will work perfectly, others might throw tantrums due to throttling or network hiccups.</p>
<p>Here&rsquo;s how the new approach handles it like a champ:</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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#75715e">// Let&#39;s say you need to download content from 25 SharePoint files</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> batch = <span style="color:#66d9ef">new</span> BatchRequestContentCollection(graphClient);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> fileMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileContentRequest&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Build the batch for file content downloads</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> filesToDownload = <span style="color:#66d9ef">await</span> GetFilesToProcess(); <span style="color:#75715e">// Your method to get file list</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> file <span style="color:#66d9ef">in</span> filesToDownload) 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestInfo = graphClient.Sites[siteId]
</span></span><span style="display:flex;"><span>                                .Drives[driveId]
</span></span><span style="display:flex;"><span>                                .Items[file.DriveItemId]
</span></span><span style="display:flex;"><span>                                .Content
</span></span><span style="display:flex;"><span>                                .ToGetRequestInformation();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestId = <span style="color:#66d9ef">await</span> batch.AddBatchRequestStepAsync(requestInfo);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Map the request ID to your file info (same pattern as previous post)</span>
</span></span><span style="display:flex;"><span>    fileMapping[requestId] = <span style="color:#66d9ef">new</span> FileContentRequest 
</span></span><span style="display:flex;"><span>    { 
</span></span><span style="display:flex;"><span>        DriveItemId = file.DriveItemId,
</span></span><span style="display:flex;"><span>        FileName = file.Name,
</span></span><span style="display:flex;"><span>        ExpectedSize = file.Size,
</span></span><span style="display:flex;"><span>        DownloadStartTime = DateTime.Now
</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:#75715e">// 🎯 Here&#39;s where the magic happens - just one line change!</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> (statuses, responses) = <span style="color:#66d9ef">await</span> graphClient.PostBatchWithFailedDependencyRetriesAsync(batch);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Process results - this is where the retry really shines</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> successfulDownloads = <span style="color:#66d9ef">new</span> List&lt;FileDownloadResult&gt;();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> failedDownloads = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;();
</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> kvp <span style="color:#66d9ef">in</span> fileMapping) 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestId = kvp.Key;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> fileRequest = kvp.Value;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (responses.TryGetValue(requestId, <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> response)) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> statusCode = statuses[requestId];
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (BatchResponseContent.IsSuccessStatusCode(statusCode)) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Success! Handle the file content</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> contentBytes = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Save to your desired location</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> localPath = Path.Combine(downloadFolder, fileRequest.FileName);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> File.WriteAllBytesAsync(localPath, contentBytes);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            successfulDownloads.Add(<span style="color:#66d9ef">new</span> FileDownloadResult 
</span></span><span style="display:flex;"><span>            { 
</span></span><span style="display:flex;"><span>                FileName = fileRequest.FileName,
</span></span><span style="display:flex;"><span>                LocalPath = localPath,
</span></span><span style="display:flex;"><span>                ActualSize = contentBytes.Length,
</span></span><span style="display:flex;"><span>                ExpectedSize = fileRequest.ExpectedSize,
</span></span><span style="display:flex;"><span>                DownloadTime = DateTime.Now - fileRequest.DownloadStartTime
</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">else</span> 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Even after smart retries, this file failed</span>
</span></span><span style="display:flex;"><span>            failedDownloads.Add(<span style="color:#e6db74">$&#34;{fileRequest.FileName} ({statusCode})&#34;</span>);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Log the specific failure for debugging</span>
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Failed to download {fileRequest.FileName}: {statusCode}&#34;</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:#75715e">// Always clean up the response</span>
</span></span><span style="display:flex;"><span>        response.Dispose();
</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>Console.WriteLine(<span style="color:#e6db74">$&#34;Successfully downloaded: {successfulDownloads.Count} files&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (failedDownloads.Any())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;Failed downloads: {string.Join(&#34;</span>, <span style="color:#e6db74">&#34;, failedDownloads)}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>The beautiful part?</strong> Look at that line with the magic emoji 🎯. That&rsquo;s literally the only change you need to make to your existing batch processing code. Everything else stays exactly the same.</p>
<p><strong>Here&rsquo;s what&rsquo;s happening behind the scenes:</strong></p>
<ol>
<li><strong>First batch attempt</strong>: Say 20 files succeed, 5 fail due to throttling</li>
<li><strong>Smart filtering</strong>: Keep those 20 successful responses safe</li>
<li><strong>Targeted retry</strong>: Build a new batch with just the 5 failures</li>
<li><strong>ID preservation</strong>: Make sure those 5 retries still map to your original file info</li>
<li><strong>Rinse and repeat</strong>: Maybe 4 of the 5 succeed on retry, leaving just 1 persistent troublemaker</li>
</ol>
<p><strong>The result?</strong> Instead of making 250 API calls (25 files × 5 retry attempts for the unlucky ones), you might only make 35 total calls. Your throttling problems become manageable, and files download way faster.</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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#75715e">// Supporting classes for the example above</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileContentRequest</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> DriveItemId { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ExpectedSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> DateTime DownloadStartTime { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</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">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileDownloadResult</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> LocalPath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ActualSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ExpectedSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> TimeSpan DownloadTime { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-method-to-the-madness-whats-really-happening">The Method to the Madness (What&rsquo;s Really Happening)</h2>
<p>I know that extension method looks intimidating – like trying to read assembly instructions in a foreign language. But once you break it down, it&rsquo;s actually pretty logical. Let me walk you through the step-by-step dance:</p>
<p><strong>Step 1: The First Attempt</strong>
&ldquo;Let&rsquo;s try everything once and see what happens&rdquo;</p>
<p>Sends your original batch of 25 files and carefully captures every single response and status code. No throwing anything away yet.</p>
<p><strong>Step 2: The Great Sorting</strong>
&ldquo;Okay, who succeeded and who&rsquo;s being difficult?&rdquo;</p>
<p>Separates the winners from the losers, but (and this is important) ignores redirect responses. Why? Because when you&rsquo;re downloading large files, redirects are totally normal – SharePoint often redirects you to the actual storage location.</p>
<p><strong>Step 3: The Preservation Society</strong>
&ldquo;Keep the good stuff safe while we deal with the troublemakers&rdquo;</p>
<p>All successful responses get stored in a safe place while we build a new, smaller batch containing only the failed requests. It&rsquo;s like having a really good filing system for your API responses.</p>
<p><strong>Step 4: The Identity Crisis Resolution</strong>
&ldquo;Wait, who are you again? Let me check your original ID&hellip;&rdquo;</p>
<p>This is the tricky bit! The <code>NewBatchWithFailedRequests</code> method gives everyone new IDs, like a witness protection program for HTTP requests. Our <code>RestoreOriginalRequestIdsAsync</code> method plays detective, matching requests by their URL paths to restore their original identities.</p>
<p><strong>Step 5: The Polite Wait</strong>
&ldquo;Let&rsquo;s not be pushy – maybe try again in a second?&rdquo;</p>
<p>Implements <a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/retry">exponential backoff</a> – starts with a 1-second wait, then 2 seconds, then 4 seconds, etc. This prevents your app from being that annoying person who keeps knocking on the door every second.</p>
<p><strong>Step 6: The Safety Net</strong>
&ldquo;Okay, we tried 5 times. Some files just aren&rsquo;t meant to be downloaded today.&rdquo;</p>
<p>Gives up after 5 attempts to prevent infinite retry loops. Because sometimes you need to know when to walk away from the poker table.</p>
<h2 id="why-this-actually-works-the-science-behind-the-magic">Why This Actually Works (The Science Behind the Magic)</h2>
<p>Here&rsquo;s what makes this approach so much better than the &ldquo;retry everything&rdquo; strategy:</p>
<p><strong>🎯 Surgical Precision</strong>
Only retry what actually failed – it&rsquo;s like having a really good therapist who focuses on the actual problems instead of rehashing everything from childhood.</p>
<p>No wasted API calls on requests that already succeeded. If 24 out of 25 files downloaded perfectly, why punish them with another round trip?</p>
<p><strong>⚡ Speed Demon</strong>
Successful requests don&rsquo;t get repeated, so everything finishes faster – sometimes dramatically faster.</p>
<p>Users see their successful downloads immediately while you quietly retry the problematic ones in the background.</p>
<p><strong>🤝 Throttling-Friendly</strong>
Fewer total requests means you&rsquo;re less likely to hit Microsoft&rsquo;s rate limits, and when you do, recovery is faster.</p>
<p>Instead of amplifying throttling issues, you&rsquo;re actually helping to resolve them by reducing load on the endpoints that are already struggling.</p>
<p><strong>🔄 Drop-in Simplicity</strong>
Change literally one line of code and you&rsquo;re done. No architectural rewrites, no complex state management – just swap out the method call.</p>
<p>Your existing error handling, logging, and business logic all stay exactly the same.</p>
<p><strong>🔍 Debug Paradise</strong>
Easy to see exactly which requests are consistently failing, making troubleshooting a breeze instead of a nightmare.</p>
<p>When file &ldquo;ImportantDocument.pdf&rdquo; fails on every retry attempt, you know there&rsquo;s something specific about that file, not your entire batch logic.</p>
<h2 id="the-before-and-after-moment">The &ldquo;Before and After&rdquo; Moment</h2>
<p>Let me paint you a picture of how this changes your life:</p>
<p><strong>Before Smart Retries:</strong></p>
<ul>
<li>25 file batch fails on 3 files due to throttling</li>
<li>Retry all 25 files → now 5 files fail due to increased throttling</li>
<li>Retry all 25 files again → now 8 files fail</li>
<li>You&rsquo;re now in the throttling spiral of doom</li>
<li>Users are staring at loading spinners</li>
<li>You&rsquo;re questioning your career choices</li>
</ul>
<p><strong>After Smart Retries:</strong></p>
<ul>
<li>25 file batch fails on 3 files due to throttling</li>
<li>Keep the 22 successful files, retry only the 3 failures</li>
<li>Maybe 2 of the 3 succeed on retry, leaving 1 stubborn file</li>
<li>Final retry gets the last file, or you log it as a persistent issue</li>
<li>Users get 24/25 files quickly, you sleep better at night</li>
</ul>
<p>It&rsquo;s the difference between being stuck in traffic because one lane is blocked (and everyone keeps switching to that lane), versus just using the open lanes and going around the problem.</p>
<h2 id="the-bottom-line-and-why-your-future-self-will-thank-you">The Bottom Line (And Why Your Future Self Will Thank You)</h2>
<p>I&rsquo;ll be real with you – when I first started working with Microsoft Graph batching, I thought the built-in retry policies were enough. &ldquo;How hard could it be?&rdquo; I thought. &ldquo;APIs fail sometimes, just retry them!&rdquo;</p>
<p>Then I built my first real-world document sync application. Suddenly, I was dealing with users uploading hundreds of files, enterprise throttling limits, and the occasional network hiccup that would bring the whole operation to a screeching halt.</p>
<p><strong>That&rsquo;s when I learned the hard way that &ldquo;retry everything&rdquo; is like using a sledgehammer to hang a picture frame.</strong> Sure, it might work, but you&rsquo;re probably going to break some stuff in the process.</p>
<p>This selective retry approach has been a game-changer. Not just for performance (though users definitely notice when their bulk operations actually complete), but for debugging too. When you can see that <code>ImportantReport_v23_FINAL_REALLY_FINAL.docx</code> is the file that keeps failing, you can actually do something about it.</p>
<p><strong>The best part?</strong> Once you have this extension method in your toolkit, it becomes muscle memory. You&rsquo;re not adding complexity to your day-to-day development – you&rsquo;re just swapping out one method call for a smarter one. It&rsquo;s like upgrading from a flip phone to a smartphone – you wonder how you ever lived without it.</p>
<p><strong>Pro tip:</strong> After implementing this, keep an eye on your application logs. You&rsquo;ll start to notice patterns in failures that you never saw before. Maybe certain file types are more prone to issues, or maybe there&rsquo;s a specific time of day when throttling gets worse. This kind of insight is pure gold for optimization.</p>
<p>The moral of the story? Sometimes the biggest performance improvements come not from doing things faster, but from doing fewer unnecessary things. And sometimes, the best debugging tool is just&hellip; not breaking the working stuff while you fix the broken stuff.</p>
<p>Your 2 AM debugging sessions will never be the same. 😌</p>
<h2 id="want-to-learn-more-the-reading-list">Want to Learn More? (The Reading List)</h2>
<ul>
<li><strong><a href="https://docs.microsoft.com/en-us/graph/json-batching">Microsoft Graph JSON batching</a></strong> - The official documentation (surprisingly readable!)</li>
<li><strong><a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph Batching for File Content: Mapping Requests to Responses</a></strong> - My previous post that sets up the foundation for this one</li>
<li><strong><a href="https://docs.microsoft.com/en-us/graph/throttling">Microsoft Graph throttling guidance</a></strong> - Understanding what makes Microsoft&rsquo;s APIs cranky</li>
<li><strong><a href="https://developer.microsoft.com/en-us/graph/graph-explorer">Graph Explorer</a></strong> - Test your batch requests interactively (great for experimenting)</li>
<li><strong><a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/retry">Exponential Backoff Pattern</a></strong> - The polite way to retry things</li>
</ul>
<p>Now go forth and batch smarter, not harder! 🚀</p>
]]></content:encoded></item><item><title>Graph Batching for File Content: Mapping Requests to Responses</title><link>https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/</link><pubDate>Wed, 10 Sep 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/</guid><description>How to handle Graph batching when downloading file content and mapping responses back to original requests</description><content:encoded><![CDATA[<h2 id="the-problem-thatll-drive-you-crazy">The Problem That&rsquo;ll Drive You Crazy</h2>
<p>Picture this: you need to download 50 files from SharePoint using Microsoft Graph. Being a good developer, you decide to use batching instead of making 50 individual API calls (because nobody wants to wait that long, and Microsoft&rsquo;s throttling limits aren&rsquo;t going anywhere).</p>
<p>You set up your batch request, send it off, and get your responses back. Great! Except&hellip; now you&rsquo;re staring at a bunch of file content with absolutely no way to tell which file is which. 😅</p>
<p>Unlike other Graph operations that return nice JSON objects with IDs and metadata, file content responses are just raw bytes. No file name, no path, no ID - nothing to help you figure out which response belongs to which original request.</p>
<p>I learned this the hard way when I first tried Graph batching for file downloads. Spent way too much time trying to correlate responses by file size or content patterns before realizing there was a much cleaner solution.</p>
<h2 id="the-mapping-solution-its-simpler-than-you-think">The Mapping Solution (It&rsquo;s Simpler Than You Think)</h2>
<p>The trick is surprisingly straightforward: use the batch request ID as your bridge between the original file info and the response content. Every batch request gets a unique ID, and that same ID comes back with the response.</p>
<p>Here&rsquo;s the game plan:</p>
<ol>
<li>Create a dictionary mapping request IDs to your original file information</li>
<li>Build your batch requests and store the mappings</li>
<li>Process responses using the request ID to look up the original file info</li>
</ol>
<p>Let me show you exactly how this works.</p>
<h2 id="setting-up-your-file-information">Setting Up Your File Information</h2>
<p>First, let&rsquo;s create a simple model to hold our file details:</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">class</span> <span style="color:#a6e22e">FileInfoDTO</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Path { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Name { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> RelativePath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> UniqueFileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Nothing fancy here - just the basics we need to identify and process each file.</p>
<h2 id="the-complete-batch-download-method">The Complete Batch Download Method</h2>
<p>Here&rsquo;s the full implementation. Don&rsquo;t worry, I&rsquo;ll break down the important parts afterward:</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">async</span> Task&lt;Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt;&gt; DownloadFilesBatchAsync(
</span></span><span style="display:flex;"><span>    FileInfoDTO[] fileInfos, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> siteId, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> driveId) {
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> BatchRequestContent batchRequestContent = <span style="color:#66d9ef">new</span> BatchRequestContent();
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt; requestMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt;();
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt; results = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Build batch requests with mapping</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (FileInfoDTO fileInfo <span style="color:#66d9ef">in</span> fileInfos) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrEmpty(fileInfo.RelativePath) || <span style="color:#66d9ef">string</span>.IsNullOrEmpty(fileInfo.UniqueFileName))
</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">string</span> requestId = batchRequestContent.AddBatchRequestStep(
</span></span><span style="display:flex;"><span>            GraphClient.Sites[siteId]
</span></span><span style="display:flex;"><span>                      .Drives[driveId]
</span></span><span style="display:flex;"><span>                      .Root
</span></span><span style="display:flex;"><span>                      .ItemWithPath(fileInfo.RelativePath)
</span></span><span style="display:flex;"><span>                      .Content
</span></span><span style="display:flex;"><span>                      .Request());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// This is the magic - storing the mapping!</span>
</span></span><span style="display:flex;"><span>        requestMapping[requestId] = fileInfo;
</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">// Execute batch request</span>
</span></span><span style="display:flex;"><span>    BatchResponseContent batchResponse = <span style="color:#66d9ef">await</span> GraphClient.Batch.Request().PostAsync(batchRequestContent);
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process responses using our mapping</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ((<span style="color:#66d9ef">string</span> requestId, HttpResponseMessage response) <span style="color:#66d9ef">in</span> responses) {
</span></span><span style="display:flex;"><span>        FileInfoDTO originalFile = requestMapping[requestId]; <span style="color:#75715e">// Look up the original file info</span>
</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 style="color:#66d9ef">switch</span> (response.StatusCode) {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.OK:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">byte</span>[] content = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>                    results[originalFile.UniqueFileName!] = content;
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.Redirect:
</span></span><span style="display:flex;"><span>                    <span style="color:#75715e">// Handle redirect for large files (more on this below)</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">byte</span>[] redirectContent = <span style="color:#66d9ef">await</span> DownloadFromRedirectAsync(response.Headers.Location);
</span></span><span style="display:flex;"><span>                    results[originalFile.UniqueFileName!] = redirectContent;
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.TooManyRequests:
</span></span><span style="display:flex;"><span>                    <span style="color:#75715e">// Handle throttling</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Throttled request for {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Failed to download {originalFile.Name}: {response.ReasonPhrase}&#34;</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">finally</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Always dispose - learned this one the hard way after some memory leak hunting</span>
</span></span><span style="display:flex;"><span>            response.Content.Dispose();
</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">return</span> results;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-key-parts-explained">The Key Parts Explained</h2>
<h3 id="the-mapping-dictionary">The Mapping Dictionary</h3>
<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>Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt; requestMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt;();
</span></span></code></pre></div><p>This is your lifeline. For every request you add to the batch, you store the request ID and link it to your original file information. When responses come back, you can instantly look up which file each response belongs to.</p>
<h3 id="building-requests-with-mapping">Building Requests with Mapping</h3>
<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">string</span> requestId = batchRequestContent.AddBatchRequestStep(...);
</span></span><span style="display:flex;"><span>requestMapping[requestId] = fileInfo;
</span></span></code></pre></div><p>The <code>AddBatchRequestStep</code> method returns a unique request ID. Store this immediately - you&rsquo;ll need it to match responses later.</p>
<h2 id="handling-the-redirect-curveball">Handling the Redirect Curveball</h2>
<p>Here&rsquo;s something that caught me off guard initially: large files don&rsquo;t return content directly. Instead, Graph gives you a redirect to an Azure Blob Storage URL where the actual file lives. Microsoft doesn&rsquo;t specify the exact file size threshold, but in practice, I&rsquo;ve observed this happening with files larger than a few MB.</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">async</span> Task&lt;<span style="color:#66d9ef">byte</span>[]&gt; DownloadFromRedirectAsync(Uri? redirectUri) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (redirectUri == <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> ArgumentException(<span style="color:#e6db74">&#34;Redirect URI is null&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> HttpClient httpClient = <span style="color:#66d9ef">new</span> HttpClient();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> httpClient.GetByteArrayAsync(redirectUri);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This happens because Microsoft doesn&rsquo;t want to push huge files through the Graph API unnecessarily. The redirect URL is temporary and works great - just make sure you handle it properly.</p>
<p><strong>Note:</strong> The exact file size that triggers a redirect isn&rsquo;t officially documented by Microsoft, so always handle both direct content (200 OK) and redirect (302) responses in your code.</p>
<h2 id="error-handling-that-actually-helps">Error Handling That Actually Helps</h2>
<p>When things go wrong (and they will), you want meaningful error messages. Here&rsquo;s how to handle the common scenarios:</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">async</span> Task ProcessBatchResponseAsync(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> requestId, 
</span></span><span style="display:flex;"><span>    HttpResponseMessage response, 
</span></span><span style="display:flex;"><span>    FileInfoDTO originalFile,
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt; results) {
</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 style="color:#66d9ef">switch</span> (response.StatusCode) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.OK:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">byte</span>[] content = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>                results[originalFile.UniqueFileName!] = content;
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Redirect:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Found:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">byte</span>[] redirectContent = <span style="color:#66d9ef">await</span> DownloadFromRedirectAsync(response.Headers.Location);
</span></span><span style="display:flex;"><span>                results[originalFile.UniqueFileName!] = redirectContent;
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.NotFound:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;File not found: {originalFile.Name} at {originalFile.RelativePath}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Maybe the file was moved or deleted</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.TooManyRequests:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Throttled request for: {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// This is where retry logic would go (coming in the next post!)</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Forbidden:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Access denied for: {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Check your permissions</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Unexpected error downloading {originalFile.Name}: {response.StatusCode} - {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</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">finally</span> {
</span></span><span style="display:flex;"><span>        response.Content?.Dispose(); <span style="color:#75715e">// Don&#39;t leak memory!</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="important-things-to-remember">Important Things to Remember</h2>
<h3 id="batch-size-limits">Batch Size Limits</h3>
<p>Graph batching has a hard limit of 20 requests per batch. If you have more files, you&rsquo;ll need to chunk them:</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">const</span> <span style="color:#66d9ef">int</span> BATCH_SIZE = <span style="color:#ae81ff">20</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i = <span style="color:#ae81ff">0</span>; i &lt; fileInfos.Length; i += BATCH_SIZE) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> batch = fileInfos.Skip(i).Take(BATCH_SIZE).ToArray();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> batchResults = <span style="color:#66d9ef">await</span> DownloadFilesBatchAsync(batch, siteId, driveId);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Merge results...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="memory-management">Memory Management</h3>
<p>Always dispose of <code>HttpResponseMessage.Content</code>. File downloads can be large, and forgetting to dispose will cause memory leaks that are painful to debug.</p>
<h3 id="request-id-uniqueness">Request ID Uniqueness</h3>
<p>Request IDs are unique within a single batch, but not across different batches. Don&rsquo;t try to reuse mappings between different batch operations.</p>
<h2 id="why-this-pattern-works-so-well">Why This Pattern Works So Well</h2>
<p>This approach has several advantages that make it my go-to solution:</p>
<ul>
<li><strong>Dead Simple</strong>: No complex logic, just a straightforward mapping pattern</li>
<li><strong>Reliable</strong>: Works consistently regardless of file sizes or response order</li>
<li><strong>Memory Efficient</strong>: Proper cleanup prevents memory leaks</li>
<li><strong>Debuggable</strong>: Easy to trace issues when something goes wrong</li>
<li><strong>Extensible</strong>: Perfect foundation for adding retry logic later</li>
</ul>
<p>The beauty is in its simplicity. You&rsquo;re not trying to guess which response belongs to which request - you know exactly because you mapped it from the start.</p>
<h2 id="whats-next">What&rsquo;s Next?</h2>
<p>This mapping pattern solves the core problem of correlating Graph batch responses with your original requests. But what happens when some requests fail due to throttling or temporary errors?</p>
<p>In my next post, I&rsquo;ll show you how to build retry logic on top of this foundation that automatically handles failed requests without losing track of which files still need to be downloaded.</p>
<p>Have you run into this mapping challenge before? Let me know in the comments how you solved it - I&rsquo;m always curious about different approaches! 🚀</p>
]]></content:encoded></item><item><title>DevProxy: How to Test API Rate Limiting and Throttling in C# Development</title><link>https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/</link><pubDate>Sun, 10 Aug 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/</guid><description>Discover how to simulate Microsoft Graph and SharePoint throttling locally using DevProxy, and prevent production slowdowns before they happen.</description><content:encoded><![CDATA[<h2 id="the-problem-when-parallel-programming-backfires">The Problem: When Parallel Programming Backfires</h2>
<p>If you’ve ever optimized your C# API calls with parallel programming, you might know this story.</p>
<p>Your client wants faster performance. You run multiple API requests in parallel. Locally, everything flies — especially late at night when traffic is low. But the next morning, on the final pre-deployment test, disaster strikes.</p>
<p><strong>Random errors. Requests delayed for 45 seconds.</strong><br>
Microsoft Graph or SharePoint has slammed you with throttling and rate limits. Your blazing-fast local solution crumbles under production-like conditions.</p>
<hr>
<h2 id="how-crud-operations-can-secretly-burn-through-your-limits--and-how-to-catch-them-with-devproxy">How CRUD Operations Can Secretly Burn Through Your Limits — and How to Catch Them with DevProxy</h2>
<p>Here’s the thing:<br>
SharePoint Online charges “Resource Units” (RUs) for every request you make. Think of RUs as an invisible currency — every API call you send deducts from your allowance. Run out too fast, and throttling kicks in.</p>
<p>And those “simple” operations? They’re not as cheap as you think.</p>
<p><strong>From Microsoft’s RU table</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Operation Type</th>
          <th>RU Cost</th>
          <th>What That Means</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Single item query</strong></td>
          <td>1 RU</td>
          <td>Reading one specific list item</td>
      </tr>
      <tr>
          <td><strong>Multi-item query</strong></td>
          <td>2 RUs</td>
          <td>Listing children, filtering, sorting</td>
      </tr>
      <tr>
          <td><strong>Create / Update / Delete / Upload</strong></td>
          <td>2 RUs</td>
          <td>Per individual operation (bulk operations may be more efficient)</td>
      </tr>
      <tr>
          <td><strong>Permission expansion</strong> (<code>$expand=permissions</code>)</td>
          <td>5 RUs</td>
          <td>Heavy permission lookups</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>Source:</strong> <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online#resource-units">Microsoft&rsquo;s official SharePoint throttling documentation</a></p>
</blockquote>
<p>Permission expansions are just one type of &ldquo;expensive&rdquo; call. Another common scenario that can impact RU consumption is <strong>making multiple separate queries instead of using CAML joins</strong> — something I explored in <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">my CAML join post</a>.<br>
While joining multiple lists in a single query is actually more efficient than separate calls, poorly structured queries or retrieving unnecessarily large datasets can still consume RUs quickly if not optimized properly.</p>
<hr>
<h3 id="why-this-sneaks-up-on-you">Why This Sneaks Up on You</h3>
<p>When coding locally, it’s easy to run a handful of queries without noticing.<br>
But in production, with real concurrency and volume, these calls pile up in seconds.</p>
<p>Imagine:</p>
<ul>
<li>10 parallel updates (2 RUs each) = <strong>20 RUs in one burst</strong></li>
<li>Add a few joins/expansions or large list queries, and you’re suddenly <em>burning through limits 5x faster</em>.</li>
</ul>
<hr>
<h3 id="how-devproxy-can-show-you-the-pain-before-production">How DevProxy Can Show You the Pain Before Production</h3>
<p>Here’s where DevProxy shines.<br>
If you configure it to simulate throttling based on these RU-heavy calls, you’ll <em>see</em> the impact locally:</p>
<ol>
<li><strong>Enable throttling plugins</strong> in your <code>devproxy.json</code> (as shown earlier).</li>
<li>Point <code>urlsToWatch</code> at your SharePoint endpoints.</li>
<li>Run your CRUD-heavy code.</li>
</ol>
<p>DevProxy will start throwing 429s (Too Many Requests) once your “fake” RU budget is exhausted — just like SharePoint would in production.</p>
<p>The beautiful part?<br>
You can crank the limits <em>down</em> during testing to make expensive patterns obvious. Even a single large query or unbatched <code>Update</code> will light up your logs.</p>
<p>Example DevProxy log when hitting a throttling simulation:</p>
<pre tabindex="0"><code>[Warning] Throttling triggered: 2 parallel Create calls exceeded RU limit (RateLimitingPlugin)
Retry after: 30 seconds
</code></pre><p><strong>Pro tip:</strong><br>
Use DevProxy as a <strong>budget meter</strong> for your API calls. Treat every 2-RU and 5-RU operation as “spending big” — and redesign those spots <em>before</em> they become a production outage.</p>
<h2 id="what-is-devproxy">What is DevProxy?</h2>
<p><a href="https://github.com/dotnet/dev-proxy">DevProxy</a> is an open-source HTTP/HTTPS proxy server that can simulate real-world network issues, including:</p>
<ul>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/concepts/what-is-rate-limiting"><strong>Rate limiting</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/concepts/what-is-throttling"><strong>Throttling</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/how-to/simulate-slow-api-responses"><strong>Network delays</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/how-to/test-my-app-with-random-errors"><strong>Intermittent errors</strong></a></li>
</ul>
<p>It’s free, open source, and designed for developers who want to <strong>catch performance bottlenecks before they reach production</strong>.</p>
<hr>
<h2 id="installing-devproxy">Installing DevProxy</h2>
<p>Follow the official setup guide here:<br>
<a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/get-started/set-up">DevProxy Installation Documentation</a></p>
<hr>
<h2 id="basic-usage">Basic Usage</h2>
<p>Run DevProxy with the default configuration:</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-bash" data-lang="bash"><span style="display:flex;"><span>devproxy
</span></span></code></pre></div><p>It will begin intercepting all HTTP/HTTPS requests.</p>
<blockquote>
<p><strong>Tip:</strong> Start DevProxy <em>before</em> running your own code — otherwise, it won’t capture the traffic.</p>
</blockquote>
<hr>
<h2 id="simulating-microsoft-graph-throttling">Simulating Microsoft Graph Throttling</h2>
<p>Here’s how to configure DevProxy to reproduce Microsoft Graph and SharePoint throttling issues locally.</p>
<h3 id="1-create-a-devproxyjson-configuration-file">1. Create a <code>devproxy.json</code> configuration file</h3>
<p>This is the exact configuration I used when testing my problematic parallel 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></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/rc.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;rate&#34;</span>: <span style="color:#ae81ff">25</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;plugins&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;RetryAfterPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</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:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;RateLimitingPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;configSection&#34;</span>: <span style="color:#e6db74">&#34;rateLimitingPlugin&#34;</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:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;GraphRandomErrorPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;configSection&#34;</span>: <span style="color:#e6db74">&#34;graphRandomErrorPlugin&#34;</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:#f92672">&#34;urlsToWatch&#34;</span>: [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.com/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.com/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.us/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.us/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://dod-graph.microsoft.us/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://dod-graph.microsoft.us/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://microsoftgraph.chinacloudapi.cn/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://microsoftgraph.chinacloudapi.cn/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_api/web/GetClientSideComponents&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_api/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_vti_bin/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint-df.*/*_api/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint-df.*/*_vti_bin/*&#34;</span>
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;graphRandomErrorPlugin&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/graphrandomerrorplugin.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;allowedErrors&#34;</span>: [
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">429</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">503</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">504</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:#f92672">&#34;rateLimitingPlugin&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/ratelimitingplugin.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;costPerRequest&#34;</span>: <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;rateLimit&#34;</span>: <span style="color:#ae81ff">120</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;logLevel&#34;</span>: <span style="color:#e6db74">&#34;information&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;newVersionNotification&#34;</span>: <span style="color:#e6db74">&#34;stable&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;showSkipMessages&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;showTimestamps&#34;</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="2-start-devproxy-with-your-configuration">2. Start DevProxy with your configuration</h3>
<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-bash" data-lang="bash"><span style="display:flex;"><span>devproxy --config-file devproxy.json
</span></span></code></pre></div><hr>
<h2 id="example-of-problematic-code">Example of Problematic Code</h2>
<p>Here’s the snippet that caused my throttling nightmare. It uses <code>Parallel.ForEachAsync</code> to query SharePoint in bulk:</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">// ❌ This will likely trigger throttling in production</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = <span style="color:#66d9ef">new</span> ConcurrentBag&lt;CustomerDTO&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> Parallel.ForEachAsync(departmentIds, <span style="color:#66d9ef">async</span> (departmentId, token) =&gt; 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> clonedContext = _clientContext.Clone(_clientContext.Url);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> query = Camlex.Query()
</span></span><span style="display:flex;"><span>        .ViewFields(<span style="color:#66d9ef">new</span> CustomerDTO().ViewFields().ToArray().Append(<span style="color:#e6db74">&#34;DepartmentId&#34;</span>))
</span></span><span style="display:flex;"><span>        .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentLookup&#34;</span>].ForeignList(DEPARTMENT_LIST_GUID))
</span></span><span style="display:flex;"><span>        .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentId&#34;</span>].List(DEPARTMENT_LIST_GUID).ShowField(<span style="color:#e6db74">&#34;ID&#34;</span>))
</span></span><span style="display:flex;"><span>        .Where(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentId&#34;</span>] == departmentId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> departmentCustomers = <span style="color:#66d9ef">await</span> SharePointService.GetItemsFromListByQuery&lt;CustomerDTO&gt;(
</span></span><span style="display:flex;"><span>        CUSTOMER_LIST_GUID,
</span></span><span style="display:flex;"><span>        clonedContext,
</span></span><span style="display:flex;"><span>        query);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (departmentCustomers?.Any() == <span style="color:#66d9ef">true</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> customer <span style="color:#66d9ef">in</span> departmentCustomers)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            customers.Add(customer);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><hr>
<h2 id="why-this-code-fails-in-production">Why This Code Fails in Production</h2>
<ol>
<li><strong>No throttling safeguards</strong> — Multiple parallel requests overwhelm SharePoint.</li>
<li><strong>No retry logic</strong> — Requests fail instead of recovering.</li>
<li><strong>No request limiting</strong> — All departments are processed simultaneously.</li>
<li><strong>Silent failures</strong> — Errors are ignored without logging or fallback.</li>
</ol>
<hr>
<h2 id="the-better-way">The Better Way</h2>
<p>Instead of hard-coding fixes here, I recommend Bert Jansen’s detailed guide on throttling and rate limit handling:<br>
<a href="https://github.com/OneDrive/samples/blob/master/scenarios/throttling-ratelimit-handling/readme.md">Throttling &amp; Rate Limit Handling Patterns</a></p>
<p>Key principles:</p>
<ul>
<li><strong>Limit concurrency</strong> with <code>SemaphoreSlim</code>.</li>
<li><strong>Use exponential backoff</strong> for retries.</li>
<li><strong>Monitor rate limit headers</strong> to adjust requests dynamically.</li>
<li><strong>Handle errors explicitly</strong> to prevent silent failures.</li>
</ul>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>DevProxy has become an essential tool in my Microsoft 365 development workflow. It helps me:</p>
<ul>
<li>Catch throttling issues <strong>before</strong> production.</li>
<li>Test error handling and retry logic <strong>locally</strong>.</li>
<li>Deliver applications that can survive real-world API limits.</li>
</ul>
<p>If I’d used DevProxy from the start, I could have avoided the last-minute throttling meltdown entirely.</p>
<p>💡 <strong>Pro tip:</strong> Make DevProxy part of your <em>early</em> development process, not your emergency toolkit.</p>
<hr>
<h2 id="additional-resources">Additional Resources</h2>
<ul>
<li><a href="https://github.com/dotnet/dev-proxy">DevProxy GitHub Repository</a></li>
<li><a href="https://docs.microsoft.com/en-us/graph/throttling">Microsoft Graph Throttling Guidelines</a></li>
<li><a href="https://aka.ms/devproxy/docs">DevProxy Documentation</a></li>
</ul>
]]></content:encoded></item><item><title>Microsoft Graph SDK Authentication in C#: Quick Start Guide</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-authentication-csharp/</link><pubDate>Fri, 20 Jun 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-authentication-csharp/</guid><description>Quick setup guide for Microsoft Graph SDK using your existing app registration</description><content:encoded><![CDATA[<h2 id="what-is-microsoft-graph">What is Microsoft Graph?</h2>
<p>Microsoft Graph is the unified REST API for Microsoft 365. Think of it as a single gateway to access data across SharePoint, Teams, OneDrive, Outlook, Azure AD, and more - all through one consistent API.</p>
<p><strong>Graph vs CSOM/PnP.Framework:</strong></p>
<ul>
<li><strong>Graph</strong>: Modern REST API, works across all Microsoft 365 services</li>
<li><strong>CSOM/PnP</strong>: SharePoint-specific, more SharePoint features available</li>
</ul>
<h2 id="when-to-use-graph-sdk-vs-pnpframework">When to Use Graph SDK vs PnP.Framework</h2>
<p><strong>✅ Use Graph SDK when:</strong></p>
<ul>
<li>Building modern cloud applications</li>
<li>Need access to Teams, OneDrive, Exchange, or Azure AD</li>
<li>Want built-in retry logic, batching, and performance optimizations</li>
<li>Building apps that integrate multiple Microsoft 365 services</li>
</ul>
<p><strong>✅ Use PnP.Framework when:</strong></p>
<ul>
<li>Need advanced SharePoint features (site templates, provisioning, taxonomy)</li>
<li>Working with SharePoint-specific APIs not yet in Graph</li>
<li>Building SharePoint-focused solutions</li>
<li>Need features like search refiners, managed metadata, or custom actions</li>
</ul>
<p><strong>❌ Graph SDK limitations:</strong></p>
<ul>
<li>No support for SharePoint classic features (master pages, web parts)</li>
<li>Limited search capabilities compared to SharePoint Search API</li>
<li>Some advanced list/library features not available</li>
<li>Can&rsquo;t access SharePoint on-premises (SharePoint Server)</li>
</ul>
<h2 id="quick-start">Quick Start</h2>
<p>Building on the <a href="https://jeppe-spanggaard.dk/pnp-framework-authentication-csharp/">PnP.Framework authentication setup</a>, let&rsquo;s add Microsoft Graph SDK to access all Microsoft 365 services with the same certificate.</p>
<h2 id="add-graph-permissions">Add Graph Permissions</h2>
<p>In your existing app registration, go to &ldquo;API permissions&rdquo; → &ldquo;Add a permission&rdquo; → &ldquo;Microsoft Graph&rdquo; → &ldquo;Application permissions&rdquo;:</p>
<ul>
<li><strong>Sites.ReadWrite.All</strong>: SharePoint sites and lists access</li>
</ul>
<p><strong>Important</strong>: Click &ldquo;Grant admin consent&rdquo; after adding permissions!</p>
<h2 id="install-and-code">Install and Code</h2>
<p>Install the NuGet package:</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-bash" data-lang="bash"><span style="display:flex;"><span>Install-Package Microsoft.Graph
</span></span></code></pre></div><p>Here&rsquo;s the same example from the PnP.Framework post, but using Graph SDK:</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> Microsoft.Graph;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Microsoft.Graph.Auth;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> System.Security.Cryptography.X509Certificates;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Program</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">readonly</span> <span style="color:#66d9ef">string</span> TenantId = <span style="color:#e6db74">&#34;your-tenant-id&#34;</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> <span style="color:#66d9ef">string</span> ClientId = <span style="color:#e6db74">&#34;your-client-id&#34;</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> <span style="color:#66d9ef">string</span> CertificatePath = <span style="color:#e6db74">@&#34;C:\Temp\cert\pnpappcert.pfx&#34;</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> <span style="color:#66d9ef">string</span> CertificatePassword = <span style="color:#e6db74">&#34;MySuperStrongPassword!&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task Main(<span style="color:#66d9ef">string</span>[] args)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Create Graph client (same certificate as PnP.Framework)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> certificate = <span style="color:#66d9ef">new</span> X509Certificate2(CertificatePath, CertificatePassword);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> ClientCertificateCredentialOptions
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
</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> clientCertCredential = <span style="color:#66d9ef">new</span> ClientCertificateCredential(
</span></span><span style="display:flex;"><span>            TenantId, ClientId, certificate, options);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> scopes = <span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#34;https://graph.microsoft.com/.default&#34;</span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> graphClient = <span style="color:#66d9ef">new</span> GraphServiceClient(clientCertCredential, scopes);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Test connection</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> org = <span style="color:#66d9ef">await</span> graphClient.Organization.Request().GetAsync();
</span></span><span style="display:flex;"><span>        Console.WriteLine(<span style="color:#e6db74">$&#34;Connected to: {org.First().DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Get all lists (like PnP.Framework example)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> site = <span style="color:#66d9ef">await</span> graphClient.Sites[<span style="color:#e6db74">&#34;root&#34;</span>].Request().GetAsync();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> lists = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists.Request().GetAsync();
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        Console.WriteLine(<span style="color:#e6db74">&#34;Lists in this site:&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> list <span style="color:#66d9ef">in</span> lists)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;- {list.DisplayName}&#34;</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:#75715e">// Upload a document (like PnP.Framework example)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> docLib = lists.FirstOrDefault(l =&gt; l.DisplayName == <span style="color:#e6db74">&#34;Documents&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (docLib != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> content = System.Text.Encoding.UTF8.GetBytes(<span style="color:#e6db74">&#34;Hello from Graph SDK!&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> drive = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists[docLib.Id].Drive.Request().GetAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> file = <span style="color:#66d9ef">await</span> graphClient.Drives[drive.Id].Root
</span></span><span style="display:flex;"><span>                .ItemWithPath(<span style="color:#e6db74">&#34;sample-document.txt&#34;</span>)
</span></span><span style="display:flex;"><span>                .Content
</span></span><span style="display:flex;"><span>                .Request()
</span></span><span style="display:flex;"><span>                .PutAsync&lt;DriveItem&gt;(<span style="color:#66d9ef">new</span> MemoryStream(content));
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Uploaded file: {file.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="key-differences-from-pnpframework">Key Differences from PnP.Framework</h2>
<p><strong>Same operations, different syntax:</strong></p>
<table>
  <thead>
      <tr>
          <th>Operation</th>
          <th>PnP.Framework</th>
          <th>Graph SDK</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Get Lists</strong></td>
          <td><code>context.Web.Lists</code></td>
          <td><code>graphClient.Sites[id].Lists</code></td>
      </tr>
      <tr>
          <td><strong>Upload File</strong></td>
          <td><code>docLib.RootFolder.Files.Add()</code></td>
          <td><code>drive.Root.ItemWithPath().Content.Put()</code></td>
      </tr>
      <tr>
          <td><strong>Authentication</strong></td>
          <td><code>AuthenticationManager</code></td>
          <td><code>ClientCertificateProvider</code></td>
      </tr>
  </tbody>
</table>
<p><strong>Graph advantages:</strong> Access to Teams, OneDrive, Exchange data with same client<br>
<strong>PnP advantages:</strong> More SharePoint-specific features like content types, site columns</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<ul>
<li><strong>&ldquo;Insufficient privileges&rdquo;</strong>: Check permissions and admin consent</li>
<li><strong>&ldquo;Certificate not found&rdquo;</strong>: Verify certificate path and password</li>
<li><strong>&ldquo;Unauthorized&rdquo;</strong>: Ensure app has correct Graph permissions</li>
</ul>
<h2 id="next-steps">Next Steps</h2>
<p>You now have Graph SDK working with your existing app registration! With the basic setup, you can:</p>
<ul>
<li>Access SharePoint sites and lists</li>
<li>Upload and manage files in document libraries</li>
<li>Get organization information</li>
</ul>
<p>To access more Microsoft 365 services, add the appropriate permissions:</p>
<ul>
<li><strong>User.Read.All</strong> for user data</li>
<li><strong>Group.Read.All</strong> for Teams and groups</li>
<li><strong>Files.ReadWrite.All</strong> for broader file operations</li>
<li><strong>Mail.Read</strong> for Exchange data</li>
</ul>
<p>Perfect foundation for building comprehensive Microsoft 365 integrations.</p>
]]></content:encoded></item><item><title>Export files as zip from SharePoint</title><link>https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/</link><pubDate>Sun, 01 Dec 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/</guid><description>Download files from SharePoint as a zip, as is possible through the UI</description><content:encoded><![CDATA[<p>I have long been frustrated by the inability to download multiple files from SharePoint as a zip file via code, in the same way it’s possible through the user interface.
When this suddenly became a requirement from a customer , I had to come up with a solution 💡. Naturally, the solution needed to be robust 💪 — I could use the endpoint that SharePoint itself utilizes, though it’s not officially documented, but I decided against taking that route 🚫.</p>
<h2 id="what-was-the-solution-then-">What was the solution then? 🤔</h2>
<p>The solution came to me 🤔 while setting up the App Service Plan for my function—it struck me that I had 250GB of storage (App Service Premium Plan) available. I figured I could use this for something.</p>
<p>I was initially unsure whether I had permissions to write to this storage, but after a quick test, I confirmed that I could easily write to it—and, of course, read from it again. 🚀</p>
<p>Whether it’s the best solution to the problem, I’m not sure 🤷‍♂️, but I know it works, and I have full control over it, ensuring it won’t suddenly disappear—unlike an unofficial endpoint might. 💡</p>
<h3 id="fetch-the-files-">Fetch the files 📥</h3>
<p>When fetching the files, it’s, of course, important to minimize the number of calls to avoid throttling. The way I’ve attempted to prevent this is by using <a href="https://learn.microsoft.com/en-us/graph/json-batching">Graph batching</a>, which allows me to bundle 20 requests into one. 📦</p>
<p><strong>Example class</strong></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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileInfoDTO</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> RelativePath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Graph batching</strong></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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">async</span> Task DownloadFilesFromPathsToTempFolderAsync(
</span></span><span style="display:flex;"><span>    List&lt;FileInfoDTO?&gt;? fileInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> tempFolderPath, 
</span></span><span style="display:flex;"><span>    Site siteInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> driveId) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	IEnumerable&lt;FileInfoDTO?[]&gt; chucked = fileInfo!.Chunk(<span style="color:#ae81ff">20</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> (FileInfoDTO?[] chunk <span style="color:#66d9ef">in</span> chucked) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">await</span> DownloadFilesFromPathsToTempFolderAsync(chunk, tempFolderPath, siteInfo, driveId);
</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">async</span> Task DownloadFilesFromPathsToTempFolderAsync(
</span></span><span style="display:flex;"><span>    FileInfoDTO?[] fileInfos, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> tempFolderPath, 
</span></span><span style="display:flex;"><span>    Site siteInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> driveId) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">using</span> BatchRequestContent batchRequestContent = <span style="color:#66d9ef">new</span> BatchRequestContent();
</span></span><span style="display:flex;"><span>	Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string</span>&gt; nameMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string</span>&gt;(fileInfos.Length);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> (FileInfoDTO? fileInfo <span style="color:#66d9ef">in</span> fileInfos) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(
</span></span><span style="display:flex;"><span>			GraphClient.Sites[siteInfo.Id]
</span></span><span style="display:flex;"><span>                       .Drives[driveId]
</span></span><span style="display:flex;"><span>                       .Root
</span></span><span style="display:flex;"><span>                       .ItemWithPath(fileInfo.RelativePath)
</span></span><span style="display:flex;"><span>                       .Content.Request());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		nameMapping[requestId] = fileInfo.FileName!;
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	BatchResponseContent batchResponse = <span style="color:#66d9ef">await</span> GraphClient.Batch.Request().PostAsync(batchRequestContent);
</span></span><span style="display:flex;"><span>	Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> ((<span style="color:#66d9ef">string</span> requestId, HttpResponseMessage response) <span style="color:#66d9ef">in</span> responses) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> (!response.IsSuccessStatusCode &amp;&amp; response.StatusCode != HttpStatusCode.Redirect) {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Error while getting file content: {response.ReasonPhrase}&#34;</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">string</span> filePath = Path.Combine(tempFolderPath, nameMapping[requestId]);
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> (response.StatusCode == HttpStatusCode.Redirect) {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">await</span> DownloadFileFromRedirectAsync(response.Headers.Location, filePath);
</span></span><span style="display:flex;"><span>		} <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">await</span> WriteContentToFileAsync(response.Content, filePath);
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		response.Content.Dispose();
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>You should be aware that the StatusCode returned may not always be 200 and can still be valid — many of my requests, for example, returned with a Redirect. 🔄
As a result, I had to implement handling for that as well.</p>
<p>When I finally managed to get all my requests working to fetch the files, the next problem arose&hellip; How could I download the files and save them to my tempFolderPath without loading all the files into memory?</p>
<p>If I did, I’d quickly run out of Memory. The solution to this turned out to be the following:</p>
<p><strong>Example class</strong></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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task DownloadFileFromRedirectAsync(Uri? redirectUri, <span style="color:#66d9ef">string</span> filePath) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">using</span> HttpResponseMessage redirectResponse = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> GraphClient.HttpProvider.SendAsync(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> HttpRequestMessage(HttpMethod.Get, redirectUri));
</span></span><span style="display:flex;"><span>	
</span></span><span style="display:flex;"><span>    redirectResponse.EnsureSuccessStatusCode();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> WriteContentToFileAsync(redirectResponse.Content, filePath);
</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">async</span> Task WriteContentToFileAsync(HttpContent content, <span style="color:#66d9ef">string</span> filePath) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> FileStream fileStream = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> FileStream(
</span></span><span style="display:flex;"><span>            filePath, 
</span></span><span style="display:flex;"><span>            FileMode.Create, 
</span></span><span style="display:flex;"><span>            FileAccess.Write, 
</span></span><span style="display:flex;"><span>            FileShare.None, 
</span></span><span style="display:flex;"><span>            <span style="color:#ae81ff">4096</span>, 
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> Stream contentStream = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> content.ReadAsStreamAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> contentStream.CopyToAsync(fileStream);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>By using using statements and ReadAsStream, each file only resides in Memory for a very short time before it is disposed. ♻️💡</p>
<p><strong>Example of a large file</strong></p>
<p><img src="https://jeppe-spanggaard.dk/images/MemoryUsages_hu_bdb7cf989460e88c.webp" srcset="/images/MemoryUsages_hu_bdb7cf989460e88c.webp 298w" sizes="(max-width: 760px) 100vw, 720px"
    width="298" height="194"
    alt="Memory Usages" loading="lazy" decoding="async"></p>
<h3 id="return-the-zip-file-">Return the ZIP file 🚀</h3>
<p>So how did I implement it all in an endpoint? I did it as follows, and it even works locally on my PC, allowing me to test it easily.
To avoid using too much Memory again, I return the ZIP file as a FileStreamResult. 🚀🗂️</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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(&#34;DownloadFilesFromSharepoint&#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; DownloadFilesFromSharepoint(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">		[HttpTrigger(AuthorizationLevel.Anonymous, &#34;post&#34;, Route = &#34;files/download&#34;)]</span> HttpRequest req) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	System.Guid guid = System.Guid.NewGuid();
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">string</span> tempFolderPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), guid.ToString());
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">string</span> zipFilePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), <span style="color:#e6db74">$&#34;{guid}.zip&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	_logger.LogInformation(<span style="color:#e6db74">$&#34;Zip file path: {zipFilePath}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	List&lt;FileInfoDTO?&gt;? fileInfo = <span style="color:#66d9ef">await</span> ...;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">if</span> (!System.IO.Directory.Exists(tempFolderPath)) {
</span></span><span style="display:flex;"><span>		System.IO.Directory.CreateDirectory(tempFolderPath);
</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> DownloadFilesFromPathsToTempFolderAsync(fileInfo, tempFolderPath);
</span></span><span style="display:flex;"><span>	System.IO.Compression.ZipFile.CreateFromDirectory(tempFolderPath, zipFilePath);
</span></span><span style="display:flex;"><span>	System.IO.Directory.Delete(tempFolderPath, <span style="color:#66d9ef">true</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(<span style="color:#66d9ef">new</span> FileStream(zipFilePath, FileMode.Open), <span style="color:#e6db74">&#34;application/zip&#34;</span>) {
</span></span><span style="display:flex;"><span>		FileDownloadName = <span style="color:#e6db74">&#34;files.zip&#34;</span>,
</span></span><span style="display:flex;"><span>		EnableRangeProcessing = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>	};
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>However, there’s one thing I haven’t yet found the perfect solution for—namely, deleting my ZIP files. 🗑️</p>
<p>Since I return them as a FileStreamResult and don’t load them into Memory as a byte[], I can’t delete the files immediately. Instead, I handle this with a timer job afterward, which I don’t think is the best solution. 🤷‍♂️⏳</p>
<p>But again, it solved the customer&rsquo;s problem, and they’re happy 😊, so I’m not planning to do much more about it. 🚀</p>
<h3 id="tldr">TL;DR</h3>
<p>This post explores how to programmatically download multiple files from SharePoint as a zip file using C# and Microsoft Graph API. 🚀</p>
<p><strong>Key takeaways:</strong></p>
<ul>
<li><strong>Storage Solution:</strong> Leveraged 250GB of App Service Premium storage to temporarily store files.</li>
<li><strong>Efficient API Usage:</strong> Minimized Graph API calls using batching to avoid throttling (bundling 20 requests into one).</li>
<li><strong>Memory Optimization:</strong> Used <code>ReadAsStream</code> and <code>FileStreamResult</code> to handle files efficiently without overloading RAM.</li>
<li><strong>ZIP File Handling:</strong> Created a ZIP file from the downloaded files and returned it via a streaming endpoint.</li>
<li><strong>Cleanup Challenge:</strong> Deleting ZIP files after returning them remains unresolved, currently handled via a timer job.</li>
</ul>
<p>It’s not perfect, but it works, and most importantly, it solved the customer’s problem. 😊</p>
]]></content:encoded></item></channel></rss>