Custom Axios Validation Beyond HTTP Status Codes

By default, Axios determines the success or failure of a request based entirely on standard HTTP status codes via its validateStatus option. However, modern APIs—such as GraphQL endpoints or legacy REST services—often return a 200 OK status code while embedding application-level errors or failure flags inside the response body. This article demonstrates how to bypass standard status-based handling and implement custom, body-level response validation in Axios using response interceptors and runtime schema validators.

The Limitation of validateStatus

Axios provides a built-in validateStatus configuration property that defines which HTTP status codes resolve or reject a promise:

axios.get('/api/data', {
  validateStatus: (status) => status >= 200 && status < 300,
});

Because validateStatus only receives the numeric HTTP status code, it cannot inspect the response headers or the payload body. If an API returns 200 OK alongside { "success": false, "error": "Invalid token" }, validateStatus treats the request as successful.


Implementing Custom Validation with Response Interceptors

The standard and most robust way to validate responses based on custom business logic or response body contents is through Axios Response Interceptors. Interceptors run before .then() or .catch() handlers, allowing you to inspect the full response object, evaluate its payload, and reject the promise when custom criteria are not met.

Basic Business Logic Validation

import axios from 'axios';

// Create an Axios instance
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
});

// Add a response interceptor
apiClient.interceptors.response.use(
  (response) => {
    const data = response.data;

    // Check for custom application-level error flags in the body
    if (data && data.success === false) {
      const customError = new Error(data.message || 'Application Error');
      customError.response = response;
      customError.isCustomAppError = true;
      
      // Reject the promise to route execution to .catch()
      return Promise.reject(customError);
    }

    // Return the response normally if validation passes
    return response;
  },
  (error) => {
    // Handle standard network and HTTP errors (4xx, 5xx)
    return Promise.reject(error);
  }
);

Handling GraphQL Errors Inside Interceptors

GraphQL APIs almost universally respond with HTTP 200 OK, even if query execution fails. An interceptor can detect the errors array in the response body:

apiClient.interceptors.response.use((response) => {
  if (response.data && Array.isArray(response.data.errors) && response.data.errors.length > 0) {
    const graphQLError = new Error(response.data.errors[0].message);
    graphQLError.errors = response.data.errors;
    graphQLError.response = response;
    
    return Promise.reject(graphQLError);
  }
  return response;
});

Schema-Based Validation with Zod

For stricter data integrity, you can combine Axios response interceptors with runtime schema validation libraries such as Zod. This ensures the response not only lacks error flags but also strictly matches the expected structure and types.

import axios from 'axios';
import { z } from 'zod';

const apiClient = axios.create({ baseURL: 'https://api.example.com' });

apiClient.interceptors.response.use((response) => {
  const schema = response.config.schema;

  // If a schema is defined in the request config, validate against it
  if (schema instanceof z.ZodType) {
    const parseResult = schema.safeParse(response.data);

    if (!parseResult.success) {
      const validationError = new Error('Response validation failed');
      validationError.issues = parseResult.error.issues;
      validationError.response = response;

      return Promise.reject(validationError);
    }

    // Replace raw data with sanitized, parsed data
    response.data = parseResult.data;
  }

  return response;
});

Usage Example

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// Pass schema inside the request configuration
apiClient
  .get('/user/1', { schema: UserSchema })
  .then((response) => {
    console.log('Validated User:', response.data);
  })
  .catch((error) => {
    if (error.issues) {
      console.error('Schema Issues:', error.issues);
    } else {
      console.error('Request Error:', error.message);
    }
  });

Wrapping Requests with a Custom Transport Function

If an interceptor applies too broadly across all endpoints, you can encapsulate custom validation logic in a targeted wrapper function:

async function requestWithValidation(config, validateFn) {
  const response = await axios(config);
  
  const isValid = validateFn(response.data, response);
  if (!isValid) {
    const error = new Error('Custom payload validation failed');
    error.response = response;
    throw error;
  }
  
  return response.data;
}

// Usage
try {
  const data = await requestWithValidation(
    { method: 'GET', url: '/api/resource' },
    (body) => body.status === 'OK' && typeof body.payload === 'object'
  );
} catch (err) {
  console.error('Request rejected:', err.message);
}

Summary