Health Checks: Is the SMS API Alive and Ready? – Web API | Lesson 95
Introduction
The SMS API now runs in the background as well as in requests. When something goes wrong, people ask one question first: is it working? Today nobody can answer that without opening logs and guessing.
A health check lets the API answer for itself. In this lesson, the SMS gets two addresses that machines and
people can call at any time: /health/live and /health/ready.
By the end of this lesson, you will be able to:
- Explain the difference between "alive" and "ready".
- Write a custom health check with
IHealthCheck. - Check SQL Server and the background worker from Lesson 94.
- Return 200 or 503 with a clear body, and never leak error details.
- Keep health endpoints open, fast, and safe from rate limits.
Why this matters
In real deployments, the API runs on more than one server behind a load balancer. The load balancer sends each request to one of the servers. It needs to know which servers can take work right now.
It finds out by calling a health address every few seconds. A server that answers 503 gets no traffic until it recovers, so parents never land on the broken one.
Health checks also feed monitoring. A dashboard can show red the moment SchoolDb stops answering, before the office phones anyone.
The concept
Think of the school gate at 7 am. The guard can ask two different questions.
"Is the school open?" Someone is inside and the gate opens.
"Is the school ready for classes?" Teachers are present, the lights work, the water is running. A school can be open but not yet ready.
Example: live versus ready
| Question | SMS address | What it checks | If it fails |
|---|---|---|---|
| Is the process running? | /health/live | nothing: answering at all is the proof | restart the process |
| Can it do its work? | /health/ready | SchoolDb answers; the message worker runs | send no traffic until it recovers |
The difference matters. If SchoolDb is down, restarting the API does not fix it. So the database belongs in "ready", never in "live".
Example: three answers
| Status | Meaning | HTTP code | Example in the SMS |
|---|---|---|---|
| Healthy | everything works | 200 | database answered, worker running |
| Degraded | works, but something needs attention | 200 | 1,500 messages waiting; last reminder run failed |
| Unhealthy | cannot do its job | 503 | SchoolDb does not answer; the worker has stopped |
Degraded still answers 200, so traffic keeps flowing. It is a warning for people, not a stop sign for the load balancer.
Example: a health check in a few lines
A check is a class with one method that returns a HealthCheckResult:
public class DiskCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
long freeBytes = new DriveInfo("C").AvailableFreeSpace;
return Task.FromResult(freeBytes > 1_000_000_000
? HealthCheckResult.Healthy("Enough disk space.")
: HealthCheckResult.Degraded("Less than 1 GB free."));
}
}
// Program.cs
builder.Services.AddHealthChecks().AddCheck<DiskCheck>("disk");
app.MapHealthChecks("/health");
Health checks are built into ASP.NET Core, so no package is needed. MapHealthChecks runs every check and
answers with the overall status.
Apply it to the SMS
Step 1: can we reach SchoolDb?
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SchoolApp.Data;
namespace SchoolApp.Api.Health;
// Lesson 95: can the API reach SchoolDb right now? It opens a connection and asks the smallest possible question.
public sealed class SchoolDbHealthCheck : IHealthCheck
{
private readonly SqlConnectionFactory _connections;
public SchoolDbHealthCheck(SqlConnectionFactory connections)
{
_connections = connections;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
await using SqlConnection connection = _connections.Create();
await connection.OpenAsync(cancellationToken);
await using SqlCommand command = new SqlCommand("SELECT 1;", connection);
await command.ExecuteScalarAsync(cancellationToken);
return HealthCheckResult.Healthy("SchoolDb answered.");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// The exception goes to the log with the result; the response shows only the description,
// because SQL Server's messages can name servers and databases.
return HealthCheckResult.Unhealthy("SchoolDb did not answer.", exception);
}
}
}
SELECT 1 is the smallest question SQL Server can answer. It proves the network, the sign-in, and the server,
without reading any school data.
The exception is passed to Unhealthy, so it reaches the log. The response shows only "SchoolDb did not answer."
Step 2: is the background work keeping up?
Lesson 94's worker sends every receipt and reminder. If it stops, the API still answers requests, but no message will ever go out. Nobody would notice for days.
Two small additions give the check something to read. The queue reports its length:
// Lesson 95: how many messages are waiting, for the health check.
public int Waiting => _channel.Reader.Count;
The job remembers its last run:
// Lesson 95: the result of the latest run, for the health check. Null until the first run.
public FeeReminderRun? LastRun { get; private set; }
// ...set in RunOnceAsync: new FeeReminderRun(today, Succeeded: true), or Succeeded: false in the catch.
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SchoolApp.Api.BackgroundJobs;
namespace SchoolApp.Api.Health;
// Lesson 95: is the background work from Lesson 94 keeping up?
// Unhealthy: the message worker has stopped, so no receipt or reminder will ever be sent.
// Degraded: the API still works, but too many messages are waiting, or the last reminder run failed.
public sealed class BackgroundJobsHealthCheck : IHealthCheck
{
public const int MaxWaitingMessages = 1_000;
private readonly IEnumerable<IHostedService> _hostedServices;
private readonly GuardianMessageQueue _queue;
// Hosted services are singletons, so asking for all of them returns the very objects the app is running.
public BackgroundJobsHealthCheck(IEnumerable<IHostedService> hostedServices, GuardianMessageQueue queue)
{
_hostedServices = hostedServices;
_queue = queue;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
Dictionary<string, object> data = new Dictionary<string, object> { ["waitingMessages"] = _queue.Waiting };
// ExecuteTask is the running ExecuteAsync. If it has completed while the app is running, the worker is dead.
GuardianMessageWorker? worker = _hostedServices.OfType<GuardianMessageWorker>().SingleOrDefault();
if (worker?.ExecuteTask is null || worker.ExecuteTask.IsCompleted)
{
return Task.FromResult(HealthCheckResult.Unhealthy("The guardian message worker is not running.", data: data));
}
if (_queue.Waiting > MaxWaitingMessages)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"{_queue.Waiting} messages are waiting; the worker is falling behind.", data: data));
}
FeeReminderRun? lastRun = _hostedServices.OfType<FeeReminderJob>().SingleOrDefault()?.LastRun;
if (lastRun is { Succeeded: false })
{
data["lastReminderRun"] = lastRun.Date.ToString("yyyy-MM-dd");
return Task.FromResult(HealthCheckResult.Degraded(
$"The fee reminder run for {lastRun.Date:yyyy-MM-dd} failed.", data: data));
}
return Task.FromResult(HealthCheckResult.Healthy("The message worker is running.", data));
}
}
ExecuteTask is the running ExecuteAsync of a BackgroundService. While the app runs, it should never be
finished. If it is, the worker has died.
To read the queue's length, the check needs the queue class itself, not just the interface. So Program.cs now
registers GuardianMessageQueue once and points IGuardianMessageQueue at the same object.
Step 3: a body people can read
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace SchoolApp.Api.Health;
// Lesson 95: the body of /health/live and /health/ready. The built-in writer prints only "Healthy"; this one also
// lists each check, so a person can see which part is unwell. Exception details never go into the response:
// they are in the log.
// This is not an ApiResponse (Lesson 80) on purpose: health endpoints are read by load balancers and monitoring
// tools, which look at the status code (200 or 503) and expect a simple, standard-looking body.
public static class HealthReportWriter
{
public static Task WriteAsync(HttpContext context, HealthReport report)
{
var body = new
{
status = report.Status.ToString(),
durationMs = (int)report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(entry => new
{
name = entry.Key,
status = entry.Value.Status.ToString(),
durationMs = (int)entry.Value.Duration.TotalMilliseconds,
description = entry.Value.Description,
data = entry.Value.Data.Count > 0 ? entry.Value.Data : null
})
};
context.Response.Headers.CacheControl = "no-store"; // a health answer is only true right now
return context.Response.WriteAsJsonAsync(body);
}
}
The built-in answer is one word, such as Healthy. The writer adds each check, its time, and its data, so the
person on call knows which part is unwell.
Step 4: two endpoints
// Lesson 95: health checks. "ready" ones run for /health/ready; /health/live runs none.
// The database check gets 3 seconds: a check that hangs is as bad as one that fails.
builder.Services.AddHealthChecks()
.AddCheck<SchoolDbHealthCheck>("database", tags: ["ready"], timeout: TimeSpan.FromSeconds(3))
.AddCheck<BackgroundJobsHealthCheck>("background-jobs", tags: ["ready"]);
// ...after app.MapControllers():
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
ResponseWriter = HealthReportWriter.WriteAsync
}).AllowAnonymous().DisableRateLimiting();
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = HealthReportWriter.WriteAsync
}).AllowAnonymous().DisableRateLimiting();
Tags pick which checks run where. Predicate = _ => false runs none, so /health/live only proves that the
process can answer.
AllowAnonymous is needed because of Lesson 85's fallback policy: every endpoint needs a token unless it says
otherwise. A load balancer has no token.
DisableRateLimiting keeps Lesson 92's limits away. A health probe refused with 429 looks exactly like a dead
server.
Step 5: run it
With SchoolDb running:
GET /health/live 200 Cache-Control: no-store
{"status":"Healthy","durationMs":0,"checks":[]}
GET /health/ready 200
{"status":"Healthy","durationMs":2050,"checks":[{"name":"database","status":"Healthy","durationMs":2048,"description":"SchoolDb answered.","data":null},{"name":"background-jobs","status":"Healthy","durationMs":1,"description":"The message worker is running.","data":{"waitingMessages":0}}]}
GET /health/ready 200 (again: the database check took 3 ms)
GET /api/students 401 (no token: the rest of the API is still locked)
The first database check took 2 seconds: it opened the first connection of the day. After that, the connection pool (Lesson 57) made it 3 ms.
Example: SchoolDb is unreachable
We started the API with a connection string pointing at a port where nothing listens:
GET /health/ready 503
{"status":"Unhealthy","durationMs":2116,"checks":[{"name":"database","status":"Unhealthy","durationMs":2098,"description":"SchoolDb did not answer.","data":null},{"name":"background-jobs","status":"Healthy","durationMs":1,"description":"The message worker is running.","data":{"waitingMessages":0}}]}
GET /health/live 200
fail: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[103]
Health check database with status Unhealthy completed after 2098.6367ms with message 'SchoolDb did not
answer.' Microsoft.Data.SqlClient.SqlException (0x80131904): A network-related or instance-specific error ...
Ready says 503, so a load balancer would stop sending traffic here. Live still says 200, so nothing restarts the process for a problem a restart cannot fix. The SQL Server error is in the log, not in the response.
The same run had RateLimits:RequestsPerMinute set to 3:
6 x GET /api/school 200 200 200 429 429 429
6 x GET /health/live 200 200 200 200 200 200
Example: SchoolDb hangs
A database that does not answer at all is worse than one that refuses. We pointed the API at an address that never replies:
GET /health/ready 503 (total 3.2 s)
{"status":"Unhealthy","durationMs":3026,"checks":[{"name":"database","status":"Unhealthy","durationMs":3005,"description":"A timeout occurred while running check.","data":null}, ...]}
Without the 3-second timeout, the check would have waited for SQL Server's own 15-second connection timeout. The load balancer's probe would have given up first, with no useful answer.
Step 6: tests
The unit tests use a real worker and queue with a fake repository. They prove all three statuses: worker stopped (Unhealthy), more than 1,000 messages waiting (Degraded), and a failed reminder run (Degraded).
Show tests/SchoolApp.Tests/Unit/HealthCheckTests.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using SchoolApp.Api.BackgroundJobs;
using SchoolApp.Api.Health;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Notifications;
using SchoolApp.Core.Services;
using SchoolApp.Tests.Api;
using SchoolApp.Tests.Fakes;
namespace SchoolApp.Tests.Unit;
// Lesson 95: the background jobs health check, with a real worker and queue and a fake repository.
public class HealthCheckTests
{
private static (GuardianMessageWorker Worker, GuardianMessageQueue Queue, ServiceProvider Services) CreateWorker(
FakeFeeReminderRepository repository)
{
GuardianMessageQueue queue = new GuardianMessageQueue();
ServiceCollection services = new ServiceCollection();
services.AddSingleton<IFeeReminderRepository>(repository);
services.AddScoped<GuardianMessageService>();
ServiceProvider provider = services.BuildServiceProvider();
GuardianMessageWorker worker = new GuardianMessageWorker(queue, provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<GuardianMessageWorker>.Instance);
return (worker, queue, provider);
}
private static Task<HealthCheckResult> CheckAsync(GuardianMessageQueue queue, params IHostedService[] hostedServices) =>
new BackgroundJobsHealthCheck(hostedServices, queue).CheckHealthAsync(new HealthCheckContext());
[Fact]
public async Task WorkerRunning_IsHealthy()
{
(GuardianMessageWorker worker, GuardianMessageQueue queue, ServiceProvider services) = CreateWorker(new FakeFeeReminderRepository());
await using (services)
{
await worker.StartAsync(CancellationToken.None);
HealthCheckResult result = await CheckAsync(queue, worker);
Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Equal(0, result.Data["waitingMessages"]);
await worker.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task WorkerStopped_IsUnhealthy()
{
(GuardianMessageWorker worker, GuardianMessageQueue queue, ServiceProvider services) = CreateWorker(new FakeFeeReminderRepository());
await using (services)
{
await worker.StartAsync(CancellationToken.None);
await worker.StopAsync(CancellationToken.None); // ExecuteAsync has ended
HealthCheckResult result = await CheckAsync(queue, worker);
Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Equal("The guardian message worker is not running.", result.Description);
}
}
[Fact]
public async Task TooManyWaiting_IsDegraded()
{
// The worker takes the first message and waits on Hold, so the rest pile up behind it.
FakeFeeReminderRepository repository = new FakeFeeReminderRepository { Hold = new TaskCompletionSource() };
(GuardianMessageWorker worker, GuardianMessageQueue queue, ServiceProvider services) = CreateWorker(repository);
await using (services)
{
await worker.StartAsync(CancellationToken.None);
for (int i = 0; i < BackgroundJobsHealthCheck.MaxWaitingMessages + 2; i++)
{
await queue.QueueAsync(new GuardianMessage($"INV-{i}", MessageReason.FeeReminder));
}
HealthCheckResult result = await CheckAsync(queue, worker);
Assert.Equal(HealthStatus.Degraded, result.Status);
Assert.EndsWith("messages are waiting; the worker is falling behind.", result.Description);
repository.Hold.SetResult();
await worker.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task FailedReminderRun_IsDegraded()
{
FakeFeeReminderRepository failing = new FakeFeeReminderRepository { FailWith = new TimeoutException("database busy") };
(FeeReminderJob job, FakeTimeProvider time, ListLoggerProvider logs, ServiceProvider jobServices) = FeeReminderTests.CreateJob(failing);
(GuardianMessageWorker worker, GuardianMessageQueue queue, ServiceProvider workerServices) = CreateWorker(new FakeFeeReminderRepository());
await using (jobServices)
await using (workerServices)
{
await worker.StartAsync(CancellationToken.None);
await job.StartAsync(CancellationToken.None);
await logs.WaitForAsync(3004);
time.Advance(TimeSpan.FromMinutes(1)); // 18:00: the run fails
await logs.WaitForAsync(3006);
HealthCheckResult result = await CheckAsync(queue, worker, job);
Assert.Equal(HealthStatus.Degraded, result.Status);
Assert.Equal("The fee reminder run for 2026-10-12 failed.", result.Description);
await job.StopAsync(CancellationToken.None);
await worker.StopAsync(CancellationToken.None);
}
}
}
The API tests call both endpoints without a token. One starts the API with an unreachable database and checks 503 for ready, 200 for live, and no server address in the body.
Show tests/SchoolApp.Tests/Api/HealthEndpointTests.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
namespace SchoolApp.Tests.Api;
// Lesson 95: /health/live and /health/ready, without a token, with SchoolDb reachable and unreachable.
[Collection("Api")]
public class HealthEndpointTests
{
private readonly ApiFactory _factory;
public HealthEndpointTests(ApiFactory factory)
{
_factory = factory;
}
private static async Task<Dictionary<string, string>> CheckStatusesAsync(HttpResponseMessage response)
{
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
return body.GetProperty("checks").EnumerateArray()
.ToDictionary(c => c.GetProperty("name").GetString()!, c => c.GetProperty("status").GetString()!);
}
[Fact]
public async Task Live_AnswersWithoutAToken_AndRunsNoChecks()
{
HttpResponseMessage response = await _factory.CreateClient().GetAsync("/health/live");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(await CheckStatusesAsync(response));
}
[Fact]
public async Task Ready_ChecksTheDatabaseAndTheWorker()
{
HttpResponseMessage response = await _factory.CreateClient().GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new Dictionary<string, string> { ["database"] = "Healthy", ["background-jobs"] = "Healthy" },
await CheckStatusesAsync(response));
Assert.Equal("no-store", response.Headers.CacheControl?.ToString());
}
// Port 1 on this computer: nothing listens there, so the connection is refused at once.
[Fact]
public async Task DatabaseUnreachable_ReadyIs503_LiveIsStill200()
{
await using WebApplicationFactory<Program> app = _factory.WithWebHostBuilder(builder =>
builder.UseSetting("ConnectionStrings:SchoolDb",
"Server=tcp:127.0.0.1,1;Database=SchoolDb;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=2"));
HttpClient client = app.CreateClient();
HttpResponseMessage ready = await client.GetAsync("/health/ready");
HttpResponseMessage live = await client.GetAsync("/health/live");
Assert.Equal(HttpStatusCode.ServiceUnavailable, ready.StatusCode);
string body = await ready.Content.ReadAsStringAsync();
Assert.Contains("\"description\":\"SchoolDb did not answer.\"", body);
Assert.DoesNotContain("127.0.0.1", body); // no server details from the exception
Assert.Equal("Unhealthy", (await CheckStatusesAsync(ready))["database"]);
Assert.Equal(HttpStatusCode.OK, live.StatusCode); // the process is fine; only the database is not
}
}
The suite has 168 tests, all passing.
Example: who calls these addresses
- A load balancer calls
/health/readyevery few seconds and sends traffic only to servers that answer 200. - A container platform such as Kubernetes uses both: a failing liveness probe restarts the container, a failing readiness probe only takes it out of traffic. Cloud hosts such as Azure App Service have a similar health check setting.
- An uptime monitor calls from outside and alerts the team when the answer changes.
Example: what belongs in a check
| Dependency | In /health/ready? | Why |
|---|---|---|
| SchoolDb | yes, Unhealthy when down | almost every endpoint needs it |
| The message worker | yes, Unhealthy or Degraded | it fails silently otherwise |
| The text message provider | no | every server shares it; see below |
| Disk space | perhaps, as Degraded | a warning before saves start failing |
The provider row surprises people. If it is down and every server reports Unhealthy, the load balancer takes all of them out of traffic.
A small problem, late text messages, becomes a full outage: nobody can even take a fee payment. Check shared outside services in monitoring, not in readiness.
Example: keeping health details private
The SMS body shows check names and short descriptions, never exception text or server names. That is safe to leave open.
Larger systems often go further. They answer only "Healthy" publicly, and serve the detailed report on an internal address or port that only the monitoring system can reach.
Common mistakes
- The database in the liveness check. When the database blips, every server is restarted, and restarts do not fix the database. Fix: database checks in readiness only.
- A health endpoint that needs a token. The load balancer gets 401 and marks every server dead. Fix:
AllowAnonymous, and no secrets in the body. - Exception text in the response. It can leak server names and versions. Fix: a short description; the exception goes to the log.
- No timeout. A hanging dependency hangs the probe. Fix: a timeout on each slow check.
- Heavy work in a check. Counting 600,000 attendance rows every 5 seconds loads the database. Fix:
SELECT 1, or a cached result. - Rate limiting the health endpoint. A 429 looks like a dead server. Fix:
DisableRateLimiting.
In real projects
A team put a full database query in their liveness check. One evening the database moved to its standby copy, which took about 30 seconds.
During those 30 seconds, every liveness check failed, so the platform restarted every copy of the API. When the database came back, all of them were still starting up, and the outage lasted five more minutes.
We moved the database check to readiness. The next switch to the standby took the servers out of traffic for 30 seconds and back in again, with no restarts and no outage beyond the switch itself.
Best practices
- Separate live (the process answers) from ready (it can do its work).
- Keep checks small and fast, and give slow ones a timeout.
- Use Degraded for "needs attention" and Unhealthy for "cannot work".
- Open health endpoints to anonymous callers, without rate limits, and with no error details.
- Check your background work too; a dead worker fails silently otherwise.
Recap
/health/liveruns no checks;/health/readyruns the checks taggedready.SchoolDbHealthCheckasksSELECT 1, with a 3-second timeout.BackgroundJobsHealthCheckreads the worker'sExecuteTask, the queue's length, and the job's last run.- Unhealthy answers 503; Healthy and Degraded answer 200; details stay in the log.
- 168 tests pass, including a run with an unreachable database.
Homework
Library module task: A library API keeps its loans as files in a library-data folder. Add /health/live
and /health/ready. The ready check must confirm that the folder exists and accepts a new file.
The rules are: missing folder, Unhealthy; writing takes more than 500 ms, Degraded; otherwise Healthy. The test file must be deleted afterwards.
A correct result looks like:
GET /health/live 200 Healthy
GET /health/ready 503 {"status":"Unhealthy","checks":[{"name":"storage","status":"Unhealthy","description":"The library-data folder is missing."}]}
(create the folder)
GET /health/ready 200 {"status":"Healthy","checks":[{"name":"storage","status":"Healthy","description":"The library-data folder accepts files."}]}
Show the solution
// Library homework, Lesson 95: health checks for a library API that keeps its loans in a folder of files.
// /health/live answers 200 while the process runs. /health/ready also checks the "storage" folder: it must exist
// and accept a new file (a full or read-only disk fails here, before a real save does).
// Missing folder: Unhealthy (503). A slow disk (writing takes more than 500 ms): Degraded (still 200).
// The folder is library-data in the system's temp folder; set Storage:Folder to use another one.
// Run with: dotnet run library-assignment/095-library-health.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck<StorageHealthCheck>("storage", tags: ["ready"], timeout: TimeSpan.FromSeconds(2));
WebApplication app = builder.Build();
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = (context, report) => context.Response.WriteAsJsonAsync(new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new { name = e.Key, status = e.Value.Status.ToString(), description = e.Value.Description })
})
});
app.Run("http://localhost:5095");
public class StorageHealthCheck(IConfiguration configuration) : IHealthCheck
{
private string Folder => configuration["Storage:Folder"] ?? Path.Combine(Path.GetTempPath(), "library-data");
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (!Directory.Exists(Folder))
{
return HealthCheckResult.Unhealthy("The library-data folder is missing.");
}
try
{
Stopwatch timer = Stopwatch.StartNew();
string probe = Path.Combine(Folder, $".health-{Guid.NewGuid():N}.tmp");
await File.WriteAllTextAsync(probe, "ok", cancellationToken);
File.Delete(probe);
return timer.ElapsedMilliseconds > 500
? HealthCheckResult.Degraded($"Writing took {timer.ElapsedMilliseconds} ms.")
: HealthCheckResult.Healthy("The library-data folder accepts files.");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return HealthCheckResult.Unhealthy("The library-data folder does not accept files.", exception);
}
}
}
Our first version looked for the folder next to where we typed the command. But dotnet run file.cs runs with
the file's own folder as the current directory, so the check looked in the wrong place. The folder is now a
setting, with the temp folder as the default.
Quiz
SchoolDb is down. What should /health/live and /health/ready answer?
Show answer
Answer: B. Live 200, ready 503 The process is fine, so no restart; but it cannot do its work, so no traffic.
What HTTP code does a Degraded result give by default?
Show answer
Answer: A. 200 Degraded is a warning for people; traffic keeps flowing.
Why are the health endpoints marked AllowAnonymous?
Show answer
Answer: B. Load balancers and monitors have no token; the fallback policy would answer 401 A 401 would make every server look dead.
Why does the database check have a 3-second timeout?
Show answer
Answer: B. A hanging database would otherwise hang the probe for 15 seconds or more A timeout turns 'no answer' into a quick, clear Unhealthy.
How does BackgroundJobsHealthCheck know the message worker has died?
Show answer
Answer: B. The worker's ExecuteTask has completed while the app is still running ExecuteAsync should run for the whole life of the app.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F What is a health check endpoint, and who calls it? concept
An address, such as /health/ready, where the API reports whether it is working. Load balancers call it every few seconds to decide which servers get traffic, container platforms use it to restart or pause instances, and monitoring tools use it to alert the team.
F What is the difference between a liveness check and a readiness check? concept
Liveness asks "is the process running?" and failing it usually means restart the process. Readiness asks "can it do its work now?" (database reachable, workers running) and failing it means send no traffic until it recovers. A database problem belongs in readiness, because restarting the API does not fix the database.
F What do Healthy, Degraded, and Unhealthy mean, and which HTTP codes do they return? concept
Healthy: everything works (200). Degraded: it works but needs attention, such as a growing queue (200, so traffic keeps flowing). Unhealthy: it cannot do its job, such as the database being unreachable (503).
Mid (3-5 yrs)
M How do you write a custom health check for SQL Server in ASP.NET Core? coding
Implement IHealthCheck: open a connection, run SELECT 1, return HealthCheckResult.Healthy; catch exceptions (except cancellation) and return Unhealthy with the exception, so it is logged. Register it with AddHealthChecks().AddCheck("database", tags: ["ready"], timeout: ...) and map it with MapHealthChecks.
M Your health endpoint returns 401 behind the load balancer and every server is marked down. Why, and what do you change? scenario
A fallback authorization policy requires a token on every endpoint, and the load balancer sends none. Mark the health endpoints AllowAnonymous, keep their body free of secrets and exception details, and also exclude them from rate limiting, because a 429 looks just as dead.
M How can a health check tell that a BackgroundService has stopped? coding
BackgroundService.ExecuteTask is the running ExecuteAsync. Hosted services are singletons, so the check can ask for IEnumerable of IHostedService, find the worker, and report Unhealthy if ExecuteTask is completed while the app is running. Queue length and the last run's result can add Degraded warnings.
Senior (6-9 yrs)
S The database failed over for 30 seconds and every API instance restarted, causing a longer outage. What went wrong? scenario
The database check was in the liveness probe, so the platform restarted every instance, and they were still starting when the database returned. Keep liveness trivial (the process answers) and put the database in readiness, which only removes instances from traffic. Add timeouts so a hanging check fails fast.
How to answer: Separate the two probe types, explain what each one triggers, and show why restarts cannot fix a dependency.
S Should the readiness check include every external service the API uses? design
No. Include what the instance itself needs and can be fixed per instance, such as its database connection. A shared external service (an SMS provider, a payment gateway) failing would mark every instance unready at once and turn a partial problem into a full outage. Monitor those separately and degrade gracefully.
How to answer: Classify dependencies as critical per instance versus shared, and explain the blast radius of each choice.
Architect / Lead (10+ yrs)
A Design health checking and monitoring for an API running many instances across regions. design
Per instance: a trivial liveness probe and a readiness probe with fast, time-limited checks of its own dependencies, answering only a status publicly and details on an internal address. Per service: synthetic checks from outside that exercise real flows, dashboards of error rates and latency, and alerts with clear owners. Health results can also be pushed (IHealthCheckPublisher) to the monitoring system, and shared dependencies are watched centrally rather than in every probe.
How to answer: Cover probe design, timeouts, security of details, external synthetic checks, metrics and alerting, and shared-dependency handling.
Resources
- Code for this lesson: git tag
lesson-95-health-checks(code repository coming soon) - Files:
src/SchoolApp.Api/Health/SchoolDbHealthCheck.cs,src/SchoolApp.Api/Health/BackgroundJobsHealthCheck.cs,src/SchoolApp.Api/Health/HealthReportWriter.cs,src/SchoolApp.Api/Program.cs,tests/SchoolApp.Tests/Unit/HealthCheckTests.cs,tests/SchoolApp.Tests/Api/HealthEndpointTests.cs,library-assignment/095-library-health.cs - Video: coming soon
What's next
The API can now say how it feels. Last in this module: tests for the whole API, so every lesson's promise is checked on every change. Lesson 96: Tests for the whole API
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.