> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> How to handle errors from the Volunteer API given the sparse error documentation — the gap between spec and reality, the defensive classification framework, and the patterns that produce reliable production integrations.

The Volunteer API's error documentation is the sparsest of the three Virtuous APIs. The OpenAPI spec documents error responses on only three of the twenty endpoints (`POST /groups`, `PUT /groups/{id}`, `PUT /groups/{id}/members` — all with a single `422 validation errors`). No endpoint formally documents `401`, `403`, `404`, or `500`.

This gap doesn't mean those errors don't occur — they do. It means partner integrations must build defensive error handling without spec guidance about exact response shapes or status codes. This page covers what to expect from the live API, the classification framework that handles each case appropriately, and the patterns that produce reliable production code.

**⚠️ Spec gap:** The Volunteer OpenAPI spec documents error responses on only three endpoints (all `422` validation errors). The rest of the spec is silent on error responses entirely. The patterns on this page reflect what the live API likely returns based on common REST conventions; confirm against actual responses for production-critical workflows.

## What the spec confirms

The three endpoints that do document error responses:

| **Endpoint**               | **Documented error**    |
| :------------------------- | :---------------------- |
| `POST /groups`             | `422` validation errors |
| `PUT /groups/{id}`         | `422` validation errors |
| `PUT /groups/{id}/members` | `422` validation errors |

Everything else is undocumented. No endpoint exposes the shape of an error response, the fields available on a problem object, or the headers returned with error responses.

***

## What the live API likely returns

Based on common conventions for Laravel-style APIs (which Volunteer appears to be):

| **HTTP status**             | **When likely returned**                          | **Body likely shape**                                                                    |
| :-------------------------- | :------------------------------------------------ | :--------------------------------------------------------------------------------------- |
| `401 Unauthorized`          | Missing, invalid, or expired Bearer token         | `{ "message": "Unauthenticated." }` or similar                                           |
| `403 Forbidden`             | Token valid but lacks permission for the resource | `{ "message": "This action is unauthorized." }`                                          |
| `404 Not Found`             | Resource ID doesn't exist or isn't accessible     | `{ "message": "Resource not found." }`                                                   |
| `422 Unprocessable Entity`  | Request body validation failure                   | `{ "message": "The given data was invalid.", "errors": { "field": ["error message"] } }` |
| `429 Too Many Requests`     | Rate limit exceeded (not documented but possible) | Possibly empty or `{ "message": "Too Many Requests" }`                                   |
| `500 Internal Server Error` | Server-side bug or unexpected failure             | Possibly empty or `{ "message": "Server Error" }`                                        |
| `503 Service Unavailable`   | Maintenance or overload                           | Possibly empty                                                                           |

These shapes are common in Laravel-based APIs but aren't formally documented for Volunteer. **Don't switch business logic on specific error message strings** — they're not a contract.

***

## The defensive classification framework

The classifier turns any error response into a category that determines how to handle it:

```javascript theme={null}
function classifyError(status, body) {
  // Network or TLS errors (no status)
  if (!status) return 'transient';

  // Success
  if (status >= 200 && status < 300) return 'success';

  // 3xx — typically redirects; treat as transient
  if (status >= 300 && status < 400) return 'transient';

  // Specific 4xx codes
  if (status === 401) return 'auth_failed';
  if (status === 403) return 'forbidden';
  if (status === 404) return 'not_found';
  if (status === 422) return 'validation_failed';
  if (status === 429) return 'rate_limited';

  // Other 4xx — generally not retryable
  if (status >= 400 && status < 500) return 'permanent_client';

  // 5xx — transient server issues
  if (status >= 500) return 'transient';

  return 'unknown';
}
```

Each classification determines the right response:

| **Classification**  | **Retry?** | **Action**                                                         |
| :------------------ | :--------- | :----------------------------------------------------------------- |
| `success`           | N/A        | Use the response.                                                  |
| `auth_failed`       | **No**     | Token is bad — pause integration for this customer; alert ops.     |
| `forbidden`         | **No**     | Permission issue — alert ops; don't keep trying.                   |
| `not_found`         | **No**     | Resource doesn't exist — return `null` to caller; let them decide. |
| `validation_failed` | **No**     | Request body has issues — surface field-level errors to user.      |
| `rate_limited`      | **Yes**    | Honor `Retry-After` if present; otherwise back off.                |
| `transient`         | **Yes**    | Exponential backoff with jitter; bounded attempts.                 |
| `permanent_client`  | **No**     | Unknown 4xx — log and surface; don't retry.                        |
| `unknown`           | **No**     | Log loudly; surface for manual review.                             |

