Skip to content
All posts

Article

Your API Works. But Is It Actually Reliable?

Building a REST API is easy. Building one that handles things going wrong is where it gets interesting.

3 min read

Building a REST API is easy. Building one that handles things going wrong is where it gets interesting.

When I first started working with APIs, I mostly cared about one thing: does the endpoint return the correct data? If I sent a GET request and received JSON, I considered it working.

But there is a lot more to a reliable API than returning 200 OK. What happens when the client sends invalid data? What if a resource does not exist? What if something breaks on the server?

Here are a few simple practices that can make a Node.js API much more reliable.

1. Use the Right HTTP Status Codes

HTTP status codes tell the client what happened. Returning 500 Internal Server Error when a requested resource does not exist makes the API harder to use and debug.

Use meaningful codes such as:

Status Meaning
200 OK The request succeeded
201 Created A resource was created
400 Bad Request The client sent invalid data
401 Unauthorized Authentication is required or invalid
403 Forbidden The client is not allowed to access the resource
404 Not Found The requested resource does not exist
500 Internal Server Error An unexpected server error occurred
503 Service Unavailable The server temporarily cannot handle the request
app.get("/api/users/:id", (req, res) => {
  const user = users.find((item) => item.id === req.params.id);

  if (!user) {
    return res.status(404).json({
      error: "User not found",
    });
  }

  res.json(user);
});

Now the client knows exactly what happened.

2. Never Trust User Input

An API should validate data before it reaches application logic or storage. A request that is expected to contain a username can still contain an empty object, the wrong type, or unexpected values.

app.post("/api/users", (req, res) => {
  const { username } = req.body ?? {};

  if (typeof username !== "string" || !username.trim()) {
    return res.status(400).json({
      error: "Username is required",
    });
  }

  const user = {
    id: crypto.randomUUID(),
    username: username.trim(),
  };

  users.push(user);
  res.status(201).json(user);
});

This rejects invalid requests before they can affect your data. The example uses an in-memory array, so a real application should also use persistent storage and suitable database constraints.

3. Handle Errors Instead of Crashing

Unexpected failures are inevitable. If an endpoint reads a missing or invalid configuration file, the server should return a controlled response instead of exposing an exception or crashing.

app.get("/api/config", async (req, res) => {
  try {
    const data = await fs.promises.readFile("config.json", "utf8");
    res.json(JSON.parse(data));
  } catch (error) {
    console.error("Failed to load configuration:", error);

    res.status(500).json({
      error: "Unable to load configuration",
    });
  }
});

The client receives a useful error while internal file paths and stack traces stay private.

4. Authentication Is Not Authorization

Authentication answers “who are you?” Authorization answers “are you allowed to access this resource?”

Successfully logging in does not automatically mean that a user should be able to access every other user's private information.

app.get("/api/users/:id/profile", authenticate, (req, res) => {
  if (req.user.id !== req.params.id) {
    return res.status(403).json({
      error: "Access denied",
    });
  }

  res.json(getProfile(req.user.id));
});

The important part is checking which resource the authenticated user is allowed to access, not only whether they have logged in.

5. Test the Bad Cases Too

It is easy to test whether an endpoint works when everything goes right. Some of the most valuable tests intentionally send invalid requests.

const { test } = require("node:test");
const assert = require("node:assert/strict");

test("unknown user returns 404", async () => {
  const response = await fetch("http://localhost:3000/api/users/unknown");

  assert.equal(response.status, 404);

  const body = await response.json();
  assert.equal(body.error, "User not found");
});

Useful failure scenarios include missing or invalid input, unknown resources, missing authentication, insufficient permissions, and unexpected server errors. Testing these cases helps catch regressions before users encounter them.

6. Do Not Leak Sensitive Information in Logs

Logging is useful for debugging, but logging every request header and body can expose passwords, access tokens, and other sensitive information.

Instead, log only what is needed:

console.log({
  method: req.method,
  path: req.path,
  timestamp: new Date().toISOString(),
});

If detailed logging is necessary, sensitive fields should be redacted before they are written.

Final Thoughts

A working API is not necessarily a reliable API. A reliable API should:

  • Return meaningful HTTP status codes
  • Validate incoming data
  • Handle unexpected failures
  • Enforce authentication and authorization
  • Include tests for failure scenarios
  • Avoid exposing sensitive information

You do not need a complicated architecture to start improving API reliability. Sometimes, a few well-placed checks and tests make a huge difference.

Read the original article on Dev.to