HTTP Methods and Status Codes

The foundation of RESTful API communication

Introduction to HTTP Communication

HTTP (Hypertext Transfer Protocol) is the foundation of data communication on the web. It defines a set of request methods (HTTP methods) and response status codes that enable clients and servers to communicate in a structured way.

What is HTTP?

HTTP is an application layer protocol that forms the basis for data exchange on the World Wide Web. It follows a classic client-server model, where a client (typically a web browser) sends an HTTP request to a server, and the server returns an HTTP response.

sequenceDiagram participant Client participant Server Client->>Server: HTTP Request (Method, Headers, Body) Note right of Client: GET /api/users HTTP/1.1
Host: example.com
Accept: application/json Server->>Client: HTTP Response (Status, Headers, Body) Note left of Server: HTTP/1.1 200 OK
Content-Type: application/json

{...data...}

The Restaurant Analogy

Think of HTTP communication like a restaurant experience:

  • The client is like a customer at a restaurant
  • The server is like the restaurant staff (waiters, kitchen, etc.)
  • HTTP methods are like different ways to interact with the restaurant:
    • GET: Looking at the menu without changing anything
    • POST: Placing a new order
    • PUT: Replacing your entire meal with a different one
    • PATCH: Modifying your existing order (adding salt, removing an item)
    • DELETE: Canceling your order
  • Status codes are like the responses from the restaurant:
    • 2xx: "Your order was successful" (200 OK, 201 Created)
    • 3xx: "We've moved to a new location" (301 Moved Permanently)
    • 4xx: "There's a problem with your order" (400 Bad Request, 404 Not Found)
    • 5xx: "We have a problem in the kitchen" (500 Internal Server Error)

Understanding HTTP Methods in Depth

HTTP methods indicate the desired action to be performed on a resource. They are a key part of the uniform interface constraint in REST architecture.

GET: Retrieving Resources

Purpose

The GET method is used to retrieve data from a specified resource. GET requests should only retrieve data without causing any side effects.

Characteristics

  • Safe: Yes (Does not change server state)
  • Idempotent: Yes (Multiple identical requests have the same effect as a single request)
  • Cacheable: Yes
  • Request Body: Not typically used
  • Successful Response: 200 OK (with response body)

Example Request

GET /api/users/123 HTTP/1.1
Host: example.com
Accept: application/json

Example Express Implementation

// GET a specific user
app.get('/api/users/:id', (req, res) => {
  try {
    const user = getUserById(req.params.id);
    
    if (!user) {
      return res.status(404).json({
        error: 'Not Found',
        message: `User with ID ${req.params.id} not found`
      });
    }
    
    res.status(200).json(user);
  } catch (error) {
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

Best Practices

  • Always make GET requests safe with no side effects
  • Use proper error handling for resources that don't exist (404)
  • Implement caching with appropriate Cache-Control headers
  • Support filtering, sorting, and pagination for collection resources
  • Keep URLs readable and logical

Common Pitfalls

  • Implementing GET requests that modify resources (unsafe)
  • Not handling non-existent resources properly
  • Returning too much data without pagination
  • Not using appropriate caching mechanisms

POST: Creating Resources

Purpose

The POST method is used to submit data to a resource, often creating a new resource. The server decides which URL the new resource will have.

Characteristics

  • Safe: No (Changes server state)
  • Idempotent: No (Multiple requests typically create multiple resources)
  • Cacheable: Rarely (Only if response includes explicit freshness information)
  • Request Body: Contains data to be processed
  • Successful Response: 201 Created (typically with the created resource in the response body)

Example Request

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "name": "John Doe",
  "email": "john@example.com",
  "role": "user"
}

Example Express Implementation

// Create a new user
app.post('/api/users', (req, res) => {
  try {
    const { name, email, role } = req.body;
    
    // Validate required fields
    if (!name || !email) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Name and email are required'
      });
    }
    
    // Check if user with this email already exists
    const existingUser = getUserByEmail(email);
    if (existingUser) {
      return res.status(409).json({
        error: 'Conflict',
        message: 'User with this email already exists'
      });
    }
    
    // Create new user
    const newUser = createUser({ name, email, role });
    
    // Return the created user with 201 Created status
    res.status(201)
       .location(`/api/users/${newUser.id}`) // Set Location header
       .json(newUser);
  } catch (error) {
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

Best Practices

  • Return 201 Created status code for successful resource creation
  • Include the Location header pointing to the new resource URL
  • Return the created resource in the response body
  • Validate input thoroughly before creating resources
  • Handle duplicate resources appropriately (409 Conflict)

Common Pitfalls

  • Using POST for idempotent operations (use PUT or PATCH instead)
  • Not validating input data properly
  • Returning 200 OK instead of 201 Created
  • Not handling duplicate resources correctly
  • Omitting the Location header

Alternative Uses

While POST is primarily used for resource creation, it can also be used for:

  • Complex operations that don't fit the other HTTP methods
  • Batch operations (creating multiple resources at once)
  • Processing or transformation operations
  • Search operations that are too complex for GET query parameters

PUT: Replacing Resources

Purpose

The PUT method is used to update a resource by completely replacing it with the provided data. If the resource doesn't exist, some servers may create it (but this is not guaranteed).

Characteristics

  • Safe: No (Changes server state)
  • Idempotent: Yes (Multiple identical requests have the same effect as a single request)
  • Cacheable: No
  • Request Body: Contains the complete new state of the resource
  • Successful Response: 200 OK (if resource updated) or 201 Created (if resource created)

Example Request

PUT /api/users/123 HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "name": "John Smith",
  "email": "john.smith@example.com",
  "role": "admin"
}

Example Express Implementation

// Update (replace) a user
app.put('/api/users/:id', (req, res) => {
  try {
    const { name, email, role } = req.body;
    
    // Validate required fields
    if (!name || !email) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Name and email are required'
      });
    }
    
    // Check if user exists
    const existingUser = getUserById(req.params.id);
    
    if (!existingUser) {
      // Option 1: Return 404 if resource doesn't exist
      return res.status(404).json({
        error: 'Not Found',
        message: `User with ID ${req.params.id} not found`
      });
      
      // Option 2: Create the resource if it doesn't exist
      // const newUser = createUser({ id: req.params.id, name, email, role });
      // return res.status(201).json(newUser);
    }
    
    // Replace user
    const updatedUser = updateUser(req.params.id, { name, email, role });
    
    // Return updated user
    res.status(200).json(updatedUser);
  } catch (error) {
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

Best Practices

  • Require the client to specify all attributes (complete representation)
  • Be consistent about whether PUT creates resources or just updates them
  • Use appropriate status codes (200 for update, 201 for creation)
  • Make PUT idempotent by replacing the entire resource
  • Validate the entire resource before applying changes

Common Pitfalls

  • Using PUT for partial updates (use PATCH instead)
  • Merging data instead of replacing it completely
  • Inconsistent handling of non-existent resources
  • Not validating the entire resource

PATCH: Partially Updating Resources

Purpose

The PATCH method is used to make partial updates to a resource. Unlike PUT, which replaces the entire resource, PATCH only applies the changes specified in the request.

Characteristics

  • Safe: No (Changes server state)
  • Idempotent: Not necessarily (Depends on implementation)
  • Cacheable: No
  • Request Body: Contains only the changes to be applied
  • Successful Response: 200 OK (with updated resource) or 204 No Content

Example Request

PATCH /api/users/123 HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "email": "john.updated@example.com"
}