***

## Per-status handling in depth

### `401 Unauthorized`**: don't retry**

A `401` means the token isn't being accepted. Common causes:

| **Cause**                                                               | **Resolution**                                            |
| :---------------------------------------------------------------------- | :-------------------------------------------------------- |
| Token expired (if Volunteer enforces expiration)                        | Customer's administrator generates a new token            |
| Token was revoked in the admin portal                                   | Same                                                      |
| Token is malformed in the request (whitespace, missing `Bearer `prefix) | Inspect the actual request — typically a partner-side bug |
| API access was disabled for the customer's VOMO account                 | Customer contacts their VOMO concierge                    |

The response:

```javascript theme={null}
if (response.status === 401) {
  await pauseIntegrationForCustomer(customerId, 'auth_failed');
  await alertOps({
    severity: 'high',
    customerId,
    message: 'VOMO token rejected — credential needs refresh',
  });
  throw new AuthError('VOMO token is invalid');
}
```

**Critical:** retries against an invalid token just produce more `401`s and noise. Pause the customer's work; alert ops. See [<u>Authentication: Handling auth failures</u>](https://markdowntorichtext.com/volunteer/authentication#handling-auth-failures).

### `403 Forbidden`: also don't retry

A `403` means the token is valid but doesn't have permission for the specific endpoint. Causes:

| **Cause**                                                              | **Resolution**                                                                    |
| :--------------------------------------------------------------------- | :-------------------------------------------------------------------------------- |
| The token's organization doesn't have access to the requested resource | The resource belongs to another organization — partner has the wrong token loaded |
| The customer's plan doesn't include the feature                        | Coordinate with VOMO concierge                                                    |
| The endpoint requires a higher permission tier                         | Same                                                                              |

```javascript theme={null}
if (response.status === 403) {
  await alertOps({
    severity: 'medium',
    customerId,
    endpoint: url,
    message: 'VOMO token lacks permission for this endpoint',
  });
  throw new ForbiddenError(`No permission for ${url}`);
}
```

Like `401`, **don't retry** — the permission isn't going to change in seconds.

### `404 Not Found`: handle gracefully

A `404` from a single-resource endpoint (e.g., `GET /users/12345`) means the user with that ID doesn't exist in the customer's organization. Causes:

| **Cause**                                                                       | **Resolution**                         |
| :------------------------------------------------------------------------------ | :------------------------------------- |
| The ID was wrong                                                                | Caller bug                             |
| The user was deleted between the integration learning about them and the lookup | Race condition — handle as "not found" |
| The user exists in a different organization                                     | Partner is using the wrong token       |
| The endpoint doesn't exist (rare — partner constructed a wrong URL)             | Partner-side URL bug                   |

```javascript theme={null}
async function getUserById(userId) {
  const response = await fetch(`https://api.vomo.org/v1/users/${userId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (response.status === 404) {
    return null; // Caller decides what to do
  }

  if (!response.ok) {
    throw await buildError(response);
  }

  return parseUser(await response.json());
}
```

**Don't retry** `404` — a non-existent resource will continue to not exist on retry. Return `null` and let the caller handle the absence.

### `422 Validation Error`: the documented case

The three endpoints that document `422` (`POST /groups`, `PUT /groups/{id}`, `PUT /groups/{id}/members`) return validation errors when the request body doesn't pass validation. Other endpoints that accept request bodies (`POST /projects`, `POST /users`, `PUT /projects/{id}`) almost certainly return `422` similarly even though it's undocumented.

The body shape (assuming Laravel conventions):

```json theme={null}
{
  "message": "The given data was invalid.",
  "errors": {
    "name": ["The name field is required."],
    "email": ["The email must be a valid email address."]
  }
}
```

Handling:

```javascript theme={null}
async function createGroup(groupData) {
  const response = await fetch('https://api.vomo.org/v1/groups', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(groupData),
  });

  if (response.status === 422) {
    const problem = await response.json();
    throw new ValidationError(
      problem.message ?? 'Validation failed',
      problem.errors ?? {}
    );
  }

  if (!response.ok) {
    throw await buildError(response);
  }

  return await response.json();
}

