Skip to main content
Published / updated

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".

ResultConclusion
Works in Postman, fails in the browserFrontend, or CORS
Fails in both, identicallyThe API
Fails in both, differentlyThe 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:

WhereShows
The console window that opens with the projectEverything the app writes, live
View → Output, with the dropdown on DebugThe 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
{
}
SymptomCause
id is 0Route parameter name does not match the argument name
The request object is nullNo Content-Type: application/json, or [FromBody] missing
Fields are null but present in the payloadProperty names do not match; casing, or a missing setter
415Content-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>"
ResponseCause
401, no WWW-Authenticate detailNo token sent
401 error="invalid_token"Malformed, wrong signature, or wrong issuer/audience
401 error="invalid_token", description="The token expired"Expired
403Authenticated, 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?
  • iss and aud — 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.

MistakeSymptom
UseAuthorization before UseAuthenticationEvery authenticated request gets 401
UseCors after UseAuthenticationCORS errors on authenticated calls only
UseRouting after UseCorsCORS does nothing
Exception middleware registered lastIt 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

SymptomCheck
500The API log, not the browser
400 with no explanationThe errors object in the response body
Breakpoint in the action never hitsBinding or routing failed first
id is 0Route parameter name mismatch
Body object is nullContent-Type, or missing [FromBody]
401 with a valid-looking tokenDecode it — exp, iss, aud
401 on everythingUseAuthorization before UseAuthentication
CORS only on authenticated callsUseCors after UseAuthentication
Empty 200, nothing in the logMiddleware not calling next()
Wrong school's data returnedSchoolId taken from the request
Wrong number, no errorA catch block swallowing the exception

Errors you will hit

What you seeWhere to look
500The API log, never the browser
400 with no explanationThe errors object in the response body
Breakpoint in the action never hitsRouting or binding failed first
401 with a valid-looking tokenDecode it — exp, iss, aud
Empty 200 and nothing loggedMiddleware not calling next()
Another school's data returnedSchoolId 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 Exception and returning a default
  • Taking SchoolId from 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 errors object in a 400
  • No correlation id, so production reports cannot be traced

Practice

The course exercise is debug an API failure.

  1. Reproduce a browser failure in Postman using Copy as cURL, and compare the two requests.
  2. Cause a 500 and find the cause in the API log rather than the browser.
  3. Enable Microsoft.EntityFrameworkCore.Database.Command logging and read the generated SQL for one request.
  4. Rename a route parameter so it no longer matches the argument. Confirm the value is 0 and the breakpoint hits with bad data.
  5. Post JSON without Content-Type and get 415. Then remove [FromBody] and get a null object.
  6. Remove a setter from a request property and watch the field bind to null with no error.
  7. Send a request missing a [Required] field and read the errors object.
  8. Add a validation rule in the browser only, then bypass it from Postman.
  9. Decode an expired token and match exp to the 401.
  10. Change the API's Audience so tokens no longer validate, and read the WWW-Authenticate header.
  11. Swap UseAuthentication and UseAuthorization and observe 401 on everything.
  12. Move UseCors after UseAuthentication and confirm CORS fails only on authenticated calls.
  13. Write a middleware that omits next(). Observe the empty 200 and the silent log.
  14. Take schoolId from 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.
  15. Write the catch block that returns Ok(0), then find the real exception with first-chance exceptions enabled.
  16. 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

  1. Why does a failure reproducing in Postman change what you investigate?
  2. Why does a breakpoint on the first line of an action sometimes never hit?
  3. Why must SchoolId come from the token, and how would you detect that it does not?
  4. Why is a 500 better than catching everything and returning a default?

Next: Debugging databases