Debugging APIs
Before you start
You need: web debugging (Article 03) and an API to break.
Time: about 50 minutes, at the keyboard.
Learning objective
Isolate an API failure from the client, reproduce it deliberately, and identify whether the cause is binding, authorisation, middleware or the handler.
Topics
- Isolating the API
- Reading the log, not the browser
- Model binding failures
- Validation and 400 responses
- Authentication and authorisation
- Middleware order
- Exception handling that hides bugs
- Correlation ids
Isolating the API
Take the frontend out of the picture first.
curl -i https://localhost:7099/api/students?schoolId=1 \
-H "Authorization: Bearer eyJhbGci..."
Or in Postman, or from Swagger's "Try it out".
| Result | Conclusion |
|---|---|
| Works in Postman, fails in the browser | Frontend, or CORS |
| Fails in both, identically | The API |
| Fails in both, differently | The request differs — compare headers and payload |
Compare the two requests before concluding anything. Copy the browser's failing request as cURL and diff it against the one that works. The difference is almost always a header, a casing difference in a field name, or a query parameter the frontend omitted.
-i includes response headers, which is where you find the WWW-Authenticate header explaining a 401.
Read the log, not the browser
The browser shows 500 Internal Server Error and a generic body. That is deliberate — a production API must not return stack traces to clients.
The actual error is in the API's own output:
fail: NexCoding.SchoolPortal.Api.Controllers.FeeController[0]
Fee calculation failed for student 12
System.NullReferenceException: Object reference not set to an instance of an object.
at FeeService.CalculateTotalFees(Int32 studentId) in FeeService.cs:line 47
In Visual Studio the log appears in two places:
| Where | Shows |
|---|---|
| The console window that opens with the project | Everything the app writes, live |
| View → Output, with the dropdown on Debug | The same, plus framework diagnostics |
Run with F5 for this, not Ctrl+F5. The Output window's Debug stream only fills when the debugger is attached, and that stream is where the unhandled-exception detail appears.
In development only:
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
Never enable the developer exception page in production. It exposes the stack trace, the file paths and often the connection string.
Turn up the level when you need more:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"NexCoding.SchoolPortal": "Debug",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
That last line logs every SQL statement EF Core generates, with parameters. It is the fastest way to see that a query is missing a WHERE SchoolId = @p0.
Model binding
A 400 with no obvious reason is usually binding.
[HttpGet("api/students/{id}")]
public async Task<IActionResult> GetStudent(int id) // from the route
{
}
[HttpGet("api/students")]
public async Task<IActionResult> Search([FromQuery] string name) // from the query string
{
}
[HttpPost("api/students")]
public async Task<IActionResult> Create([FromBody] CreateStudentRequest request) // from the body
{
}
| Symptom | Cause |
|---|---|
id is 0 | Route parameter name does not match the argument name |
| The request object is null | No Content-Type: application/json, or [FromBody] missing |
| Fields are null but present in the payload | Property names do not match; casing, or a missing setter |
| 415 | Content-Type wrong |
| 400 before your code runs | [ApiController] automatic validation rejected it |
Bind failures happen before your breakpoint. A breakpoint on the first line of the action never hits, which looks like a routing problem and is not.
public class CreateStudentRequest
{
public string Name { get; set; } // needs a public setter
public string RollNumber { get; set; }
public DateTime DateOfBirth { get; set; }
}
A property without a setter binds to null silently. So does a name mismatch — rollNo in the payload will not fill RollNumber.
System.Text.Json is case-insensitive by default in ASP.NET Core, so rollNumber matches RollNumber, but roll_number does not.
Validation and 400
With [ApiController], validation failures return 400 automatically, before the action runs:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"RollNumber": ["The RollNumber field is required."],
"DateOfBirth": ["The field DateOfBirth must be a date."]
}
}
Read the errors object. It names the field and the rule. Most "the API rejects my request for no reason" reports end here.
public class CreateStudentRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; }
[Required]
[RegularExpression(@"^NCA-\d{4}-\d{4}$")]
public string RollNumber { get; set; }
}
Server-side validation is the control. Client-side validation is a convenience. A request from Postman bypasses every check in the browser, so any rule that matters must exist here.
Authentication and authorisation
curl -i https://localhost:7099/api/students -H "Authorization: Bearer <token>"
| Response | Cause |
|---|---|
401, no WWW-Authenticate detail | No token sent |
401 error="invalid_token" | Malformed, wrong signature, or wrong issuer/audience |
401 error="invalid_token", description="The token expired" | Expired |
| 403 | Authenticated, but the role or policy denied it |
Decode the token before assuming the API is wrong. Paste it at jwt.io and check:
exp— is it in the future?issandaud— do they match the API's configuration exactly?role— is the claim there, and spelled as the policy expects?schoolId— is it the claim your code reads?
int schoolId = int.Parse(User.FindFirst("schoolId").Value);
SchoolId must come from the token, never from the request.
// Wrong — any user can read any school
public async Task<IActionResult> GetStudents([FromQuery] int schoolId)
// Correct
public async Task<IActionResult> GetStudents()
{
int schoolId = int.Parse(User.FindFirst("schoolId").Value);
return Ok(await _studentService.GetBySchoolAsync(schoolId));
}
This is a bug that produces no error. Every response is a 200 with correct-looking data — from the wrong school. The only way to find it is to test it deliberately: sign in as School 1 and request School 2's records.
Middleware order
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("SchoolPortal");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Order is behaviour, not style.
| Mistake | Symptom |
|---|---|
UseAuthorization before UseAuthentication | Every authenticated request gets 401 |
UseCors after UseAuthentication | CORS errors on authenticated calls only |
UseRouting after UseCors | CORS does nothing |
| Exception middleware registered last | It never catches anything |
Custom middleware not calling next() | Requests hang or return empty |
Exception-handling middleware must be first, because it can only catch what happens after it in the pipeline.
app.Use(async (context, next) =>
{
_logger.LogInformation("→ {Method} {Path}", context.Request.Method, context.Request.Path);
await next(); // omitting this hangs the request
_logger.LogInformation("← {StatusCode}", context.Response.StatusCode);
});
A middleware that does not call next() silently ends the pipeline. No error, no handler, an empty 200 — and nothing in the log to say why.
Exception handling that hides bugs
// The worst code in this article
try
{
return Ok(await _feeService.CalculateTotalFeesAsync(studentId));
}
catch (Exception)
{
return Ok(0);
}
This returns a plausible number, logs nothing, and turns a crash into silently wrong financial data. A 500 is better than a wrong answer, because a 500 gets investigated.
try
{
return Ok(await _feeService.CalculateTotalFeesAsync(studentId));
}
catch (FeeAccountNotFoundException ex)
{
_logger.LogWarning(ex, "No fee account for student {StudentId}", studentId);
return NotFound(new { message = "No fee account exists for this student." });
}
Catch what you can handle. Let the rest reach the global handler:
app.UseExceptionHandler("/error");
[ApiExplorerSettings(IgnoreApi = true)]
[Route("/error")]
public IActionResult HandleError()
{
IExceptionHandlerFeature feature = HttpContext.Features.Get<IExceptionHandlerFeature>();
_logger.LogError(feature.Error, "Unhandled exception on {Path}", feature.Path);
return Problem(
title: "An error occurred processing your request.",
statusCode: 500);
}
Log the full exception server-side, return a generic message to the client. Both halves matter — one for diagnosis, one for not leaking internals.
Correlation ids
app.Use(async (context, next) =>
{
string correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (_logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
{
await next();
}
});
Every log line for one request now carries the same id. A user reports a failure, gives you the id from the response header, and you retrieve exactly the log lines for that request out of thousands.
Without it, debugging a production report means guessing at timestamps.
Diagnosing
| Symptom | Check |
|---|---|
| 500 | The API log, not the browser |
| 400 with no explanation | The errors object in the response body |
| Breakpoint in the action never hits | Binding or routing failed first |
id is 0 | Route parameter name mismatch |
| Body object is null | Content-Type, or missing [FromBody] |
| 401 with a valid-looking token | Decode it — exp, iss, aud |
| 401 on everything | UseAuthorization before UseAuthentication |
| CORS only on authenticated calls | UseCors after UseAuthentication |
| Empty 200, nothing in the log | Middleware not calling next() |
| Wrong school's data returned | SchoolId taken from the request |
| Wrong number, no error | A catch block swallowing the exception |
Errors you will hit
| What you see | Where to look |
|---|---|
| 500 | The API log, never the browser |
| 400 with no explanation | The errors object in the response body |
| Breakpoint in the action never hits | Routing or binding failed first |
| 401 with a valid-looking token | Decode it — exp, iss, aud |
| Empty 200 and nothing logged | Middleware not calling next() |
| Another school's data returned | SchoolId taken from the request |
Reproduce it in Postman first. If it fails there too, the frontend is exonerated and you have halved the search.
Common mistakes
- Debugging from the browser instead of the API log
- Leaving the developer exception page on in production
- Catching
Exceptionand returning a default - Taking
SchoolIdfrom the request - Relying on client-side validation
- Middleware in the wrong order
- Custom middleware that never calls
next() - Not decoding the token before blaming the API
- Ignoring the
errorsobject in a 400 - No correlation id, so production reports cannot be traced
Practice
The course exercise is debug an API failure.
- Reproduce a browser failure in Postman using Copy as cURL, and compare the two requests.
- Cause a 500 and find the cause in the API log rather than the browser.
- Enable
Microsoft.EntityFrameworkCore.Database.Commandlogging and read the generated SQL for one request. - Rename a route parameter so it no longer matches the argument. Confirm the value is 0 and the breakpoint hits with bad data.
- Post JSON without
Content-Typeand get 415. Then remove[FromBody]and get a null object. - Remove a setter from a request property and watch the field bind to null with no error.
- Send a request missing a
[Required]field and read theerrorsobject. - Add a validation rule in the browser only, then bypass it from Postman.
- Decode an expired token and match
expto the 401. - Change the API's
Audienceso tokens no longer validate, and read theWWW-Authenticateheader. - Swap
UseAuthenticationandUseAuthorizationand observe 401 on everything. - Move
UseCorsafterUseAuthenticationand confirm CORS fails only on authenticated calls. - Write a middleware that omits
next(). Observe the empty 200 and the silent log. - Take
schoolIdfrom the query string, then sign in as School 1 and request School 2's students. Confirm the 200 with the wrong data. Fix it to read the claim. - Write the catch block that returns
Ok(0), then find the real exception with first-chance exceptions enabled. - Add a correlation id middleware and trace one request end to end through the logs.
Exercise 14 is the one that matters most. It is the failure with no error message, and it is a data breach.
You can now
- Isolate an API failure from the client
- Read the server log rather than the browser
- Diagnose model binding and validation failures
- Spot a middleware ordering problem
- Detect a missing tenant filter, which produces no error
Review questions
- Why does a failure reproducing in Postman change what you investigate?
- Why does a breakpoint on the first line of an action sometimes never hit?
- Why must
SchoolIdcome from the token, and how would you detect that it does not? - Why is a 500 better than catching everything and returning a default?
Next: Debugging databases