Actively polling an endpoint in .NET 6

Actively polling an endpoint in .NET 6

I was recently working on a document e-signing solution that had me integrating with PandaDoc. The whole process was very straightforward, but what I liked the most was that I found out about a new .NET API that I had not seen before!

The two main steps in the process were uploading a PDF with placeholders (called Field Tags) to PandaDoc and then sharing the document with a customer (with the placeholders being converted into fillable form fields by then). Seems simple, and it was, but take a look at the documentation:

Between the two main points I mentioned above, there is another little step: "Wait for the Document to enter draft status". It's not surprising, really - it would actually be surprising if the document was ready the moment you upload it - what would I be paying for, then? 😁 There are two options here - you can either:

  • set up a webhook listener, and the document status changes will be sent to you
  • actively poll the document status endpoint until the desired status is reached

I would usually prefer the first option, but in my case, it did not make much sense to go that way without a major rewrite of a few processes, so I decided to go with active polling.

In my proof-of-concept implementation, I used Task.Delay to introduce some delay between the requests to avoid being blocked by PandaDoc.

var documentCreationOptions = new DocumentCreationOptions(...);
var (documentId, documentStatus) = await _pandaDocClient.CreateDocument(file, fileName, documentCreationOptions);
while (status != "document.draft")
{
    await Task.Delay(PollingInterval);
    documentStatus = await _pandaDocClient.GetDocumentStatus(documentId);
    if (status != "document.uploaded" && status != "document.draft")
    {
        throw new InvalidDocumentStatusException($"Invalid document status. DocumentId: {documentId}, Status: {documentStatus}.")
    }
}
await _pandaDocClient.SendDocument(documentId);

It worked, but when I was cleaning up the solution and moving it to the main project, I said to myself: there should be a nicer way to do this - it's 2022, after all.

A quick Google search revealed what I was looking for:

A New Modern Timer API In .NET 6 - PeriodicTimer
A New Modern Timer API In .NET 6 - PeriodicTimer

The project was just migrated to .NET 6 so I got lucky, the cleaned-up version is not very different, but I like it much more:

var documentCreationOptions = new DocumentCreationOptions(...);
var (documentId, documentStatus) = await _pandaDocClient.CreateDocument(file, fileName, documentCreationOptions);
using (var timer = new PeriodicTimer(PollingInterval))
{
    while (status != "document.draft" && await timer.WaitForNextTickAsync())
    {
        documentStatus = await _pandaDocClient.GetDocumentStatus(documentId);
        if (status != "document.uploaded" && status != "document.draft")
        {
            throw new InvalidDocumentStatusException($"Invalid document status. DocumentId: {documentId}, Status: {documentStatus}.")
        }
    }
}
await _pandaDocClient.SendDocument(documentId);

So what's to like here? At first glance, all it did was introduce another nesting level for the using statement. Well, that one can be easily fixed by moving the waiting part to a separate method and converting the using statement to a using declaration.

So under the hood, both approaches use the TimerQueueTimer class. The difference is that the new PeriodicTimer allocated just one TimerQueueTimer instance while each Task.Delay() creates a new object that later needs to be cleaned up too. This might not seem like a huge gain - especially here, since the wait time for the document state change should not be too long. But in a large system, those allocations might pile up, so it's always nice to make less of them when possible.

The PeriodicTimer is not a silver bullet and in some cases, Task.Delay will be the better option. With the new timer, the time between one operation finishing and the next one starting will be at most what you set as the interval. With Task.Delay, the time will be more or less what you choose. Here's a little diagram to help visualize it:

So, as usual, use the tool that makes the most sense in your situation.

I know that some people will come and say that I could've used the Timer class and be done with it. My first answer would be - which one? On a more serious note, the one thing that disqualified Timer for me in this scenario is that Timer callbacks can overlap, which does not make much sense when polling an external endpoint.

If you want more insight into how PeriodicTimer came to be, head over to the GitHub issue where it all happened:

API proposal: Modern Timer API · Issue #31525 · dotnet/runtime
To solve of the common issues with timers: The fact that it always captures the execution context is problematic for certain long lived operations (https://github.com/dotnet/corefx/issues/32866) Th...

Cover photo by Renel Wackett on Unsplash

Show Comments