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.
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.
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
- Add Authentication: Implement token-based authentication and appropriate status codes (401, 403)
- Add Rate Limiting: Implement rate limiting with the 429 status code
- Add ETag Support: Implement ETag headers and 304 Not Modified responses for efficient caching
- Validate Input: Use a validation library like express-validator for more robust input validation
- Add Task Categories: Extend the API to support task categories with appropriate nested routes
Summary
- HTTP Methods define the action to be performed on a resource:
- GET: Retrieve resources (safe, idempotent, cacheable)
- POST: Create resources (unsafe, not idempotent)
- PUT: Replace resources completely (unsafe, idempotent)
- PATCH: Update resources partially (unsafe, not necessarily idempotent)
- DELETE: Remove resources (unsafe, idempotent)
- HEAD: Get metadata only (like GET but without response body)
- OPTIONS: Get available methods for a resource
- HTTP Status Codes indicate the result of the request:
- 1xx: Informational responses (rarely used in APIs)
- 2xx: Successful responses (200 OK, 201 Created, 204 No Content)
- 3xx: Redirection responses (301 Moved Permanently, 304 Not Modified)
- 4xx: Client error responses (400 Bad Request, 401 Unauthorized, 404 Not Found)
- 5xx: Server error responses (500 Internal Server Error, 503 Service Unavailable)
- Method Selection is critical for RESTful API design, matching HTTP methods to CRUD operations
- Error Handling should be robust and consistent, with appropriate status codes and structured error messages
- Special Cases like controller resources and complex search operations may require alternative approaches
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.