Back to Home

API Design Errors: Guide for Developers

Practical Guide to Avoiding 10 Critical Errors in API Design and Implementation: HTTP Statuses, Error Handling, Pagination, Versioning, Idempotency and Data Validation.

10 Critical Errors in API Design: How to Build a Reliable System
Advertisement 728x90

API Design Pitfalls: A Practical Guide to Building Robust and Scalable Systems

Crafting an efficient and resilient API is a core responsibility for any backend developer. Yet, even seasoned teams frequently encounter common pitfalls that lead to unpredictable client behavior, debugging nightmares, and potential security vulnerabilities. This article delves into ten critical mistakes in API design and implementation, drawing on real-world scenarios, and offers practical prevention strategies to help your project steer clear of costly issues.

Misleading HTTP Statuses: The Peril of "False 200 OKs"

One of the most insidious and elusive errors is the incorrect use of HTTP status codes. When an API returns a 200 OK response to a request that has, in fact, failed (e.g., {"error": "insufficient_balance"}), it creates a false sense of success. These "false 200 OKs" can go unnoticed for months because monitoring systems typically track only status codes, overlooking the response body. Consequently, monitoring dashboards show a "green" status, while client applications encounter invisible issues: orders aren't created, transactions fail, but automatic retry mechanisms aren't triggered because the response is perceived as successful.

A similar situation arises when the server returns a 200 OK with an empty body instead of a proper 400 Bad Request when receiving invalid data. Debugging such issues can consume dozens of person-hours, as developers waste time checking network connectivity, access permissions, or proxy servers, rather than immediately focusing on input validation errors.

Google AdInline article slot

Another common pitfall is the misapplication of 4xx (client-side errors) and 5xx (server-side errors) statuses. For instance, if an API returns a 400 Bad Request when it fails to read its own configuration file (which is a server-side problem), the client application mistakenly assumes the issue lies with its request. This prevents the client from attempting retries, even though retry mechanisms would typically activate for a 500 Internal Server Error. Conversely, returning a 500 Internal Server Error for a business logic error (e.g., "message sending prohibited") causes the client to endlessly retry the request, despite the business rule remaining unchanged.

Correctly utilizing HTTP statuses isn't merely about adhering to standards; it's a fundamental aspect of API reliability and predictability. It enables monitoring systems to accurately assess service health and allows client applications to respond appropriately to various scenarios.

Here's a standard set of statuses for common scenarios:

Google AdInline article slot
  • 400 Bad Request: The client sent invalid data.
  • 401 Unauthorized: The client is not authenticated.
  • 403 Forbidden: The client is authenticated but lacks permission for the operation.
  • 404 Not Found: The requested resource was not found.
  • 409 Conflict or 422 Unprocessable Entity: A business rule prevents the operation (e.g., the resource already exists, or data cannot be processed).
  • 500 Internal Server Error: The server encountered an unexpected error.
  • 502 Bad Gateway or 504 Gateway Timeout: A dependent service is unresponsive or timed out.

Using these statuses according to their semantics significantly simplifies API integration and operation.

Inconsistent Error Handling: From Format Chaos to Data Leaks

Another critical issue API consumers face is the lack of a unified, standardized format for error responses. When an API returns errors in several different formats — sometimes {"code": -1, "description": "..."}, sometimes {"error": "..."}, and in some cases, just a plain text string like ok — it creates a "zoo" of formats. This makes programmatic error handling on the client side extremely complex and inefficient. Client developers are forced to write intricate logic to parse and interpret every possible response variation, which inflates the codebase, complicates testing, and increases the likelihood of bugs. For example, if all errors return the same generic code (-1), the client cannot differentiate the root cause of the problem and provide an appropriate message to the user.

// Example of error format "zoo"
// Format 1
{
  "code": -1,
  "description": "Invalid request body"
}

// Format 2
{
  "error": "File too large"
}

// Format 3 (plain string)
"ok"

This inconsistency often arises when different teams or individual developers create endpoints without a unified agreement or centralized error-handling middleware. The solution involves defining a single error model (e.g., with code, message, details fields) and strictly adhering to it across all API components, possibly through a centralized error handler. The longer unification is postponed, the more painful the migration will be for existing clients.

Google AdInline article slot

