Polling Steps
The polling pattern lets a step wait for an external system to reach a desired state without holding a thread. Instead of sleeping in a loop, the step returns StepStatus.Pending and the runtime reschedules the job after a configurable delay via IStepDispatcher.ScheduleStepAsync.
The Problem
Blocking a thread while waiting for an external API is expensive and ties up worker threads. With polling, each check-in is a short-lived execution that re-schedules itself via the runtime adapter:
Attempt 1 → response: { "status": "processing" } → Pending → wait 10s
Attempt 2 → response: { "status": "processing" } → Pending → wait 10s
Attempt 3 → response: { "status": "accepted" } → Succeeded → continue
PollableStepHandler<TInput>
Extend this abstract base class instead of IStepHandler<T> directly:
public sealed class CheckJobStatusHandler
: PollableStepHandler<CheckJobStatusInput>
{
private readonly HttpClient _http;
public CheckJobStatusHandler(IHttpClientFactory factory)
=> _http = factory.CreateClient("ExternalApi");
protected override async ValueTask<(JsonElement Result, bool IsJson)> FetchAsync(
IExecutionContext ctx,
IFlowDefinition flow,
IStepInstance<CheckJobStatusInput> step)
{
var response = await _http.GetAsync($"/jobs/{step.Inputs.JobId}");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return (doc.RootElement.Clone(), true);
}
}
The base class manages:
- Poll start time tracking across reschedules
- Attempt counting
- Condition evaluation against the response
- Timeout enforcement
- Returning
StepStatus.PendingwithDelayNextStep— the engine callsReleaseDispatchAsyncthenIStepDispatcher.ScheduleStepAsync(delay)to reschedule via the active runtime adapter - Guarding
pollConditionPathagainst non-JSON responses — returnIsJson: falsefromFetchAsyncwhen the body could not be parsed as JSON. If apollConditionPathis configured the step fails immediately withPolling with 'pollConditionPath' requires a JSON response body.instead of polling to timeout; with no condition path the raw result is used as-is.
Input Class
Your input class must implement IPollableInput:
public sealed class CheckJobStatusInput : IPollableInput
{
// Your custom fields
public string? JobId { get; set; }
// --- Required by IPollableInput ---
[JsonPropertyName("pollEnabled")]
public bool PollEnabled { get; set; } = true;
[JsonPropertyName("pollIntervalSeconds")]
public int PollIntervalSeconds { get; set; } = 10;
[JsonPropertyName("pollTimeoutSeconds")]
public int PollTimeoutSeconds { get; set; } = 120;
[JsonPropertyName("pollMinAttempts")]
public int PollMinAttempts { get; set; } = 1;
[JsonPropertyName("pollConditionPath")]
public string? PollConditionPath { get; set; }
[JsonPropertyName("pollConditionEquals")]
public object? PollConditionEquals { get; set; }
// Internal state — managed by PollableStepHandler, persisted between attempts
[JsonPropertyName("pollStartedAtUtc")]
public string? PollStartedAtUtc { get; set; }
[JsonPropertyName("pollAttempt")]
public int? PollAttempt { get; set; }
}
Note
Step inputs are round-tripped through System.Text.Json between execution attempts (across rescheduled jobs or channel messages) using Web defaults — camelCase on write, case-insensitive matching on read — so [JsonPropertyName] is optional: PollStartedAtUtc survives as pollStartedAtUtc without it. Add the attribute only when you want an explicit wire name; the bundled CallExternalApiStepInput uses __pollStartedAtUtc / __pollAttempt to keep internal poll state visually distinct from user inputs.
Polling Configuration (Manifest Inputs)
Configure polling behaviour in StepMetadata.Inputs:
| Input Key | Type | Default | Description |
|---|---|---|---|
pollEnabled |
bool |
true |
When false, FetchAsync runs once and the result is returned directly |
pollIntervalSeconds |
int |
10 |
Seconds between poll attempts |
pollTimeoutSeconds |
int |
120 |
Total time before the step fails with StepStatus.Failed and reason Polling timed out after {n} seconds. |
pollMinAttempts |
int |
1 |
Minimum number of attempts before a positive condition is accepted |
pollConditionPath |
string? |
— | Dot-notation JSON path evaluated against the response |
pollConditionEquals |
object? |
— | Expected value at the path. Compared by case-insensitive string equality after normalisation, so "accepted", 42 and true all work. If omitted, any non-empty value at the path succeeds. |
["check_job"] = new StepMetadata
{
Type = "CheckJobStatus",
Inputs = new Dictionary<string, object?>
{
["jobId"] = "@triggerBody()?.jobId",
["pollEnabled"] = true,
["pollIntervalSeconds"] = 15,
["pollTimeoutSeconds"] = 300,
["pollMinAttempts"] = 2,
["pollConditionPath"] = "status",
["pollConditionEquals"] = "accepted"
}
}
Condition Evaluation
Given a response { "id": 1, "status": "accepted" }:
pollConditionPath = "status",pollConditionEquals = "accepted"→ matches whenstatus == "accepted"pollConditionPath = "id"(nopollConditionEquals) → matches when the value at the path is present and non-empty. Any number (including0) and any boolean (includingfalse) count as a match; onlynull, blank strings, empty arrays and empty objects do not. UsepollConditionEqualswhen you need to exclude0/false.- No
pollConditionPath→ the whole response payload is tested for non-empty content; anull,{},[]or blank-string body does not satisfy the condition and polling continues until the timeout.
Nested paths work: pollConditionPath = "result.state" evaluates response.result.state.
Timeout Behaviour
If the elapsed time since the first poll attempt exceeds pollTimeoutSeconds, the step fails with:
StepStatus.Failed
FailedReason: "Polling timed out after 120 seconds."
Values are clamped before use: pollIntervalSeconds has a floor of 1 s, pollMinAttempts a floor of 1, and pollTimeoutSeconds is raised to at least pollIntervalSeconds — a timeout shorter than one interval is silently promoted to one interval, and the failure message reports the clamped value.
From the dashboard you can retry the step, which resets the poll clock and starts fresh.
Disabling Polling
Set pollEnabled = false to make a one-shot call:
["fetch_config"] = new StepMetadata
{
Type = "CallExternalApi",
Inputs = new Dictionary<string, object?>
{
["path"] = "/config",
["pollEnabled"] = false // single HTTP call, no retry loop
}
}
Example: OrderFulfillment WMS Integration
// From OrderFulfillmentFlow:
["submit_to_wms"] = new StepMetadata
{
Type = "CallExternalApi",
RunAfter = new RunAfterCollection { ["fetch_orders"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
["method"] = "GET",
["path"] = "/posts/1",
["pollEnabled"] = true,
["pollIntervalSeconds"] = 10,
["pollTimeoutSeconds"] = 120,
["pollConditionPath"] = "id" // succeeds when response.id is non-empty
}
}
CallExternalApiStep extends PollableStepHandler<CallExternalApiStepInput> and calls the configured HTTP endpoint on each attempt. Polling completes as soon as response.id resolves to a non-empty value, and the next step runs.