Example Express Implementation

// Partially update a user
app.patch('/api/users/:id', (req, res) => {
  try {
    // Check if user exists
    const existingUser = getUserById(req.params.id);
    
    if (!existingUser) {
      return res.status(404).json({
        error: 'Not Found',
        message: `User with ID ${req.params.id} not found`
      });
    }
    
    // Validate the changes (optional, depends on your requirements)
    if (req.body.email && !isValidEmail(req.body.email)) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Invalid email format'
      });
    }
    
    // Apply partial update
    const updatedUser = updateUser(req.params.id, req.body);
    
    // Return updated user
    res.status(200).json(updatedUser);
  } catch (error) {
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

Best Practices

  • Only update the fields specified in the request
  • Validate each field being updated
  • Consider supporting JSON Patch format (RFC 6902) for precise updates
  • Make PATCH idempotent when possible
  • Be careful with array operations (replacing vs. appending)

Common Pitfalls

  • Implementing PATCH as a full resource replacement (it's not PUT)
  • Not properly validating partial updates
  • Ignoring the potential complexity of merging data
  • Failing to handle conflicts when multiple fields are updated simultaneously

JSON Patch Format

Consider supporting the JSON Patch format for more precise updates:

PATCH /api/users/123 HTTP/1.1
Host: example.com
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/email", "value": "new.email@example.com" },
  { "op": "add", "path": "/address", "value": { "city": "New York", "zip": "10001" } },
  { "op": "remove", "path": "/phone" }
]

DELETE: Removing Resources

Purpose

The DELETE method is used to remove a resource from the server. Once deleted, the resource is typically no longer accessible.

Characteristics

  • Safe: No (Changes server state)
  • Idempotent: Yes (Multiple identical requests have the same effect as a single request)
  • Cacheable: No
  • Request Body: Not typically used
  • Successful Response: 204 No Content (typically) or 200 OK (with response body)

Example Request

DELETE /api/users/123 HTTP/1.1
Host: example.com

Example Express Implementation

// Delete a user
app.delete('/api/users/:id', (req, res) => {
  try {
    // Check if user exists
    const existingUser = getUserById(req.params.id);
    
    if (!existingUser) {
      return res.status(404).json({
        error: 'Not Found',
        message: `User with ID ${req.params.id} not found`
      });
    }
    
    // Delete user
    deleteUser(req.params.id);
    
    // Return 204 No Content status (no response body)
    res.status(204).end();
  } catch (error) {
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

Best Practices

  • Return 204 No Content for successful deletions (or 200 OK with metadata)
  • Make DELETE idempotent (second DELETE to same resource doesn't error)
  • Consider implementing "soft deletes" for recoverable data
  • Properly authorize deletion operations
  • Consider the implications of cascade deletions for related resources

Common Pitfalls

  • Not making DELETE idempotent
  • Insufficient authorization checks
  • Not considering cascading deletes for related resources
  • Returning inappropriate status codes

Soft Delete Pattern

In many applications, it's better to implement "soft delete" rather than permanently removing data:

// Soft delete implementation
app.delete('/api/users/:id', (req, res) => {
  try {
    // Check if user exists
    const existingUser = getUserById(req.params.id);
    
    if (!existingUser) {
      return res.status(404).json({
        error: 'Not Found',
        message: `User with ID ${req.params.id} not found`
      });
    }
    
    // Mark user as deleted instead of removing completely
    markUserAsDeleted(req.params.id);
    
    // Return 204 No Content
    res.status(204).end();
  } catch (error) {
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

Other HTTP Methods

HEAD

The HEAD method is identical to GET, but the server doesn't return a response body. It's useful for checking if a resource exists or has been modified without downloading the entire resource.

// Implementing HEAD method
app.head('/api/users/:id', (req, res) => {
  // Check if resource exists
  const user = getUserById(req.params.id);
  
  if (!user) {
    return res.status(404).end();
  }
  
  // Set headers but don't send a body
  res.status(200)
     .set('Last-Modified', user.updatedAt)
     .set('Content-Type', 'application/json')
     .end();
});

OPTIONS

The OPTIONS method returns information about the communication options available for a resource. It's often used for CORS preflight requests.

// Implementing OPTIONS method
app.options('/api/users/:id', (req, res) => {
  res.set('Allow', 'GET, PUT, PATCH, DELETE, HEAD, OPTIONS')
     .set('Access-Control-Allow-Methods', 'GET, PUT, PATCH, DELETE')
     .status(204)
     .end();
});

TRACE, CONNECT

These methods are less commonly used in RESTful APIs:

  • TRACE: Echoes back the received request, mainly for debugging
  • CONNECT: Establishes a tunnel to the server identified by the resource

Choosing the Right HTTP Method

Selecting the appropriate HTTP method for your API endpoints is crucial for maintaining RESTful principles and ensuring your API is intuitive to use.

flowchart TD A{What operation
are you performing?} -->|Reading data| B{Single resource
or collection?} A -->|Creating data| C{Do you know
the resource ID?} A -->|Updating data| D{Complete replacement
or partial update?} A -->|Deleting data| E[Use DELETE] B -->|Single resource| F[Use GET
/resource/:id] B -->|Collection| G[Use GET
/resources] C -->|No| H[Use POST
/resources] C -->|Yes| I[Use PUT
/resources/:id] D -->|Complete replacement| J[Use PUT
/resources/:id] D -->|Partial update| K[Use PATCH
/resources/:id] L{Is it an action
that doesn't fit CRUD?} -->|Yes| M[Consider POST
/resources/:id/action] L -->|No| A

Method Selection Guide

Operation Collection Resource
(/users)
Singleton Resource
(/users/123)
Create POST /users PUT /users/123 (if ID is client-provided)
Read GET /users GET /users/123
Update (Complete) PUT /users (bulk update, rare) PUT /users/123
Update (Partial) PATCH /users (bulk update, rare) PATCH /users/123
Delete DELETE /users (bulk delete, careful!) DELETE /users/123
Read Metadata Only HEAD /users HEAD /users/123
Special Actions POST /users/search POST /users/123/activate

Special Cases and Non-CRUD Operations

Not all API operations fit neatly into CRUD. For these special cases, consider these approaches:

Controller Resources

For operations that are more like functions or commands than resources:

// Example: Email verification
POST /users/123/verify-email

// Example: Password reset
POST /users/reset-password

// Example: Bulk operations
POST /emails/send-batch

Complex Search

For search operations that require complex parameters or request bodies:

// Using GET with query parameters for basic search
GET /products?category=electronics&minPrice=100

// Using POST for complex search criteria
POST /products/search
{
  "categories": ["electronics", "computers"],
  "priceRange": { "min": 100, "max": 500 },
  "specifications": {
    "ram": "8GB",
    "processor": ["i5", "i7"]
  }
}

State Transitions

For changing the state of a resource through a defined workflow:

// Example: Order processing
POST /orders/123/ship
POST /orders/123/cancel
POST /orders/123/refund

// Alternative approach: using PATCH
PATCH /orders/123
{
  "status": "shipped"
}

HTTP Status Codes in Detail

HTTP status codes indicate the result of an HTTP request. They're grouped into five classes, each representing a different category of response.

1xx: Informational Responses

These status codes indicate a provisional response. They're rarely used directly in RESTful APIs.

100 Continue

Indicates that the initial part of the request has been received and the client should continue sending the remainder of the request.

101 Switching Protocols

The server is switching protocols as requested by the client (e.g., switching to WebSockets).

102 Processing

The server has received and is processing the request, but no response is available yet.

103 Early Hints

Used to return some response headers before final HTTP message.

2xx: Successful Responses

These status codes indicate that the client's request was successfully received, understood, and accepted.

200 OK

The request has succeeded. The response body typically contains the requested data.

When to use: For successful GET requests, and successful PUT/PATCH requests that return the updated resource.

// Example
app.get('/api/users/:id', (req, res) => {
  const user = getUserById(req.params.id);
  
  if (user) {
    res.status(200).json(user);
  } else {
    res.status(404).json({ error: 'User not found' });
  }
});

201 Created

The request has succeeded and a new resource has been created. The response typically includes the Location header with the URI of the new resource.

When to use: For successful POST requests that create new resources, or PUT requests that create new resources.

// Example
app.post('/api/users', (req, res) => {
  const newUser = createUser(req.body);
  
  res.status(201)
     .location(`/api/users/${newUser.id}`)
     .json(newUser);
});

202 Accepted

The request has been accepted for processing, but the processing has not been completed. This is useful for asynchronous operations.

When to use: For long-running operations that will continue processing after the response is sent.

// Example for a long-running process
app.post('/api/reports/generate', (req, res) => {
  const reportId = queueReportGeneration(req.body);
  
  res.status(202).json({
    message: 'Report generation started',
    reportId: reportId,
    status: 'processing',
    statusUrl: `/api/reports/${reportId}/status`
  });
});

204 No Content

The server has successfully processed the request and there is no content to send in the response body.

When to use: For successful DELETE requests, or for operations that don't need to return content.

// Example
app.delete('/api/users/:id', (req, res) => {
  const success = deleteUser(req.params.id);
  
  if (success) {
    res.status(204).end();
  } else {
    res.status(404).json({ error: 'User not found' });
  }
});

206 Partial Content

The server is delivering only part of the resource due to a range request by the client.

When to use: For responses to range requests, such as for video or large file streaming.

// Example for partial file download
app.get('/api/files/:id', (req, res) => {
  const file = getFileById(req.params.id);
  
  if (!file) {
    return res.status(404).json({ error: 'File not found' });
  }
  
  const range = req.headers.range;
  
  if (range) {
    // Process range request
    const parts = range.replace(/bytes=/, '').split('-');
    const start = parseInt(parts[0], 10);
    const end = parts[1] ? parseInt(parts[1], 10) : file.size - 1;
    
    res.status(206)
       .set({
         'Content-Range': `bytes ${start}-${end}/${file.size}`,
         'Accept-Ranges': 'bytes',
         'Content-Length': end - start + 1
       })
       .sendFile(file.path, { start, end });
  } else {
    // Send entire file
    res.status(200).sendFile(file.path);
  }
});

3xx: Redirection Messages

These status codes indicate that further action needs to be taken by the client to complete the request.

301 Moved Permanently

The resource has been permanently moved to a new URL, provided in the Location header.

When to use: When a resource's URL has permanently changed.

// Example
app.get('/api/oldpath', (req, res) => {
  res.redirect(301, '/api/newpath');
});

302 Found

The resource temporarily resides at a different URL, provided in the Location header.

When to use: For temporary redirects.

// Example
app.get('/api/current-promotion', (req, res) => {
  res.redirect(302, '/api/promotions/summer-2025');
});

303 See Other

The response to the request can be found at another URI using a GET method.

When to use: After a POST operation, to direct the client to the created resource's URI.

// Example
app.post('/api/orders', (req, res) => {
  const orderId = createOrder(req.body);
  
  res.redirect(303, `/api/orders/${orderId}`);
});

304 Not Modified

The resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match.

When to use: For conditional GET requests, to reduce bandwidth.

// Example with ETag for caching
app.get('/api/users/:id', (req, res) => {
  const user = getUserById(req.params.id);
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  // Generate ETag based on user data
  const etag = generateETag(user);
  
  // Check If-None-Match header
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }
  
  res.set('ETag', etag).json(user);
});

307 Temporary Redirect

The resource temporarily resides at another URL. Unlike 302, the request method should not change.

When to use: For temporary redirects where the HTTP method must be preserved.

308 Permanent Redirect

The resource has been permanently moved to another URL. Unlike 301, the request method should not change.

When to use: For permanent redirects where the HTTP method must be preserved.

4xx: Client Error Responses

These status codes indicate that the client seems to have made an error. They're crucial for proper API error handling.

400 Bad Request

The server cannot process the request due to a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).

When to use: For requests with invalid syntax or invalid parameters.

// Example
app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  
  if (!name || !email) {
    return res.status(400).json({
      error: 'Bad Request',
      message: 'Name and email are required fields'
    });
  }
  
  // Continue processing valid request...
});

401 Unauthorized

Authentication is required and has failed or has not been provided. The response must include a WWW-Authenticate header.

When to use: When a request requires authentication and no credentials are provided, or the provided credentials are invalid.

// Example
app.get('/api/protected', (req, res) => {
  const token = req.headers.authorization;
  
  if (!token) {
    return res.status(401)
              .set('WWW-Authenticate', 'Bearer')
              .json({
                error: 'Unauthorized',
                message: 'Authentication required'
              });
  }
  
  // Validate token and continue...
});

403 Forbidden

The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource.

When to use: When a client is authenticated but doesn't have permission to access the requested resource.

// Example
app.delete('/api/users/:id', (req, res) => {
  const currentUser = req.user;
  
  if (currentUser.role !== 'admin' && currentUser.id !== req.params.id) {
    return res.status(403).json({
      error: 'Forbidden',
      message: 'You do not have permission to delete this user'
    });
  }
  
  // Continue with authorized operation...
});

404 Not Found

The server cannot find the requested resource. This could mean the URL is misspelled or the resource no longer exists.

When to use: When the requested resource doesn't exist.

// Example
app.get('/api/users/:id', (req, res) => {
  const user = getUserById(req.params.id);
  
  if (!user) {
    return res.status(404).json({
      error: 'Not Found',
      message: `User with ID ${req.params.id} not found`
    });
  }
  
  // Return found resource...
});

405 Method Not Allowed

The request method is known by the server but is not supported by the target resource.

When to use: When the client uses an HTTP method that the resource doesn't support.

// Example using Express
const router = express.Router();

// Only allow GET and POST methods for this route
router.route('/items')
  .get((req, res) => { /* Handle GET */ })
  .post((req, res) => { /* Handle POST */ })
  .all((req, res) => {
    res.status(405)
       .set('Allow', 'GET, POST')
       .json({
         error: 'Method Not Allowed',
         message: `${req.method} is not allowed for this resource`
       });
  });

409 Conflict

The request conflicts with the current state of the server, such as a duplicate entry or an outdated version.

When to use: When a request conflicts with the current state of the resource.

// Example for duplicate resource
app.post('/api/users', (req, res) => {
  const { email } = req.body;
  
  const existingUser = getUserByEmail(email);
  
  if (existingUser) {
    return res.status(409).json({
      error: 'Conflict',
      message: 'A user with this email already exists'
    });
  }
  
  // Create user...
});

422 Unprocessable Entity

The server understands the content type and syntax of the request, but was unable to process the contained instructions.

When to use: For validation errors and other semantic errors in the request.

// Example for validation errors
app.post('/api/users', (req, res) => {
  const { name, email, age } = req.body;
  
  const errors = [];
  
  if (name && name.length < 2) {
    errors.push('Name must be at least 2 characters long');
  }
  
  if (email && !isValidEmail(email)) {
    errors.push('Email format is invalid');
  }
  
  if (age && (age < 18 || age > 120)) {
    errors.push('Age must be between 18 and 120');
  }
  
  if (errors.length > 0) {
    return res.status(422).json({
      error: 'Unprocessable Entity',
      message: 'Validation failed',
      details: errors
    });
  }
  
  // Process valid request...
});

429 Too Many Requests

The user has sent too many requests in a given amount of time ("rate limiting").

When to use: When implementing rate limiting for API consumers.

// Example using a rate limiting middleware
const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: {
    error: 'Too Many Requests',
    message: 'Too many requests, please try again later',
    retryAfter: 15 * 60 // seconds
  },
  statusCode: 429,
  headers: true // adds X-RateLimit headers
});

app.use('/api', apiLimiter);

5xx: Server Error Responses

These status codes indicate that the server failed to fulfill a valid request.

500 Internal Server Error

The server has encountered a situation it doesn't know how to handle.

When to use: For uncaught exceptions or unexpected conditions.

// Example with error handling
app.get('/api/users', (req, res) => {
  try {
    const users = fetchUsers();
    res.json(users);
  } catch (error) {
    console.error('Error fetching users:', error);
    res.status(500).json({
      error: 'Internal Server Error',
      message: 'An unexpected error occurred'
    });
  }
});

// Global error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({
    error: 'Internal Server Error',
    message: 'An unexpected error occurred'
  });
});