Beyond formatting issues, exposing internal implementation details in error messages poses a serious security risk. Returning messages to the client like org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "users_login_key" or a full stack trace is not just poor practice; it's a direct security threat. Such messages can reveal critical information about your system's internal structure:

  • Database table and column names: users_login_key
  • Type and version of the DBMS used: PSQLException
  • Internal package and class structure: org.example.service.UserService
  • Library and framework versions.
  • Fragments of SQL queries or business logic.

An attacker who obtains such information can use it to conduct targeted attacks, such as SQL injection, exploit known vulnerabilities in specific software versions, or plan further steps to compromise the system. Before a security audit or going live, it's crucial to ensure that the API does not expose any internal details through error messages. Instead, provide general, informative messages to the client, and log detailed information on the server side for internal analysis.

Scaling and Evolution Challenges: Pagination and Versioning

As systems grow and data volumes increase, the absence of pagination in an API becomes a critical issue. Endpoints initially designed to return a small number of records (e.g., /api/equipment for 50-100 items) may later face requests for tens of thousands of elements. In such scenarios, the server is forced to retrieve all records from the database, serialize them into a massive JSON object (potentially several megabytes in size), and send it to the client. This leads to significantly increased response times, high server load, and, critically for mobile applications, OutOfMemory (OOM) errors on the client side.

// Example of missing pagination:
// GET /api/equipment
// Returns all 40,000+ records at once
[
  { "id": 1, "name": "Excavator" },
  { "id": 2, "name": "Bulldozer" },
  // ... 39,998 other records
]

The situation becomes particularly absurd when a frontend application displays data with pagination (e.g., 20 records per page), but this pagination is implemented client-side after receiving the entire massive data array. The backend remains "unaware" that the client only needs a portion of the data. Adding pagination to an existing API is a breaking change, as older clients expect to receive the full list. This necessitates either introducing a new endpoint version or complex compatibility logic, both of which consume time and resources. The optimal solution is to implement pagination from the outset, using limit and offset parameters or cursor-based pagination.

API versioning is another aspect often overlooked in early development stages under the guise of "over-engineering." During the MVP phase, with only one or two clients, it might seem reasonable not to complicate URLs with a /v1/ prefix. However, as the number of clients grows to dozens, and external consumers you don't control emerge, API changes without versioning become extremely painful. Adding a new field or removing an outdated one can break integrations that strictly deserialize responses or rely on an immutable contract.

The point at which versioning becomes critically necessary is often missed because teams are focused on developing new features. Consequently, an "internal" API that was not initially versioned might be discovered and used by external integrations, effectively making it public. The absence of a mechanism to denote different contract versions makes any API evolution extremely risky and costly. You should start versioning your API as soon as even one external consumer, whom you cannot directly control, appears.

Violating Design Principles: URL Naming and Idempotency

Inconsistency in endpoint URL naming is a problem that directly impacts API usability and "learnability." When an API features various naming styles (e.g., RESTful GET /equipment, RPC-style POST /loadUsers, camelCase GET /equipmentInfo/{id}, or endpoints with suffixes like Async), it creates a "zoo" of URLs. A developer using such an API cannot predict the name of the next endpoint and must constantly refer to documentation (if it exists and is up-to-date) or even the source code.

// Examples of URL "zoo":
GET  /equipment                  // RESTful
GET  /geozone/{id}/check         // Singular noun + verb
GET  /equipmentInfo/{id}         // camelCase
POST /loadUsers                  // RPC-style, verb
GET  /geozonesAsync              // Async suffix
POST /api/internal/equipment     // POST for reading data

Using POST for data retrieval operations, such as fetching a list with complex filters in the request body, is particularly problematic. While technically feasible, it semantically violates HTTP principles: GET requests should be idempotent and cacheable. POST requests are not cached by CDNs and are not idempotent by specification. Such a violation can break assumptions made by client libraries and infrastructure designed to optimize GET request handling. Developing and strictly adhering to unified naming conventions (e.g., RESTful principles, using plural nouns for collections, verbs for actions on resources) is crucial for creating an intuitive and easy-to-use API.

Idempotency is a property of an operation where performing it multiple times yields the same result as performing it once. For HTTP methods GET, PUT, and DELETE, idempotency is part of their specification. However, POST requests are not idempotent by default. The lack of idempotency for POST requests that should not lead to entity duplication or side effects is a ticking time bomb.

