Resilient Actions
One night, Cron was generating jobs as expected. The queue was accepting them as expected. The Worker was running as expected.
Then a single Action got stuck.
As a result:
- Cron continued generating new jobs;
- the queue continued growing;
- other Actions stopped executing;
- the issue was resolved only after a restart.
This is a classic example of how system reliability is determined not by how it behaves in a perfect world, but by how it handles failures.
An Action Must Be Designed for Failure
Every Action operates in a hostile environment:
- the network may stop responding;
- the database may become slow;
- an external API may hang;
- the server may restart;
- the action may be cancelled by the user.
For this reason, an Action should be designed as if failure is inevitable.
Always Use CancellationToken
Bad:
await http.GetStringAsync(url);
Good:
await http.GetStringAsync(url, ct);
Bad:
await Task.Delay(TimeSpan.FromMinutes(5));
Good:
await Task.Delay(TimeSpan.FromMinutes(5), ct);
If an operation does not support cancellation, the Worker cannot stop the Action gracefully.
Always Define Timeouts
Even when a CancellationToken is used.
Bad:
var http = new HttpClient();
Good:
var http = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
External resources are not required to respond quickly.
Sometimes they do not respond at all.
Distinguish Timeouts from Cancellations
A timeout and a cancellation are not the same thing.
A timeout usually means:
the operation took too long and may be safe to retry.
A cancellation usually means:
execution was intentionally stopped and should not be retried.
In .NET both situations may appear as a TaskCanceledException.
Example:
try
{
await http.GetStringAsync(url, ct);
}
catch (TaskCanceledException ex)
when (ex.InnerException is TimeoutException)
{
logger.LogWarning("Request timed out");
throw;
}
catch (OperationCanceledException)
{
logger.LogInformation("Action was cancelled");
throw;
}
Without this distinction, logs may show both situations as simple cancellations even though the retry decision is often the opposite.
A timeout is typically a transient failure.
A cancellation is typically an intentional stop request.
Log Execution Stages
Often the most useful log messages are the simplest ones:
ReportProgress(0, "Loading data");
ReportProgress(0, "Downloaded data");
ReportProgress(0, "Saving data");
ReportProgress(0, "Completed");
If an Action gets stuck, the last message immediately identifies where execution stopped.
For example:
Loading data
It becomes obvious that the problem occurred before the database save phase.
Make Actions Idempotent
After a failure, an Action may execute again.
If re-execution creates duplicate data, the system is not resilient.
Bad:
INSERT INTO Tokens (...)
Better:
INSERT ...
WHERE NOT EXISTS (...)
or
MERGE
or
UNIQUE INDEX
A repeated execution should be safe.
Do Not Store State in Memory
Bad:
private static int Processed;
The value will be lost after a restart.
Instead, store state in:
- a database;
- a queue;
- a persistent state store.
An Action should survive process restarts.
Break Large Operations into Smaller Actions
Bad:
DownloadAllHistory();
ProcessAllHistory();
SaveAllHistory();
Good:
download
process
aggregate
publish
Smaller Actions are easier to:
- cancel;
- retry;
- troubleshoot.
Verify Behavior After a Timeout
It is not enough to verify that an Action was cancelled.
You must prove that the queue continues to operate.
A good integration test is:
- The first job becomes stuck.
- A timeout occurs.
- The second job executes successfully.
- The queue continues processing.
This scenario closely matches real production behavior.
A Practical Rule
Whenever an Action contains an:
await
ask yourself:
What happens if this operation hangs forever?
If the answer is:
Nothing good.
Then the Action is not production-ready yet.
Summary
A simple, resilient Action has six characteristics:
- supports
CancellationToken; - defines reasonable timeouts;
- distinguishes timeouts from cancellations;
- logs key execution stages;
- is idempotent;
- safely handles re-execution.
In an ideal world, failures never happen.
In production, they happen every day.
A good Action is not one that never fails.
A good Action is one after which the system continues to operate. 🚀🐈⬛
Top comments (2)
Nice list, and the timeout plus CancellationToken pair has one detail that helps with the logging point. Since .NET 5, when HttpClient.Timeout fires you get a TaskCanceledException whose InnerException is a TimeoutException, so a catch can check ex.InnerException is TimeoutException and log "timed out, safe to retry" separately from "the job was cancelled, stop". Without that check they both look like the same cancellation in the log, and the retry decision is usually the opposite, so it's worth the two extra lines.
Great catch. Timeout and cancellation may surface as the same exception type, but they require different retry behavior. Thanks for pointing that out. 👍