501 Not Implemented

The server does not support the functionality required to fulfill the request.

When to use: When a feature or endpoint is planned but not yet implemented.

// Example
app.put('/api/files/:id', (req, res) => {
  res.status(501).json({
    error: 'Not Implemented',
    message: 'File update functionality is coming soon'
  });
});

502 Bad Gateway

The server, while acting as a gateway or proxy, received an invalid response from an upstream server.

When to use: When your API calls another service that returns an invalid response.

503 Service Unavailable

The server is not ready to handle the request, typically due to maintenance or temporary overloading.

When to use: During maintenance periods or when the server is overloaded.

// Example for maintenance mode
const isMaintenanceMode = process.env.MAINTENANCE_MODE === 'true';

app.use((req, res, next) => {
  if (isMaintenanceMode) {
    return res.status(503)
              .set('Retry-After', '3600') // seconds
              .json({
                error: 'Service Unavailable',
                message: 'System is under maintenance, please try again later',
                retryAfter: '1 hour'
              });
  }
  
  next();
});

504 Gateway Timeout

The server, while acting as a gateway or proxy, did not receive a timely response from an upstream server.

When to use: When your API calls another service that times out.

Implementing Robust Error Handling

Proper error handling is essential for building reliable APIs. Here are best practices for handling errors in your Express.js applications.