Consider a scenario: a client sends a POST request to create an order but receives no response due to a network failure or timeout. Unsure if the request reached the server, the client might retry it. Without idempotency, this would lead to the creation of two identical orders, two service tickets, or two debits if financial transactions are involved. This can result in significant financial losses, customer dissatisfaction, and reputational damage.

Solutions for ensuring POST request idempotency include:

  • Using a unique idempotency key: The client generates a unique ID for each request and sends it in a header. The server remembers this key and, if it receives a request with an already used key, simply returns the result of the first successful execution without processing the request again.
  • Converting POST to PUT: If a resource is created with a pre-known ID, PUT (which is idempotent) can be used instead of POST.
  • Checking for resource existence before creation: In some cases, you can check for the existence of a resource by its unique attributes before creating it.
// Example of Idempotency-Key usage in header
// Client request
POST /api/orders
Idempotency-Key: a1b2c3d4-e5f6-7890-1234-567890abcdef
Content-Type: application/json

{
  "item_id": 123,
  "quantity": 2
}

Implementing idempotency for critical POST operations is mandatory for building resilient systems capable of correctly handling network failures and retries.

Data Validation Flaws: The Risks of Using Strings Instead of Enums

One of the most frequently underestimated mistakes in API design is the use of free-form string fields (String) where business logic dictates fixed, constrained sets of values. Classic examples include status or role fields that accept string values. If status is expected to be only active or inactive, and role only admin or user, declaring these fields as String opens the door to numerous issues:

// Example of String instead of enum problem
{
  "login": "john",
  "status": "actve", // Typo, should be "active"
  "roles": ["admn"]   // Typo, should be "admin"
}

In such a case, the request might successfully pass JSON syntax validation, and the data will be stored with typos ("actve", "admn"). These "invalid" values can remain unnoticed until they manifest as incorrect system behavior (e.g., a user cannot log into the admin panel because their role "admn" doesn't match the expected "admin"), or until manual debugging is required.

The consequences of such errors include:

  • Silent failures: The system operates, but data is incorrect, leading to unpredictable behavior.
  • Debugging complexity: Finding the root cause can be time-consuming, as formally, there are no errors.
  • Lack of autocompletion: Client-side IDEs and tools cannot suggest valid values.
  • Documentation overhead: All permissible values for each string field must be manually described.
  • Potential vulnerabilities: Uncontrolled string values can be exploited for injections or other attacks if they don't undergo strict validation at every level.

The solution to this problem lies in using mechanisms that explicitly restrict the set of permissible values. These include:

  • Backend Enums: In programming languages like Java, C#, or TypeScript, the enum type can be used for fields that accept a restricted set of values.
  • Sealed Traits/Classes: In Scala or Kotlin, sealed traits or sealed classes can model constrained type hierarchies.
  • JSON Schema with enum keyword: For JSON schema validation, the enum keyword can explicitly list all permissible string values. This allows validating incoming requests at the API gateway or controller level.
  • Strict validation at the API controller level: Even if using an enum at the schema level isn't possible, strict validation of incoming string values against a whitelist must be implemented.
// Example JSON Schema with enum for the status field
{
  "type": "object",
  "properties": {
    "login": { "type": "string" },
    "status": {
      "type": "string",
      "enum": ["active", "inactive", "pending"]
    },
    "roles": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": ["admin", "user", "guest"]
      }
    }
  },
  "required": ["login", "status", "roles"]
}

Implementing such mechanisms early in a project significantly enhances API reliability, simplifies client application development, and reduces errors related to incorrect data.

Key Takeaways

  • Accurate HTTP Statuses: Always return correct HTTP statuses (4xx for client errors, 5xx for server errors) instead of misleading 200 OKs to ensure appropriate client and monitoring system responses.
  • Unified Error Format: Develop and strictly adhere to a single, standardized format for all error responses, avoiding the leakage of internal information (stack traces, database details).
  • Pagination and Versioning: Implement pagination for all data collections and version your API as soon as the first external consumers appear to ensure scalability and managed evolution.
  • Idempotent POST Requests: Implement idempotency mechanisms (e.g., Idempotency-Key) for POST operations that can cause side effects to prevent duplication during retries.
  • Strict Data Validation: Use enums, JSON Schema, or strict server-side validation for fields with a limited set of values, eliminating input errors and ensuring data integrity.

— Editorial Team

Advertisement 728x90

Read Next