Cloud applications frequently encounter transient database failures. A connection can temporarily fail because of network interruptions, database failover, throttling, resource pressure, or other infrastructure conditions.
For an Azure SQL application, immediately treating every database exception as a permanent failure can make an otherwise healthy application appear unreliable.
Modern .NET applications can implement SQL-specific retry behavior through Microsoft.Data.SqlClient, allowing transient failures to be retried without introducing a separate general-purpose resilience library for database operations.
The important part is not simply enabling retries. A production implementation must define which operations are safe to retry, how many attempts are allowed, how delays are calculated, and how failures are ultimately reported.
Why Azure SQL Applications Need Retry Logic
Consider a web API executing a database query:
API Request
|
v
Azure SQL
|
X
Transient FailureWithout retry handling:
Request
↓
SQL Failure
↓
HTTP 500With appropriate retry behavior:
Request
↓
SQL Operation
↓
Transient Failure
↓
Wait
↓
Retry
↓
SuccessThis can allow short-lived infrastructure problems to recover without immediately failing the application request.
However, retrying is not appropriate for every database error.
What Is a Transient Failure?
A transient failure is generally a temporary condition where repeating the operation later may succeed.
Examples can include:
Temporary connectivity problems
Database failover
Resource throttling
Temporary service availability issues
Network interruptions
A permanent failure is different.
For example:
Invalid SQL
Invalid table
Invalid credentials
Constraint violation
Invalid application dataRetrying these conditions usually does not solve the underlying problem.
The retry mechanism therefore needs to distinguish retryable conditions from non-retryable ones.
Microsoft.Data.SqlClient Retry Logic
Microsoft.Data.SqlClient provides retry-related functionality that can be configured for SQL Server and Azure SQL workloads.
A simplified configuration concept looks like:
using Microsoft.Data.SqlClient;
var connectionString =
builder.Configuration.GetConnectionString("Database");
var builder = new SqlConnectionStringBuilder(
connectionString)
{
ConnectRetryCount = 5,
ConnectRetryInterval = 2
};
builder.Services.AddSingleton(
new SqlConnectionStringBuilder(
builder.ConnectionString));The exact configuration should be aligned with the version of Microsoft.Data.SqlClient used by the application.
Also note that connection retry settings primarily address connection establishment and related transient connectivity scenarios. They should not be confused with automatically retrying every SQL command.
Connection Retry vs Command Retry
This distinction is important.
There are two different layers:
Application
|
v
Open SQL Connection
|
+---- Connection Retry
|
v
Execute SQL Command
|
+---- Command RetryA connection can succeed while a subsequent command encounters a transient failure.
Therefore, configuring connection retry does not automatically mean every database operation has a complete retry policy.
Using SqlConnection
A typical repository method can remain straightforward:
using Microsoft.Data.SqlClient;
public async Task<Customer?> GetCustomerAsync(
int customerId,
CancellationToken cancellationToken)
{
await using var connection =
new SqlConnection(connectionString);
await connection.OpenAsync(
cancellationToken);
const string sql = """
SELECT Id, Name, Email
FROM Customers
WHERE Id = @id
""";
await using var command =
new SqlCommand(sql, connection);
command.Parameters.AddWithValue(
"@id",
customerId);
await using var reader =
await command.ExecuteReaderAsync(
cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new Customer(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2));
}The query uses a parameter rather than concatenating user input into SQL.
Retry logic should not be used as a substitute for safe SQL construction.
Configuring Retry Logic
Microsoft.Data.SqlClient also provides retry-policy APIs for more granular scenarios.
A retry policy can define:
Retryable Errors
Maximum Attempts
Delay
Backoff Strategy
Transient Error DetectionConceptually:
var retryPolicy =
new SqlRetryLogicOption
{
NumberOfTries = 5,
DeltaTime = TimeSpan.FromSeconds(2),
MaxTimeInterval =
TimeSpan.FromSeconds(30)
};The exact API surface depends on the installed Microsoft.Data.SqlClient version, so developers should verify the supported members for their package version.
Exponential Backoff
A retry system should avoid sending all retry requests immediately.
Consider:
Attempt 1 → Immediate
Attempt 2 → Short delay
Attempt 3 → Longer delay
Attempt 4 → Longer delayThis is commonly called exponential backoff.
A simplified implementation is:
private static TimeSpan GetDelay(
int attempt)
{
var seconds = Math.Pow(2, attempt);
return TimeSpan.FromSeconds(
Math.Min(seconds, 30));
}For production systems, a retry strategy should also consider jitter so that many application instances do not retry at exactly the same moment.
Why Jitter Matters
Imagine 500 API instances all experience the same transient database event.
If every instance retries after exactly five seconds:
500 Requests
|
v
Failure
|
v
Exactly 5 seconds
|
v
500 RetriesThat can create another load spike.
Jitter introduces controlled randomness into the delay:
Request A → 2.1s
Request B → 2.8s
Request C → 3.4s
Request D → 2.5sThe exact strategy should be chosen according to the application's workload and resilience requirements.
Retrying Commands Safely
Retrying a SELECT query is usually easier to reason about than retrying a state-changing operation.
Consider:
UPDATE Accounts
SET Balance = Balance - @amount
WHERE Id = @accountId;If the client loses its connection after the database processes the update but before the client receives the response, the application may not know whether the operation succeeded.
Automatically executing the update again could produce unintended behavior.
This creates the classic ambiguity:
Client
|
| UPDATE
v
Database
|
| Operation succeeds
X
Network failure
|
v
Client thinks it failedRetrying without considering idempotency can therefore be dangerous.
Idempotency for Write Operations
For critical writes, use an idempotency strategy where appropriate.
For example, an application can associate a unique operation ID:
public sealed record PaymentOperation(
Guid OperationId,
int AccountId,
decimal Amount);The database can record the operation ID.
Before applying the operation again, the application can determine whether the operation has already been processed.
Conceptually:
Operation ID
|
v
Already Processed?
/ \
Yes No
| |
Return Execute
Result OperationThis is much safer than blindly retrying every write.
Retry Budget
Retries should have a finite budget.
For example:
const int maxAttempts = 3;Then:
for (var attempt = 1;
attempt <= maxAttempts;
attempt++)
{
try
{
return await ExecuteAsync(
cancellationToken);
}
catch (SqlException) when (
attempt < maxAttempts)
{
await Task.Delay(
GetDelay(attempt),
cancellationToken);
}
}
throw new InvalidOperationException(
"The database operation could not be completed.");This example intentionally simplifies transient-error classification. Production code should retry only errors that the SQL client identifies as appropriate for the configured policy.
Respect Cancellation
Retry delays should respect the request's cancellation token.
await Task.Delay(
delay,
cancellationToken);This is particularly important in ASP.NET Core.
If the user disconnects while the server is waiting to retry a database operation, the application should not continue unnecessary work indefinitely.
Do Not Retry Everything
A dangerous pattern is:
catch (Exception)
{
await Task.Delay(1000);
// Retry
}This can retry:

Join the conversation! Your thoughts help the community grow.