Structured Error Responses

Maintain a consistent structure for error responses across your API:

// Example of a structured error response
{
  "error": "Bad Request",          // A simple error title or code
  "message": "Invalid input data", // A human-readable message
  "details": [                     // Optional detailed errors
    "Email is required",
    "Password must be at least 8 characters"
  ],
  "timestamp": "2025-05-04T12:34:56.789Z", // When the error occurred
  "path": "/api/users",            // The requested endpoint
  "requestId": "abc-123-xyz"       // Optional request identifier for tracking
}

Centralized Error Handling

Use Express's error handling middleware to centralize error handling:

// CustomError class for consistent error handling
class CustomError extends Error {
  constructor(statusCode, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
    this.timestamp = new Date().toISOString();
  }
}

// Route handler that throws custom errors
app.post('/api/users', (req, res, next) => {
  try {
    const { name, email } = req.body;
    
    if (!name || !email) {
      throw new CustomError(400, 'Missing required fields', {
        name: name ? null : 'Name is required',
        email: email ? null : 'Email is required'
      });
    }
    
    // Check for duplicate email
    const existingUser = getUserByEmail(email);
    if (existingUser) {
      throw new CustomError(409, 'Email already exists');
    }
    
    // Continue processing...
  } catch (error) {
    next(error); // Pass to error handler
  }
});