class ValidationError extends Error {
  constructor(message, fieldErrors) {
    super(message);
    this.fieldErrors = fieldErrors; // { fieldName: ["error message", ...] }
  }
}
```

**Don't retry** `422` — the same payload will fail validation again. Surface the field-level errors to the user (or your integration's logs) so the bad data can be corrected.

### `429 Too Many Requests`: rate limited

`429` isn't documented in the spec but rate limits likely exist at the platform level. When it happens:

```javascript theme={null}
if (response.status === 429) {
  const retryAfter = response.headers.get('Retry-After');
  const delayMs = retryAfter
    ? parseInt(retryAfter, 10) * 1000
    : 30 * 1000; // Default: 30 seconds

  await sleep(delayMs);
  // Retry the request after the delay
}
```

See [<u>Rate Limits</u>](https://markdowntorichtext.com/volunteer/rate-limits) for the broader rate-limit pattern.

### `5xx` Server errors: retry with backoff

Server errors (`500`, `502`, `503`, `504`) are typically transient. Retry with exponential backoff:

```javascript theme={null}
async function callWithRetry(fn, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const classification = classifyError(err.status, err.body);

      if (classification !== 'transient' && classification !== 'rate_limited') {
        throw err; // Non-retryable
      }

      if (attempt === maxAttempts) {
        throw err; // Exhausted retries
      }

      const baseDelay = Math.pow(2, attempt - 1) * 1000;
      const jitter = Math.random() * 1000;
      await sleep(baseDelay + jitter);
    }
  }
}
```

The exponential backoff (1s, 2s, 4s, 8s, 16s with jitter) is the standard pattern. Bounded retries prevent infinite loops; jitter prevents thundering-herd retries from multiple integrations simultaneously.

***

## A complete error-handling pattern

Putting it together as a reusable client:

```javascript theme={null}
class VomoApiError extends Error {
  constructor(message, status, body, classification) {
    super(message);
    this.status = status;
    this.body = body;
    this.classification = classification;
  }
}

class VomoClient {
  constructor({ token, customerId, alerter, credentialPause }) {
    this.token = token;
    this.customerId = customerId;
    this.alerter = alerter;
    this.credentialPause = credentialPause;
  }

  async request(path, options = {}) {
    const url = path.startsWith('http') ? path : `https://api.vomo.org/v1${path}`;

    return this._withRetry(async () => {
      const response = await fetch(url, {
        ...options,
        headers: {
          Authorization: `Bearer ${this.token}`,
          Accept: 'application/json',
          ...(options.body ? { 'Content-Type': 'application/json' } : {}),
          ...options.headers,
        },
      });

      if (response.ok) {
        return response.json();
      }

      const body = await this._parseBody(response);
      const classification = classifyError(response.status, body);
      const message = body?.message ?? `Request failed: ${response.status}`;
      const error = new VomoApiError(message, response.status, body, classification);

      // Side effects for specific classifications
      switch (classification) {
        case 'auth_failed':
          await this.credentialPause.pause(this.customerId, 'vomo_auth_failed');
          await this.alerter.send({
            severity: 'high',
            customerId: this.customerId,
            title: 'VOMO token rejected',
            url,
          });
          break;

        case 'forbidden':
          await this.alerter.send({
            severity: 'medium',
            customerId: this.customerId,
            title: 'VOMO permission denied',
            url,
          });
          break;

        case 'rate_limited':
          const retryAfter = response.headers.get('Retry-After');
          error.retryAfter = retryAfter ? parseInt(retryAfter, 10) : null;
          break;
      }

      throw error;
    });
  }

  async _withRetry(fn, maxAttempts = 5) {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        return await fn();
      } catch (err) {
        if (err instanceof VomoApiError) {
          if (err.classification !== 'transient' && err.classification !== 'rate_limited') {
            throw err;
          }
        } else if (!isNetworkError(err)) {
          throw err;
        }

        if (attempt === maxAttempts) throw err;

        const delayMs = err.retryAfter
          ? err.retryAfter * 1000
          : Math.pow(2, attempt - 1) * 1000 + Math.random() * 1000;

        await sleep(delayMs);
      }
    }
  }

  async _parseBody(response) {
    try {
      return await response.json();
    } catch {
      return null;
    }
  }
}

function isNetworkError(err) {
  return ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED'].includes(err.code);
}
```

Usage:

```javascript theme={null}
const vomo = new VomoClient({ token, customerId, alerter, credentialPause });

