Skip to content

Error Handling

FetchClient provides flexible error handling with support for expected status codes, custom error callbacks, and RFC 7807 Problem Details.

Default Behavior

By default, FetchClient throws an error for any non-2xx status code:

ts
import { FetchClient } from "@foundatiofx/fetchclient";

const client = new FetchClient();

try {
  await client.getJSON("/api/not-found");
} catch (error) {
  console.log(error.message); // "404 Not Found"
}

Expected Status Codes

Tell FetchClient which status codes are expected (won't throw):

ts
const response = await client.getJSON("/api/resource", {
  expectedStatusCodes: [404, 410],
});

if (response.status === 404) {
  console.log("Resource not found - this is fine");
}

Prevent All Throwing

Disable throwing entirely:

ts
const response = await client.getJSON("/api/resource", {
  shouldThrowOnUnexpectedStatusCodes: false,
});

// Always returns response, never throws
if (!response.ok) {
  console.log("Request failed:", response.status);
}

Custom Error Callback

Handle errors with custom logic:

ts
const response = await client.getJSON("/api/resource", {
  errorCallback: (response) => {
    if (response.status === 404) {
      console.log("Not found - handling gracefully");
      return true; // Don't throw
    }

    if (response.status === 403) {
      window.location.href = "/login";
      return true; // Don't throw
    }

    // Return false/undefined to throw
    return false;
  },
});

Throwing Custom Errors

ts
const response = await client.getJSON("/api/resource", {
  errorCallback: (response) => {
    if (response.status === 404) {
      throw new NotFoundError("The resource was not found");
    }
    // Falls through to default error
  },
});

Problem Details (RFC 7807)

When APIs return Problem Details format, FetchClient parses it automatically:

ts
const response = await client.postJSON("/api/users", {
  email: "invalid",
});

if (!response.ok) {
  console.log(response.problem.title);   // "Validation Error"
  console.log(response.problem.detail);  // "The request was invalid"
  console.log(response.problem.status);  // 400
  console.log(response.problem.errors);  // { email: ["Invalid email format"] }
}

Problem Details Structure

ts
interface ProblemDetails {
  type?: string;      // URI identifying the problem type
  title?: string;     // Short human-readable summary
  status?: number;    // HTTP status code
  detail?: string;    // Detailed explanation
  instance?: string;  // URI identifying this occurrence
  errors?: Record<string, string[]>;  // Field-level errors
}

Creating Problem Details

Use ProblemDetails for client-side validation:

ts
import { ProblemDetails } from "@foundatiofx/fetchclient";

const problem = new ProblemDetails();
problem.title = "Validation Failed";
problem.status = 400;
problem.errors = {
  email: ["Email is required"],
  password: ["Password must be at least 8 characters"],
};

Model Validation

Validate request data before sending:

ts
import { setModelValidator, ProblemDetails } from "@foundatiofx/fetchclient";

setModelValidator(async (data) => {
  if (!data) return null;

  const problem = new ProblemDetails();
  const d = data as { email?: string; password?: string };

  if (!d.email) {
    problem.errors.email = ["Email is required"];
  }

  if (d.password && d.password.length < 8) {
    problem.errors.password = ["Password must be at least 8 characters"];
  }

  // Return problem if there are errors, null otherwise
  return Object.keys(problem.errors).length > 0 ? problem : null;
});

// Now validation runs before the request
const response = await client.postJSON("/api/users", {
  email: "",
  password: "123",
});

if (!response.ok) {
  console.log(response.problem.errors);
  // { email: ["Email is required"], password: ["Password must be at least 8 characters"] }
}

With Zod

ts
import { z } from "zod";
import { setModelValidator, ProblemDetails } from "@foundatiofx/fetchclient";

const UserSchema = z.object({
  email: z.string().email("Invalid email format"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

setModelValidator(async (data) => {
  if (!data) return null;

  const result = UserSchema.safeParse(data);
  if (result.success) return null;

  const problem = new ProblemDetails();
  problem.title = "Validation Error";
  problem.status = 400;

  for (const error of result.error.errors) {
    const field = error.path.join(".");
    problem.errors[field] = problem.errors[field] || [];
    problem.errors[field].push(error.message);
  }

  return problem;
});

Network Errors

Network errors (connection refused, DNS failure, etc.) throw TypeError:

ts
try {
  await client.getJSON("https://nonexistent.example.com");
} catch (error) {
  if (error instanceof TypeError) {
    console.log("Network error:", error.message);
  }
}

Timeout Errors

Timeout returns a 408 response or throws DOMException:

ts
// Using FetchClient - returns 408 response
const response = await client.getJSON("/api/slow", { timeout: 5000 });
if (response.status === 408) {
  console.log("Request timed out");
}

// Using fetch directly with AbortSignal - throws DOMException
try {
  await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
} catch (error) {
  if (error.name === "TimeoutError") {
    console.log("Request timed out");
  }
}

Circuit Breaker Errors

When circuit breaker is open:

ts
import { CircuitOpenError } from "@foundatiofx/fetchclient";

provider.useCircuitBreaker({ throwOnOpen: true });

try {
  await client.getJSON("/api/data");
} catch (error) {
  if (error instanceof CircuitOpenError) {
    console.log(`Circuit open for ${error.group}`);
    console.log(`Retry after ${error.retryAfter}ms`);
  }
}

// Or check response status (default behavior)
const response = await client.getJSON("/api/data");
if (response.status === 503) {
  console.log("Service unavailable - circuit is open");
}

Rate Limit Errors

ts
import { RateLimitError } from "@foundatiofx/fetchclient";

try {
  await client.getJSON("/api/data");
} catch (error) {
  if (error instanceof RateLimitError) {
    console.log(`Rate limited. Retry after ${error.retryAfter}ms`);
  }
}

Practical Example: Comprehensive Error Handler

ts
import {
  FetchClient,
  FetchClientProvider,
  CircuitOpenError,
  RateLimitError,
} from "@foundatiofx/fetchclient";

const provider = new FetchClientProvider();
provider.setBaseUrl("https://api.example.com");
provider.useCircuitBreaker({ failureThreshold: 5 });
provider.useRateLimit({ maxRequests: 100, windowSeconds: 60 });

const client = provider.getFetchClient();

async function apiRequest<T>(
  url: string,
  options?: RequestOptions
): Promise<{ data: T | null; error: string | null }> {
  try {
    const response = await client.getJSON<T>(url, {
      shouldThrowOnUnexpectedStatusCodes: false,
      ...options,
    });

    if (response.ok) {
      return { data: response.data, error: null };
    }

    // Handle specific status codes
    switch (response.status) {
      case 400:
        return {
          data: null,
          error: response.problem.detail || "Invalid request"
        };
      case 401:
        // Redirect to login
        window.location.href = "/login";
        return { data: null, error: "Please log in" };
      case 403:
        return { data: null, error: "You don't have permission" };
      case 404:
        return { data: null, error: "Not found" };
      case 503:
        return { data: null, error: "Service temporarily unavailable" };
      default:
        return { data: null, error: `Error: ${response.status}` };
    }
  } catch (error) {
    if (error instanceof CircuitOpenError) {
      return { data: null, error: "Service is down. Please try again later." };
    }
    if (error instanceof RateLimitError) {
      return { data: null, error: "Too many requests. Please slow down." };
    }
    if (error instanceof TypeError) {
      return { data: null, error: "Network error. Check your connection." };
    }
    return { data: null, error: "An unexpected error occurred" };
  }
}

// Usage
const { data, error } = await apiRequest<User>("/users/123");
if (error) {
  showErrorToast(error);
} else {
  displayUser(data);
}

Released under the MIT License.