// Centralized error handling middleware
app.use((err, req, res, next) => {
  console.error(err);
  
  // Handle CustomError instances
  if (err instanceof CustomError) {
    return res.status(err.statusCode).json({
      error: getErrorTitle(err.statusCode),
      message: err.message,
      details: err.details,
      timestamp: err.timestamp,
      path: req.path
    });
  }
  
  // Handle other known error types
  if (err.name === 'ValidationError') {
    return res.status(422).json({
      error: 'Validation Error',
      message: 'Invalid input data',
      details: formatValidationErrors(err),
      timestamp: new Date().toISOString(),
      path: req.path
    });
  }
  
  // Handle unexpected errors
  res.status(500).json({
    error: 'Internal Server Error',
    message: process.env.NODE_ENV === 'production' 
      ? 'An unexpected error occurred' 
      : err.message,
    timestamp: new Date().toISOString(),
    path: req.path
  });
});

// Helper function to get error titles
function getErrorTitle(statusCode) {
  const titles = {
    400: 'Bad Request',
    401: 'Unauthorized',
    403: 'Forbidden',
    404: 'Not Found',
    409: 'Conflict',
    422: 'Unprocessable Entity',
    429: 'Too Many Requests',
    500: 'Internal Server Error',
    501: 'Not Implemented',
    503: 'Service Unavailable'
  };
  
  return titles[statusCode] || 'Error';
}

