# @pawells/http-common

[![GitHub Release](https://img.shields.io/github/v/release/PhillipAWells/workspace)](https://github.com/PhillipAWells/workspace/releases)
[![CI](https://github.com/PhillipAWells/workspace/actions/workflows/ci.yml/badge.svg)](https://github.com/PhillipAWells/workspace/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@pawells/http-common.svg)](https://www.npmjs.com/package/@pawells/http-common)
[![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/PhillipAWells)](https://github.com/sponsors/PhillipAWells)

## Description

Typed HTTP error classes, status code constants, and RFC 7807 Problem Details serialization for Node.js — structured error handling with metadata validation and cause-chain support.

## Requirements

| Requirement | Version |
|---|---|
| Node.js | `>=22.0.0` |
| `@pawells/typescript-common` | `^3.1.0` (bundled — installs automatically) |
| `zod` | `^4.4.3` (bundled — installs automatically) |
| `tslib` | `^2.3.0` (bundled — installs automatically) |

## Installation

```sh
yarn add @pawells/http-common
```

`@pawells/typescript-common` and `zod` are listed as direct dependencies and install automatically. No additional peer dependency installation is required.

## Quick Start

### Throw and catch a typed HTTP error

```ts
import { HTTPNotFoundError } from '@pawells/http-common';

function GetUser(id: string): User {
	const user = db.find(id);
	if (!user) {
		throw new HTTPNotFoundError(`User ${id} not found`);
	}
	return user;
}

try {
	GetUser('abc');
} catch (error) {
	if (error instanceof HTTPNotFoundError) {
		console.log(error.HTTPStatusCode); // 404
		console.log(error.Code);           // 'HTTP_NOT_FOUND'
		console.log(error.message);        // 'User abc not found'
	}
}
```

### Chain errors for structured diagnostics

```ts
import { HTTPBadRequestError } from '@pawells/http-common';

try {
	const data = parsePayload(raw);
} catch (cause) {
	throw new HTTPBadRequestError('Invalid request payload', { cause });
	// error.Cause === cause (the original parse error)
}
```

### Use a status code constant

```ts
import { HTTP_STATUS_OK, HTTP_STATUS_CREATED } from '@pawells/http-common';

res.status(HTTP_STATUS_CREATED).json({ id: newRecord.id });
```

### Look up an error class dynamically

```ts
import { GetHTTPErrorClass } from '@pawells/http-common';

function ThrowForStatus(statusCode: number, message: string): never {
	const ErrorClass = GetHTTPErrorClass(statusCode);
	if (ErrorClass) {
		throw new ErrorClass(message);
	}
	throw new Error(`Unmapped status code: ${statusCode}`);
}

ThrowForStatus(422, 'Validation failed'); // throws HTTPUnprocessableEntityError
```

## API Reference

### `HTTPError`

Base class for all HTTP errors. Extends `BaseError<THTTPErrorMetadata>` from `@pawells/typescript-common`.

**Constructor**

```ts
constructor(message: string, metadata: Partial<THTTPErrorMetadata>)
```

The `metadata` parameter is required (though its properties are each optional) and is validated via `BuildMetadata` before use — the same validation path every subclass uses. Subclasses provide optional metadata parameters and handle validation internally.

**Getters**

| Getter | Type | Description |
|---|---|---|
| `HTTPStatusCode` | `number \| undefined` | The HTTP status code carried in `Metadata.HTTPStatusCode`. `undefined` on the base class unless set explicitly. |
| `Code` | `string \| undefined` | Machine-readable error code from `Metadata.code` (inherited from `BaseError`). |
| `Cause` | `Error \| undefined` | Chained root-cause error from `Metadata.cause` (inherited from `BaseError`). |
| `Metadata` | `THTTPErrorMetadata \| undefined` | Full metadata object (inherited from `BaseError`). |

---

### `HTTPMetadataValidationError`

Error class thrown when metadata validation fails during HTTP error construction.

```ts
class HTTPMetadataValidationError extends BaseError<{ code: string; cause?: Error }>
```

**Properties**

| Property | Type | Description |
|---|---|---|
| `code` | `'HTTP_METADATA_VALIDATION_ERROR'` | Constant error code identifier |
| `cause` | `Error \| undefined` | The underlying error passed via `options.cause` (in practice always the `z.ZodError` thrown by schema validation) |

---

### Concrete error subclasses

Each subclass fixes `HTTPStatusCode` to its status code at construction time. The default `code` string is listed in the **Default code** column.

Constructor signature for all subclasses:

```ts
constructor(message: string, metadata?: Partial<THTTPErrorMetadata>)
```

| Class | HTTP status | Default code |
|---|---|---|
| `HTTPBadRequestError` | 400 | `'HTTP_BAD_REQUEST'` |
| `HTTPUnauthorizedError` | 401 | `'HTTP_UNAUTHORIZED'` |
| `HTTPForbiddenError` | 403 | `'HTTP_FORBIDDEN'` |
| `HTTPNotFoundError` | 404 | `'HTTP_NOT_FOUND'` |
| `HTTPMethodNotAllowedError` | 405 | `'HTTP_METHOD_NOT_ALLOWED'` |
| `HTTPNotAcceptableError` | 406 | `'HTTP_NOT_ACCEPTABLE'` |
| `HTTPRequestTimeoutError` | 408 | `'HTTP_REQUEST_TIMEOUT'` |
| `HTTPConflictError` | 409 | `'HTTP_CONFLICT'` |
| `HTTPGoneError` | 410 | `'HTTP_GONE'` |
| `HTTPPayloadTooLargeError` | 413 | `'HTTP_PAYLOAD_TOO_LARGE'` |
| `HTTPUnsupportedMediaTypeError` | 415 | `'HTTP_UNSUPPORTED_MEDIA_TYPE'` |
| `HTTPUnprocessableEntityError` | 422 | `'HTTP_UNPROCESSABLE_ENTITY'` |
| `HTTPTooManyRequestsError` | 429 | `'HTTP_TOO_MANY_REQUESTS'` |
| `HTTPUnavailableForLegalReasonsError` | 451 | `'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS'` |
| `HTTPInternalServerError` | 500 | `'HTTP_INTERNAL_SERVER_ERROR'` |
| `HTTPNotImplementedError` | 501 | `'HTTP_NOT_IMPLEMENTED'` |
| `HTTPBadGatewayError` | 502 | `'HTTP_BAD_GATEWAY'` |
| `HTTPServiceUnavailableError` | 503 | `'HTTP_SERVICE_UNAVAILABLE'` |
| `HTTPGatewayTimeoutError` | 504 | `'HTTP_GATEWAY_TIMEOUT'` |

#### `HTTPTooManyRequestsError` (429) and `HTTPServiceUnavailableError` (503) — Retry-After support

Both the 429 (Too Many Requests) and 503 (Service Unavailable) error classes accept an optional `retryAfter` parameter per RFC 7231 section 7.1.3. This allows servers to communicate when clients should attempt to retry.

**Constructor signature**

```ts
constructor(message: string, metadata?: Partial<THTTPErrorMetadata> & { retryAfter?: number | Date })
```

**Getter**

| Getter | Type | Description |
|---|---|---|
| `RetryAfter` | `number \| Date \| undefined` | The Retry-After value: either a number of seconds (delay-seconds form) or a Date (HTTP-date form). Returns `undefined` if not set. |

**Retry-After forms (per RFC 7231)**

- **Delay-seconds** — a non-negative integer representing seconds to wait before retrying:
  ```ts
  throw new HTTPTooManyRequestsError('Rate limited', { retryAfter: 60 });
  // Client should retry after 60 seconds
  ```

- **HTTP-date** — a Date object representing when retry should be attempted:
  ```ts
  const retryDate = new Date(Date.now() + 1800000); // 30 minutes from now
  throw new HTTPServiceUnavailableError('Maintenance', { retryAfter: retryDate });
  // Client should retry after the specified date
  ```

**Usage example**

```ts
import { HTTPTooManyRequestsError } from '@pawells/http-common';

function HandleRateLimit(retryAfterSeconds: number): never {
	throw new HTTPTooManyRequestsError('Rate limit exceeded', {
		retryAfter: retryAfterSeconds
	});
}

try {
	HandleRateLimit(60);
} catch (error) {
	if (error instanceof HTTPTooManyRequestsError) {
		const retry = error.RetryAfter;
		if (typeof retry === 'number') {
			console.log(`Retry after ${retry} seconds`);
		} else if (retry instanceof Date) {
			console.log(`Retry after ${retry.toISOString()}`);
		}
	}
}
```

---

### `GetHTTPErrorClass`

```ts
function GetHTTPErrorClass(statusCode: number): THTTPErrorClasses | undefined
```

Returns the error class constructor mapped to the given HTTP status code, or `undefined` if the status code is not in `HTTP_ERROR_CLASS_MAP` (e.g. 1xx, 2xx, 3xx codes, or unmapped 4xx/5xx codes).

---

### `ThrowHTTPError`

```ts
function ThrowHTTPError(
  statusCode: number,
  message: string,
  metadata?: Partial<THTTPErrorMetadata>
): never
```

Convenience wrapper that looks up the appropriate `HTTPError` subclass using `GetHTTPErrorClass`, instantiates it with the provided message and metadata, and throws it. For unmapped status codes, throws a generic `HTTPError` with the status code in metadata. Always throws — the return type is `never`.

This function eliminates the boilerplate of manually calling `GetHTTPErrorClass()` and instantiating the error:

```ts
import { ThrowHTTPError } from '@pawells/http-common';

// Before (manual approach)
const ErrorClass = GetHTTPErrorClass(422);
if (ErrorClass) {
  throw new ErrorClass('Validation failed');
}

// After (convenience wrapper)
ThrowHTTPError(422, 'Validation failed');
```

Supports metadata passthrough for cause chaining and custom error codes:

```ts
// With cause chain
try {
  const data = parseJSON(raw);
} catch (cause) {
  ThrowHTTPError(400, 'Invalid payload', { cause });
}

// With custom error code
ThrowHTTPError(404, 'User not found', { code: 'USER_NOT_FOUND' });

// Unmapped status code (418 — "I'm a teapot")
ThrowHTTPError(418, 'I am a teapot'); // Throws generic HTTPError
```

---

### `IsHTTPError`

```ts
function IsHTTPError(value: unknown): value is HTTPError
```

Type guard that checks if a value is an `HTTPError` instance via `instanceof`. Enables safe type narrowing for HTTP error handling without duck-typing.

```ts
import { IsHTTPError } from '@pawells/http-common';

try {
	throw new HTTPNotFoundError('User not found');
} catch (error) {
	if (IsHTTPError(error)) {
		console.log(`HTTP ${error.HTTPStatusCode}: ${error.message}`);
	}
}
```

---

### `IsClientError`

```ts
function IsClientError(value: unknown): value is HTTPError
```

Type guard that checks if a value is an `HTTPError` with a 4xx (400–499) status code. Enables ergonomic branching on client-error categories.

```ts
import { IsClientError } from '@pawells/http-common';

try {
	throwSomeError();
} catch (error) {
	if (IsClientError(error)) {
		// Handle 400–499 client errors
		console.log('Client error:', error.message);
	}
}
```

---

### `IsServerError`

```ts
function IsServerError(value: unknown): value is HTTPError
```

Type guard that checks if a value is an `HTTPError` with a 5xx (500–599) status code. Enables ergonomic branching on server-error categories.

```ts
import { IsServerError } from '@pawells/http-common';

try {
	throwSomeError();
} catch (error) {
	if (IsServerError(error)) {
		// Handle 500–599 server errors
		console.log('Server error:', error.message);
	}
}
```

---

### `ToProblemDetails`

```ts
function ToProblemDetails(error: HTTPError): TProblemDetails
```

Converts an `HTTPError` to an RFC 7807 Problem Details object. Maps HTTPError properties to standard RFC 7807 fields and includes org-standard extensions (`code`, `cause`).

**RFC 7807 standard fields:**
- `status` — HTTP status code from `HTTPError.HTTPStatusCode` (defaults to 500 if missing)
- `title` — Human-readable title derived from `HTTPError.Code` (e.g., "HTTP Not Found")
- `detail` — Error message from `HTTPError.message`
- `type` — Optional; reserved for future use (not populated by this function)
- `instance` — Optional; reserved for future use (not populated by this function)

**Org-standard extensions:**
- `code` — Machine-readable error identifier from `HTTPError.Code`
- `cause` — Safe representation of `HTTPError.Cause` message (stack traces never exposed)

**Security:** Only the cause error message is included in the output; stack traces and internal details are never exposed.

```ts
import { HTTPNotFoundError, ToProblemDetails } from '@pawells/http-common';

const error = new HTTPNotFoundError('User #42 not found');
const problem = ToProblemDetails(error);

// {
//   title: 'HTTP Not Found',
//   status: 404,
//   detail: 'User #42 not found',
//   code: 'HTTP_NOT_FOUND'
// }
```

---

### `HTTP_ERROR_CLASS_MAP`

```ts
const HTTP_ERROR_CLASS_MAP: Readonly<Partial<Record<number, THTTPErrorClasses>>>
```

A frozen object keyed by the 19 mapped error status codes (400, 401, 403, 404, 405, 406, 408, 409, 410, 413, 415, 422, 429, 451, 500, 501, 502, 503, 504) pointing to their corresponding error class constructor. Frozen at module load time via `Object.freeze()` to prevent runtime mutations. `GetHTTPErrorClass` is a thin wrapper around this object.

---

### Status code constants

Named `number` constants for 42 HTTP status codes across all ranges (100–511).

| Constant | Value |
|---|---|
| `HTTP_STATUS_CONTINUE` | 100 |
| `HTTP_STATUS_SWITCHING_PROTOCOLS` | 101 |
| `HTTP_STATUS_PROCESSING` | 102 |
| `HTTP_STATUS_EARLY_HINTS` | 103 |
| `HTTP_STATUS_OK` | 200 |
| `HTTP_STATUS_CREATED` | 201 |
| `HTTP_STATUS_ACCEPTED` | 202 |
| `HTTP_STATUS_NO_CONTENT` | 204 |
| `HTTP_STATUS_PARTIAL_CONTENT` | 206 |
| `HTTP_STATUS_MULTIPLE_CHOICES` | 300 |
| `HTTP_STATUS_MOVED_PERMANENTLY` | 301 |
| `HTTP_STATUS_FOUND` | 302 |
| `HTTP_STATUS_NOT_MODIFIED` | 304 |
| `HTTP_STATUS_TEMPORARY_REDIRECT` | 307 |
| `HTTP_STATUS_PERMANENT_REDIRECT` | 308 |
| `HTTP_STATUS_BAD_REQUEST` | 400 |
| `HTTP_STATUS_UNAUTHORIZED` | 401 |
| `HTTP_STATUS_FORBIDDEN` | 403 |
| `HTTP_STATUS_NOT_FOUND` | 404 |
| `HTTP_STATUS_METHOD_NOT_ALLOWED` | 405 |
| `HTTP_STATUS_NOT_ACCEPTABLE` | 406 |
| `HTTP_STATUS_REQUEST_TIMEOUT` | 408 |
| `HTTP_STATUS_LENGTH_REQUIRED` | 411 |
| `HTTP_STATUS_PRECONDITION_FAILED` | 412 |
| `HTTP_STATUS_PAYLOAD_TOO_LARGE` | 413 |
| `HTTP_STATUS_URI_TOO_LONG` | 414 |
| `HTTP_STATUS_CONFLICT` | 409 |
| `HTTP_STATUS_GONE` | 410 |
| `HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE` | 415 |
| `HTTP_STATUS_UNPROCESSABLE_ENTITY` | 422 |
| `HTTP_STATUS_UNAVAILABLE_FOR_LEGAL_REASONS` | 451 |
| `HTTP_STATUS_TOO_MANY_REQUESTS` | 429 |
| `HTTP_STATUS_INTERNAL_SERVER_ERROR` | 500 |
| `HTTP_STATUS_NOT_IMPLEMENTED` | 501 |
| `HTTP_STATUS_BAD_GATEWAY` | 502 |
| `HTTP_STATUS_SERVICE_UNAVAILABLE` | 503 |
| `HTTP_STATUS_GATEWAY_TIMEOUT` | 504 |
| `HTTP_STATUS_VARIANT_ALSO_NEGOTIATES` | 506 |
| `HTTP_STATUS_INSUFFICIENT_STORAGE` | 507 |
| `HTTP_STATUS_LOOP_DETECTED` | 508 |
| `HTTP_STATUS_NOT_EXTENDED` | 510 |
| `HTTP_STATUS_NETWORK_AUTHENTICATION_REQUIRED` | 511 |

---

### Types, schemas, and type guards

| Symbol | Kind | Description |
|---|---|---|
| `THTTPErrorMetadata` | `type` | Zod-inferred type extending `TErrorMetadata` with an optional `HTTPStatusCode: number` field. |
| `THTTPErrorClasses` | `type` | Union of all 19 concrete error class constructors. |
| `TProblemDetails` | `interface` | RFC 7807 Problem Details object shape with required fields `title` and `status`, optional fields `type`, `detail`, `instance`, and org extensions `code` and `cause`. |
| `HTTP_ERROR_METADATA_SCHEMA` | `const` | Zod schema used to validate metadata passed to `HTTPError` and its subclasses. |
| `AssertHTTPErrorMetadata` | `function` | Asserts that an unknown value satisfies `THTTPErrorMetadata`. Throws `HTTPMetadataValidationError` on failure. |
| `ValidateHTTPErrorMetadata` | `function` | Returns `true` if the value satisfies `THTTPErrorMetadata`, `false` otherwise. |
| `IsHTTPError` | `function` | Type guard that returns `true` if the value is an `HTTPError` instance. |
| `IsClientError` | `function` | Type guard that returns `true` if the value is an `HTTPError` with a 4xx (400–499) status code. |
| `IsServerError` | `function` | Type guard that returns `true` if the value is an `HTTPError` with a 5xx (500–599) status code. |

## License

MIT — Phillip Aaron Wells. See [LICENSE](./LICENSE) for details.
