My provisioning engine had just created a SharePoint site. I could open it in the browser. Title rendered, default library in place, everything fine.
The very next line of code asked Microsoft Graph for that site’s ID.
- Not found.
I created you two seconds ago. I’m looking at you. And Graph, with a straight face: no such site.
Welcome to eventual consistency between SharePoint and Graph. The site creation goes through CSOM against SharePoint directly; the ID lookup goes through Graph, which has its own view of the world that updates on its own schedule. For a freshly created site, that schedule can lag from seconds to a couple of minutes. Your code sits exactly in that gap.
TL;DR
After creating a SharePoint resource, Graph may 404 on it for a while even though it exists. Don’t treat that 404 as an error and don’t solve it with a general-purpose retry hammer. Write a narrow in-activity retry loop that catches only 404, backs off with a cap, and gives up after a bounded number of attempts. Then let the other retry layers (HTTP handlers for 429, the orchestrator’s policy for everything else) do their own jobs.The Loop
This runs at the end of my site creation activity, after the site exists in SharePoint:
private async Task<string> ResolveGraphSiteIdWithRetryAsync(string siteUrl)
{
var uri = new Uri(siteUrl);
var serverRelativePath = uri.AbsolutePath.TrimStart('/');
var siteIdentifier = $"{uri.Host}:/{serverRelativePath}";
const int maxAttempts = 8;
var delay = TimeSpan.FromSeconds(10);
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var site = await graphClient.Sites[siteIdentifier].GetAsync();
return site?.Id
?? throw new InvalidOperationException($"Graph returned null site ID for {siteUrl}");
}
catch (ODataError ex) when (ex.ResponseStatusCode == 404 && attempt < maxAttempts)
{
// Site not yet indexed in Graph - wait and retry
logger.LogInformation(
"Graph site not found yet (attempt {Attempt}/{Max}), retrying in {Delay}s...",
attempt, maxAttempts, delay.TotalSeconds);
await Task.Delay(delay);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 1.5, 60));
}
}
throw new InvalidOperationException($"Could not resolve Graph site ID for {siteUrl} after {maxAttempts} attempts.");
}
What’s happening here?
- The site is addressed as
{hostname}:/sites/{alias}- Graph’s path-based site addressing, so no ID is needed to ask for the ID. The thing it hands back is the composite{hostname},{siteCollectionId},{webId}form, three parts comma-joined, which is why you can’t just build one yourself: two of the three are GUIDs with no relationship to the URL. - The
catchfilter is deliberately narrow:ODataErrorand status 404 and attempts remaining. A 403 means permissions and should fail now. A 401 means auth and should fail now. Only “not found” means “not found yet”. - Backoff starts at 10 seconds and multiplies by 1.5 with a 60-second cap: 10, 15, 22, 34, 51, 60, 60. Eight attempts covers about four minutes, which in practice covers the Graph indexing lag with room to spare.
- When the loop gives up, it throws an ordinary exception, and something interesting happens one level up. More on that next.
Three Layers of Retry, Three Different Jobs
This loop lives inside a Durable Functions activity that already has retries around and below it. It took me a while to see these as separate tools rather than redundant paranoia, so here’s the layer map:
Layer 1: the HTTP pipeline. The Graph SDK’s Kiota handlers honor Retry-After on 429s; my CSOM calls go through PnP’s ExecuteQueryRetry, which does the same for SharePoint throttling. This layer retries individual requests over seconds. It handles “you’re going too fast”.
Layer 2: in-activity loops like the one above. This layer knows the semantics of the operation. No HTTP handler can know that a 404 right after site creation means “wait”, while a 404 anywhere else means “broken”. Only code that knows a create just happened can decide that. Seconds to minutes, single operation.
Layer 3: the orchestrator’s retry policy. Every activity call gets 10 attempts with exponential backoff from 5 seconds to 5 minutes. This layer retries whole activities, which is why the loop’s final throw is a plain InvalidOperationException: if four minutes of polite waiting wasn’t enough, the orchestrator reruns the entire activity. The rerun finds the site already exists, skips creation, and effectively buys the resolution another eight attempts, minutes later. The layers compose.
The failure mode to avoid is collapsing these into one: cranking the orchestrator policy to 30 attempts because “Graph is flaky”. Then every genuine failure, including permanent config errors, grinds through half an hour of retries, and you still haven’t expressed the one thing you actually knew - that a 404 after a create is expected.
Each layer catches what it can name. The HTTP layer names status codes. The activity names operations. The orchestrator names steps.
Naming Things: Reading a Graph SDK Exception
All of that depends on the middle layer being able to tell a 404 from a 403, so it’s worth spelling out where ex.ResponseStatusCode in that catch filter comes from. Most of the advice you’ll find online is written against a type that no longer exists: SDK v5 replaced ServiceException with ODataError.
catch (ODataError ex)
{
var status = ex.ResponseStatusCode; // 404, 403, 401...
var code = ex.Error?.Code; // "itemNotFound", "accessDenied"
var msg = ex.Error?.Message;
var inner = ex.Error?.InnerError; // request-id and date, for support tickets
}
Status code for control flow, Error?.Code when two failures share a status and you need to tell them apart, and InnerError for the request ID that support will ask for.
It’s Not Just Site IDs
The same lag shows up one step later in my pipeline, wearing a different costume. After the site exists, an activity pulls content types from the content type hub. On a fresh site, that pull can fail with a transient HTTP 400, or report success while the content types take their time actually appearing - and a missing content type breaks the PnP template apply that comes after, because child content types have no parent to inherit from.
The pattern there is verify-then-escalate: after requesting the pull, the activity polls for each content type’s presence with a short bounded backoff. Genuinely present: done. Still missing after the window: throw a transient error so the orchestrator retries the whole pull, since a content type that never got pulled won’t appear no matter how long you stare at it.
There’s a 503-shaped sibling worth recognising too:
Microsoft.Graph.Models.ODataErrors.ODataError: The underlying service is
experiencing a high load and is unable to fulfill your request. Please retry
after a brief backoff period.
Same treatment as the 404: transient, retry it. The catch is that this one asks you to back off without telling you how long - there’s no Retry-After header to honour, so the Kiota retry handlers have nothing to work with and the delay has to come from your own code. Layer 2’s job again.
Same philosophy as the 404 loop, different escalation point. Wait briefly for the thing that’s probably coming; retry the operation that produces it if it demonstrably isn’t. What you never get to do is assume that “the API returned success” and “the resource is usable” happen at the same time. In Microsoft 365 provisioning, they’re separated by an index, a cache, or a queue you can’t see.
Gotchas
- Bound every wait. Every retry loop in the engine has a max attempt count and a delay cap, and the activity level has the orchestrator’s policy above it as a final bound. An unbounded “wait until it appears” loop is an outage with extra steps.
Task.Delayis fine here, in an activity. In an orchestrator function it would be a determinism violation - there you’d usecontext.CreateTimer. Activities are plain code; orchestrators replay. Know which kind of function you’re standing in before you sleep.- Log the attempt number and the delay. “Graph site not found yet (attempt 3/8), retrying in 22s” in the logs turns a scary multi-minute silence into a visible, healthy process. My first version logged nothing and I spent an evening convinced the function was hung.
- Don’t cache your way around it. My first instinct was to compute the Graph site ID from the URL format and skip the lookup. Tempting, but the composite ID contains GUIDs you can’t derive, and half-guessed IDs fail later in worse places. Wait for the real one.
- The lag varies wildly. Dev tenant: usually under 15 seconds. Production tenant under load: I’ve seen over two minutes. Tune
maxAttemptsfor the worst tenant you serve, not the one on your laptop.
Wrapping Up
A 404 immediately after a create isn’t an error, it’s a timestamp - proof you’re faster than the index. Catch exactly that case in a narrow, bounded, backing-off loop inside the activity, and leave throttling to the HTTP layer and rerun-the-step to the orchestrator. Retry layers work when each one handles only what it can name.
The “rerun the step” half of that sentence is the orchestrator’s job, and Durable Functions: A Function That Sleeps for a Week explains why it can be trusted with it.