Validation Error Handling

Handle validation errors consistently (in this example, using express-validator):

const { body, validationResult } = require('express-validator');

// Define validation rules
const userValidationRules = [
  body('name').notEmpty().withMessage('Name is required'),
  body('email').isEmail().withMessage('Email must be valid'),
  body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
];

// Validation middleware
const validate = (req, res, next) => {
  const errors = validationResult(req);
  
  if (!errors.isEmpty()) {
    return res.status(422).json({
      error: 'Validation Error',
      message: 'Invalid input data',
      details: errors.array().map(err => err.msg),
      timestamp: new Date().toISOString(),
      path: req.path
    });
  }
  
  next();
};

// Route with validation
app.post('/api/users', userValidationRules, validate, (req, res) => {
  // Process valid request
});

Async Error Handling

Properly handle errors in async functions:

// Helper to catch async errors
const asyncHandler = fn => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Using the helper with async route handlers
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserById(req.params.id);
  
  if (!user) {
    throw new CustomError(404, `User with ID ${req.params.id} not found`);
  }
  
  res.json(user);
}));

Error Logging

Implement proper error logging for troubleshooting:

// Logger setup (using Winston as an example)
const winston = require('winston');

const logger = winston.createLogger({
  level: 'error',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'error.log' })
  ]
});

// Add logging to error handler
app.use((err, req, res, next) => {
  // Log the error
  logger.error({
    message: err.message,
    stack: err.stack,
    method: req.method,
    url: req.originalUrl,
    ip: req.ip,
    userId: req.user ? req.user.id : null
  });
  
  // Handle the error response
  // ...
});

Practical Exercise

Exercise: Build a Task Management API with Proper HTTP Methods and Status Codes

Create a RESTful API for managing tasks, implementing all the HTTP methods correctly with appropriate status codes and error handling.

Step 1: Setup Project

mkdir task-manager-api
cd task-manager-api
npm init -y
npm install express uuid cors morgan

Step 2: Create the Main Application File

Create a file named app.js:

const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid');

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

// In-memory data store
let tasks = [
  {
    id: '1',
    title: 'Complete project proposal',
    description: 'Finish the client proposal with cost estimates',
    status: 'in-progress',
    priority: 'high',
    dueDate: '2025-05-15',
    createdAt: '2025-05-01T10:30:00Z'
  },
  {
    id: '2',
    title: 'Buy groceries',
    description: 'Milk, eggs, bread, and vegetables',
    status: 'pending',
    priority: 'medium',
    dueDate: '2025-05-05',
    createdAt: '2025-05-01T11:45:00Z'
  },
  {
    id: '3',
    title: 'Schedule team meeting',
    description: 'Quarterly planning session with the development team',
    status: 'completed',
    priority: 'high',
    dueDate: '2025-05-02',
    createdAt: '2025-04-28T09:15:00Z',
    completedAt: '2025-05-02T14:20:00Z'
  }
];

// Custom error class
class ApiError extends Error {
  constructor(statusCode, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
  }
}