try {
  const user = await vomo.request('/users/12345');
  console.log(user.data);
} catch (err) {
  if (err instanceof VomoApiError) {
    switch (err.classification) {
      case 'not_found':
        return null;
      case 'validation_failed':
        return { errors: err.body.errors };
      default:
        throw err; // Already handled side effects; let it propagate
    }
  }
  throw err;
}
```

The client centralizes retry, classification, and alerting — caller code only handles the cases it cares about (`not_found`, `validation_failed`, etc.).

***

## Surfacing errors meaningfully

The error pattern produces good developer experience when integrated properly:

### Don't swallow errors silently

```javascript theme={null}
// ❌ Anti-pattern — caller has no idea what happened
try {
  await fetchUsers();
} catch {
  return [];
}

// ✅ Let errors propagate with context
try {
  return await fetchUsers();
} catch (err) {
  logger.error('User fetch failed', { customerId, error: err.message });
  throw err;
}
```

Silently returning empty arrays on errors makes problems invisible until they're catastrophic. Let errors surface so they can be investigated.

### Surface field-level validation errors

```javascript theme={null}
try {
  await vomo.request('/groups', {
    method: 'POST',
    body: JSON.stringify({ name: 'New Group' }),
  });
} catch (err) {
  if (err.classification === 'validation_failed') {
    // err.body.errors might look like:
    // { name: ["The name has already been taken."] }
    Object.entries(err.body.errors ?? {}).forEach(([field, messages]) => {
      messages.forEach((msg) => {
        ui.showFieldError(field, msg);
      });
    });
    return;
  }
  throw err;
}
```

Field-level errors are the most useful kind — they tell the user (or the calling code) exactly what's wrong, not just "something failed."

### Distinguish user-facing from internal errors

```javascript theme={null}
function userFacingMessage(err) {
  if (!(err instanceof VomoApiError)) {
    return 'Something went wrong. Please try again.';
  }

  switch (err.classification) {
    case 'auth_failed':
      return 'Our connection to VOMO is having an issue. Our team has been notified.';
    case 'forbidden':
      return 'This action requires additional permission. Contact your administrator.';
    case 'not_found':
      return 'This record could not be found.';
    case 'validation_failed':
      return null; // Field-level errors shown elsewhere
    case 'rate_limited':
      return 'Please wait a moment and try again.';
    case 'transient':
      return 'A temporary issue occurred. Please try again.';
    default:
      return 'Something went wrong. Please try again.';
  }
}
```

User-facing messages shouldn't expose technical details (status codes, stack traces, exact error texts from the API). The mapping function above produces friendly messages while preserving the technical details for logs.

***

## Monitoring error rates

Track these metrics for any production Volunteer integration:

| **Metric**              | **Healthy baseline** | **Alert threshold**                                |
| :---------------------- | :------------------- | :------------------------------------------------- |
| `4xx` rate per customer | \<0.5%               | >2% sustained                                      |
| `5xx` rate per customer | \<0.1%               | >0.5% sustained                                    |
| `401` rate (any)        | 0                    | Any non-zero — credential issue                    |
| `429` rate              | 0 to occasional      | Sustained spike — rate-limit issue                 |
| `503` rate              | 0 to occasional      | Sustained — VOMO platform issue                    |
| Retry success rate      | >95%                 | \<80% — transient errors aren't actually transient |

Per-customer breakdowns matter. A customer-specific spike in `401`s indicates that customer's token is bad; a platform-wide spike in `503`s indicates a VOMO issue not specific to any one customer.

***

## A common-error-cases checklist

For a new Volunteer integration, walk through these cases explicitly:

* Token is invalid (`401`) → pause customer; alert
* Token lacks permission (`403`) → alert; don't retry
* Resource doesn't exist (`404`) → return null; caller decides
* Request body invalid (`422`) → surface field-level errors
* Rate limited (`429`) → honor `Retry-After`; back off
* Server error (`5xx`) → retry with exponential backoff
* Network error → retry as transient
* Unexpected `4xx` → log and surface; don't retry
* All errors logged with sufficient context (customer ID, endpoint, status, body)
* Metrics emitted for each error class
* Field-level validation errors surfaced to UI / caller
* User-facing messages distinct from internal logs

***

## Where to go next

The retry pattern for `429` responses in detail.The broader recovery patterns — circuit breakers, dead-letter queues, classification.The auth-failure handling section in depth.Error patterns specific to multi-page reads.