// GET all tasks (with filtering, sorting, and pagination)
app.get('/api/tasks', (req, res) => {
  try {
    // Extract query parameters
    const { 
      status, 
      priority, 
      search,
      sort = 'createdAt', 
      order = 'desc',
      page = 1,
      limit = 10
    } = req.query;
    
    // Filter tasks
    let filteredTasks = [...tasks];
    
    if (status) {
      filteredTasks = filteredTasks.filter(task => 
        task.status === status
      );
    }
    
    if (priority) {
      filteredTasks = filteredTasks.filter(task => 
        task.priority === priority
      );
    }
    
    if (search) {
      const searchLower = search.toLowerCase();
      filteredTasks = filteredTasks.filter(task => 
        task.title.toLowerCase().includes(searchLower) || 
        (task.description && task.description.toLowerCase().includes(searchLower))
      );
    }
    
    // Sort tasks
    filteredTasks.sort((a, b) => {
      const aValue = a[sort];
      const bValue = b[sort];
      
      if (order.toLowerCase() === 'asc') {
        return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
      } else {
        return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
      }
    });
    
    // Paginate
    const startIndex = (parseInt(page) - 1) * parseInt(limit);
    const endIndex = startIndex + parseInt(limit);
    const paginatedTasks = filteredTasks.slice(startIndex, endIndex);
    
    // Prepare pagination metadata
    const totalTasks = filteredTasks.length;
    const totalPages = Math.ceil(totalTasks / parseInt(limit));
    
    res.status(200).json({
      data: paginatedTasks,
      meta: {
        totalTasks,
        currentPage: parseInt(page),
        totalPages,
        limit: parseInt(limit)
      }
    });
  } catch (error) {
    console.error('Error getting tasks:', error);
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

// GET a specific task by ID
app.get('/api/tasks/:id', (req, res) => {
  try {
    const task = tasks.find(t => t.id === req.params.id);
    
    if (!task) {
      return res.status(404).json({
        error: 'Not Found',
        message: `Task with ID ${req.params.id} not found`
      });
    }
    
    res.status(200).json(task);
  } catch (error) {
    console.error('Error getting task:', error);
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });
  }
});

// POST a new task
app.post('/api/tasks', (req, res) => {
  try {
    const { title, description, status, priority, dueDate } = req.body;
    
    // Validate required fields
    if (!title) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Title is required'
      });
    }
    
    // Validate status if provided
    if (status && !['pending', 'in-progress', 'completed'].includes(status)) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Status must be pending, in-progress, or completed'
      });
    }
    
    // Validate priority if provided
    if (priority && !['low', 'medium', 'high'].includes(priority)) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Priority must be low, medium, or high'
      });
    }
    
    // Create new task
    const newTask = {
      id: uuidv4(),
      title,
      description: description || '',
      status: status || 'pending',
      priority: priority || 'medium',
      dueDate: dueDate || null,
      createdAt: new Date().toISOString(),
      ...(status === 'completed' ? { completedAt: new Date().toISOString() } : {})
    };
    
    // Add to collection
    tasks.push(newTask);
    
    // Return 201 Created with the new task
    res.status(201)
       .location(`/api/tasks/${newTask.id}`)
       .json(newTask);
  } catch (error) {
    console.error('Error creating task:', error);
    res.status(500).json({
      error: 'Internal Server Error',
      message: error.message
    });

    // PUT (replace) a task
    app.put('/api/tasks/:id', (req, res) => {
      try {
        const { title, description, status, priority, dueDate } = req.body;
        
        // Validate required fields
        if (!title) {
          return res.status(400).json({
            error: 'Bad Request',
            message: 'Title is required'
          });
        }
        
        // Validate status if provided
        if (status && !['pending', 'in-progress', 'completed'].includes(status)) {
          return res.status(400).json({
            error: 'Bad Request',
            message: 'Status must be pending, in-progress, or completed'
          });
        }
        
        // Validate priority if provided
        if (priority && !['low', 'medium', 'high'].includes(priority)) {
          return res.status(400).json({
            error: 'Bad Request',
            message: 'Priority must be low, medium, or high'
          });
        }
        
        // Check if task exists
        const taskIndex = tasks.findIndex(t => t.id === req.params.id);
        
        if (taskIndex === -1) {
          return res.status(404).json({
            error: 'Not Found',
            message: `Task with ID ${req.params.id} not found`
          });
        }
        
        // Preserve creation time from original task
        const { createdAt } = tasks[taskIndex];
        
        // Replace task completely (this is PUT semantics)
        const updatedTask = {
          id: req.params.id,
          title,
          description: description || '',
          status: status || 'pending',
          priority: priority || 'medium',
          dueDate: dueDate || null,
          createdAt,
          updatedAt: new Date().toISOString(),
          ...(status === 'completed' ? { completedAt: new Date().toISOString() } : {})
        };
        
        // Update collection
        tasks[taskIndex] = updatedTask;
        
        // Return 200 OK with updated task
        res.status(200).json(updatedTask);
      } catch (error) {
        console.error('Error updating task:', error);
        res.status(500).json({
          error: 'Internal Server Error',
          message: error.message
        });
      }
    });
    // PATCH (partially update) a task
    app.patch('/api/tasks/:id', (req, res) => {
      try {
        // Check if task exists
        const taskIndex = tasks.findIndex(t => t.id === req.params.id);
        
        if (taskIndex === -1) {
          return res.status(404).json({
            error: 'Not Found',
            message: `Task with ID ${req.params.id} not found`
          });
        }
        
        const existingTask = tasks[taskIndex];
        
        // Validate status if provided
        if (req.body.status && !['pending', 'in-progress', 'completed'].includes(req.body.status)) {
          return res.status(400).json({
            error: 'Bad Request',
            message: 'Status must be pending, in-progress, or completed'
          });
        }
        
        // Validate priority if provided
        if (req.body.priority && !['low', 'medium', 'high'].includes(req.body.priority)) {
          return res.status(400).json({
            error: 'Bad Request',
            message: 'Priority must be low, medium, or high'
          });
        }
        
        // Check if status is being changed to completed
        const isCompletingTask = 
          req.body.status === 'completed' && 
          existingTask.status !== 'completed';
        
        // Update task (partial update, preserving existing fields)
        const updatedTask = {
          ...existingTask,
          ...req.body,
          id: req.params.id,  // Ensure ID doesn't change
          updatedAt: new Date().toISOString(),
          ...(isCompletingTask ? { completedAt: new Date().toISOString() } : {})
        };
        
        // Update collection
        tasks[taskIndex] = updatedTask;
        
        // Return 200 OK with updated task
        res.status(200).json(updatedTask);
      } catch (error) {
        console.error('Error updating task:', error);
        res.status(500).json({
          error: 'Internal Server Error',
          message: error.message
        });
      }
    });
    
    // DELETE a task
    app.delete('/api/tasks/:id', (req, res) => {
      try {
        // Check if task exists
        const taskIndex = tasks.findIndex(t => t.id === req.params.id);
        
        if (taskIndex === -1) {
          return res.status(404).json({
            error: 'Not Found',
            message: `Task with ID ${req.params.id} not found`
          });
        }
        
        // Remove from collection
        tasks.splice(taskIndex, 1);
        
        // Return 204 No Content (success but no content to return)
        res.status(204).end();
      } catch (error) {
        console.error('Error deleting task:', error);
        res.status(500).json({
          error: 'Internal Server Error',
          message: error.message
        });
      }
    });
    
    // POST to mark a task as completed (action endpoint)
    app.post('/api/tasks/:id/complete', (req, res) => {
      try {
        // Check if task exists
        const taskIndex = tasks.findIndex(t => t.id === req.params.id);
        
        if (taskIndex === -1) {
          return res.status(404).json({
            error: 'Not Found',
            message: `Task with ID ${req.params.id} not found`
          });
        }
        
        const task = tasks[taskIndex];
        
        // Check if task is already completed
        if (task.status === 'completed') {
          return res.status(409).json({
            error: 'Conflict',
            message: 'Task is already completed'
          });
        }
        
        // Update task
        const updatedTask = {
          ...task,
          status: 'completed',
          completedAt: new Date().toISOString(),
          updatedAt: new Date().toISOString()
        };
        
        // Update collection
        tasks[taskIndex] = updatedTask;
        
        // Return 200 OK with updated task
        res.status(200).json(updatedTask);
      } catch (error) {
        console.error('Error completing task:', error);
        res.status(500).json({
          error: 'Internal Server Error',
          message: error.message
        });
      }
    });
    
    // Handle 404 - Route not found
    app.use((req, res) => {
      res.status(404).json({
        error: 'Not Found',
        message: `Route ${req.originalUrl} not found`,
        timestamp: new Date().toISOString(),
        path: req.originalUrl
      });
    });
    
    // Global error handler
    app.use((err, req, res, next) => {
      console.error(err.stack);
      
      // Handle custom API errors
      if (err instanceof ApiError) {
        return res.status(err.statusCode).json({
          error: getErrorTitle(err.statusCode),
          message: err.message,
          details: err.details,
          timestamp: new Date().toISOString(),
          path: req.originalUrl
        });
      }
      
      // Default error response for uncaught errors
      res.status(500).json({
        error: 'Internal Server Error',
        message: 'Something went wrong on the server',
        timestamp: new Date().toISOString(),
        path: req.originalUrl
      });
    });
    
    // Helper function to get error titles
    function getErrorTitle(statusCode) {
      const titles = {
        400: 'Bad Request',
        401: 'Unauthorized',
        403: 'Forbidden',
        404: 'Not Found',
        409: 'Conflict',
        422: 'Unprocessable Entity',
        429: 'Too Many Requests',
        500: 'Internal Server Error'
      };
      
      return titles[statusCode] || 'Error';
    }
    
    // Start the server
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
    
    module.exports = app;

Step 3: Test the API

Use Postman or a similar tool to test the API endpoints. Ensure all HTTP methods and status codes are implemented correctly.

Step 4: Bonus Challenges

Implement additional features such as authentication, rate limiting, ETag support, and input validation.

Step 5: Submit Your Code

Once completed, submit your code for review.

Bonus Challenges

  1. Add Authentication: Implement token-based authentication and appropriate status codes (401, 403)
  2. Add Rate Limiting: Implement rate limiting with the 429 status code
  3. Add ETag Support: Implement ETag headers and 304 Not Modified responses for efficient caching
  4. Validate Input: Use a validation library like express-validator for more robust input validation
  5. Add Task Categories: Extend the API to support task categories with appropriate nested routes

Summary

Further Reading

Next Lesson Preview

In our next session, we'll explore API endpoint design, focusing on how to structure and organize your API endpoints effectively. We'll cover topics like resource naming conventions, nested resources, query parameters, and action endpoints. You'll learn best practices for creating intuitive and maintainable API endpoints that follow RESTful principles.