first commit
This commit is contained in:
100
docs/communication/messaging/discord-client/README.md
Normal file
100
docs/communication/messaging/discord-client/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# @suaveplan/discord-client - Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes
|
||||
|
||||
Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure mode to a stable `DISCORD.*` error code and emits OpenTelemetry counters and histograms for every request.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
|
||||
- [discord-bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
|
||||
- [webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
|
||||
- [discord-webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
|
||||
- [errors](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
|
||||
- [discord-client-error](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
|
||||
- [http](./src/utils/http.md) — Single funnel through which every Discord API and webhook request flows. It maps Discord response codes onto the `Disco…
|
||||
- [rate-limit](./src/utils/rate-limit.md) — Parses Discord's three rate-limit response headers into a single millisecond back-off duration that callers can use to…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/discord-client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient, DiscordWebhook } from "@suaveplan/discord-client";
|
||||
|
||||
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
|
||||
await bot.sendMessage("123", { content: "hello from the bot" });
|
||||
|
||||
const hook = new DiscordWebhook({
|
||||
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
});
|
||||
await hook.sendText("hello from a webhook");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| Bot Client | [src/core/bot-client/discord-bot-client.md](src/core/bot-client/discord-bot-client.md) | `DiscordBotClient` — send/edit/delete messages, reactions, channels, guilds |
|
||||
| Webhook | [src/core/webhook/discord-webhook.md](src/core/webhook/discord-webhook.md) | `DiscordWebhook` — webhook-URL delivery with structured reports |
|
||||
| HTTP Helper | [src/utils/http.md](src/utils/http.md) | Shared request funnel: error mapping, timeout, OTel metrics |
|
||||
| Rate-Limit | [src/utils/rate-limit.md](src/utils/rate-limit.md) | `parseRetryAfterMs` parser for Discord's three rate-limit headers |
|
||||
| Errors | [src/errors/discord-client-error.md](src/errors/discord-client-error.md) | `DISCORD_CODES` registry and `DiscordClientError` |
|
||||
| Types | [src/types/discord-client.types.md](src/types/discord-client.types.md) | Public type surface + Zod boundary schemas |
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient } from "@suaveplan/discord-client/bot";
|
||||
import { DiscordWebhook } from "@suaveplan/discord-client/webhook";
|
||||
import { DISCORD_CODES } from "@suaveplan/discord-client/errors";
|
||||
import { DiscordMessageSchema } from "@suaveplan/discord-client/types";
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | HTTP | Meaning |
|
||||
|------|------|---------|
|
||||
| `DISCORD.API_ERROR` | 502 | Generic non-2xx bot response |
|
||||
| `DISCORD.AUTH_ERROR` | 401 | Token rejected |
|
||||
| `DISCORD.RATE_LIMITED` | 429 | Honour `error.context.retryAfterMs` |
|
||||
| `DISCORD.CHANNEL_NOT_FOUND` | 404 | Channel missing or bot lacks access |
|
||||
| `DISCORD.GUILD_NOT_FOUND` | 404 | Guild missing or bot is not a member |
|
||||
| `DISCORD.CONFIG_MISSING` | 500 | Required config value missing |
|
||||
| `DISCORD.INVALID_CONFIG` | 500 | Config failed Zod validation |
|
||||
| `DISCORD.INVALID_MESSAGE` | 400 | Message payload failed Zod validation |
|
||||
| `DISCORD.WEBHOOK_FAILED` | 502 | Webhook delivery rejected |
|
||||
| `DISCORD.TIMEOUT` | 504 | Request aborted by timeout |
|
||||
| `DISCORD.FORBIDDEN` | 403 | Caller lacks permissions |
|
||||
|
||||
## Injectable Fetch & Clock
|
||||
|
||||
Both `DiscordBotClient` and `DiscordWebhook` accept `fetch` and `clock` injectables — pass deterministic stubs in tests, omit them in production to use `globalThis.fetch` and `Date.now`.
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
|
||||
|
||||
const stubFetch: FetchLike = async () => ({
|
||||
ok: true, status: 200, statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "", json: async () => ({ id: "1", channelId: "1", content: "x", timestamp: "" }),
|
||||
});
|
||||
|
||||
new DiscordBotClient({
|
||||
token: "abc",
|
||||
fetch: stubFetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
```
|
||||
|
||||
## OTel Metrics
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|--------|------|--------|
|
||||
| `discord_client.request.total` | Counter | `kind`, `method`, `outcome`, `status` |
|
||||
| `discord_client.request.duration_ms` | Histogram | `kind`, `method`, `outcome` |
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Discord Bot Client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the bot-token half of `@suaveplan/discord-client`. Wraps the
|
||||
Discord Bot REST API (`/channels/:id/messages`, `/guilds/:id`, reactions) with
|
||||
a runtime-agnostic surface: every constructor option is Zod-validated, every
|
||||
network call routes through the shared `executeDiscordRequest` funnel (which
|
||||
emits OTel metrics and maps responses to `DiscordClientError`), and both the
|
||||
HTTP client and the wall clock are injectable so tests stay deterministic
|
||||
without ever touching the real Discord API.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`,
|
||||
`createReaction`, `getStoredMessage`.
|
||||
- In-memory message cache keyed by Discord message ID. `deleteMessage` evicts.
|
||||
- Boundary validation of `DiscordMessage` via `DiscordMessageSchema`.
|
||||
- Boundary validation of `DiscordBotConfig` via `DiscordBotConfigSchema`.
|
||||
- Per-request `AbortController` timeout (default 10s) enforced through
|
||||
`executeDiscordRequest`.
|
||||
- `Bot <token>` authorization header set on every request.
|
||||
- `/guilds/:id` requests automatically tag `isGuildRequest: true` so 404s
|
||||
raise `DISCORD.GUILD_NOT_FOUND` instead of `DISCORD.CHANNEL_NOT_FOUND`.
|
||||
- camelCase → snake_case mapping (`avatarUrl` → `avatar_url`) for Discord
|
||||
payload compatibility.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient } from "@suaveplan/discord-client";
|
||||
|
||||
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
|
||||
await bot.sendMessage("123", { content: "hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
|
||||
|
||||
const fakeFetch: FetchLike = async (_url, _init) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({
|
||||
id: "42",
|
||||
channelId: "1",
|
||||
content: "hi",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
|
||||
const bot = new DiscordBotClient({
|
||||
token: "abc",
|
||||
fetch: fakeFetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const sent = await bot.sendMessage("1", { content: "hi" });
|
||||
expect(bot.getStoredMessage(sent.id)).toEqual(sent);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new DiscordBotClient(config: DiscordBotConfig)` — throws
|
||||
`DiscordClientError(DISCORD.CONFIG_MISSING)` if `token` is missing,
|
||||
`DiscordClientError(DISCORD.INVALID_CONFIG)` for other Zod failures.
|
||||
- `sendMessage(channelId, message)` → `DiscordResponse`. Validates payload.
|
||||
- `editMessage(channelId, messageId, partial)` → `DiscordResponse`.
|
||||
- `deleteMessage(channelId, messageId)` → `void`.
|
||||
- `getChannel(channelId)` → `DiscordChannel`.
|
||||
- `getGuild(guildId)` → `{ id; name }`.
|
||||
- `createReaction(channelId, messageId, emoji)` → `void`.
|
||||
- `getStoredMessage(messageId)` → `DiscordResponse | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The in-memory `messages` cache is intentionally simple — Discord assigns
|
||||
monotonic snowflake IDs so no eviction policy is required for typical usage.
|
||||
Callers needing TTLs should layer their own cache around `getStoredMessage`.
|
||||
`createReaction` URL-encodes the emoji segment because Discord rejects raw
|
||||
non-ASCII path segments. `request<T>` deliberately funnels through
|
||||
`executeDiscordRequest` so the bot client contributes zero bespoke error or
|
||||
metric code — the funnel owns the entire error mapping and observability
|
||||
surface.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Discord Webhook
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks
|
||||
are URLs that anyone with the URL can POST to — no bot token required — and
|
||||
they accept the same message shape as the bot API. This module wraps that
|
||||
endpoint with Zod boundary validation, injectable fetch/clock, OpenTelemetry
|
||||
instrumentation (inherited from the shared HTTP funnel), and a structured
|
||||
`DiscordDeliveryReport` return value so callers get persistable delivery
|
||||
telemetry rather than a raw Discord response.
|
||||
|
||||
## Features
|
||||
|
||||
- `send(message)`, `sendText(content)`, `sendEmbed(embed)` convenience methods.
|
||||
- Defaults `username` and `avatarUrl` from the constructor when the message
|
||||
payload omits them.
|
||||
- Threaded webhook routing via `?thread_id=<id>` query string.
|
||||
- Always sets `?wait=true` so Discord returns the persisted message ID,
|
||||
required for the `DiscordDeliveryReport.messageId` field.
|
||||
- Boundary validation:
|
||||
- `DiscordWebhookConfigSchema` at construction; missing/non-string URL →
|
||||
`DISCORD.CONFIG_MISSING`, other failures (bad URL shape, wrong host) →
|
||||
`DISCORD.INVALID_CONFIG`.
|
||||
- `DiscordMessageSchema` on every `send` call → `DISCORD.INVALID_MESSAGE`.
|
||||
- Uses the shared `executeDiscordRequest` funnel so error mapping
|
||||
(401/403/404/429/5xx) is identical to the bot client; on the webhook path,
|
||||
generic non-ok responses raise `DISCORD.WEBHOOK_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DiscordWebhook } from "@suaveplan/discord-client";
|
||||
|
||||
const hook = new DiscordWebhook({
|
||||
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
});
|
||||
await hook.sendText("deploy succeeded");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DiscordWebhook, type FetchLike } from "@suaveplan/discord-client";
|
||||
|
||||
const stub: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({ id: "42", channel_id: "1" }),
|
||||
});
|
||||
|
||||
const hook = new DiscordWebhook({
|
||||
webhookUrl: "https://discord.com/api/webhooks/1/tok",
|
||||
username: "deploybot",
|
||||
fetch: stub,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const report = await hook.sendEmbed({
|
||||
title: "Deploy",
|
||||
description: "succeeded",
|
||||
color: 0x00ff00,
|
||||
});
|
||||
expect(report.status).toBe("delivered");
|
||||
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new DiscordWebhook(config: DiscordWebhookConfig)` — Zod-validated;
|
||||
missing or non-string `webhookUrl` raises `DISCORD.CONFIG_MISSING`; any
|
||||
other validation failure (non-Discord host, bad URL) raises
|
||||
`DISCORD.INVALID_CONFIG`.
|
||||
- `send(message: DiscordMessage): Promise<DiscordDeliveryReport>`.
|
||||
- `sendText(content: string): Promise<DiscordDeliveryReport>`.
|
||||
- `sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildReport` derives the `timestamp` from the injected `Clock` rather than
|
||||
calling `new Date()` so tests can pin the value deterministically. The
|
||||
`errorCode` and `errorMessage` fields on the report are only populated when
|
||||
the caller explicitly constructs a failure report — in the happy path the
|
||||
report carries `status: "delivered"` and no error fields. The webhook URL is
|
||||
appended with `?wait=true` unconditionally because Discord returns 204 (no
|
||||
body) when `wait` is absent, which would leave the delivery report without an
|
||||
ID.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Discord Client Error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the namespaced, frozen error-code registry and the typed
|
||||
`DiscordClientError` class used by every runtime failure path in
|
||||
`@suaveplan/discord-client`. Co-locating the codes with the error class
|
||||
guarantees that adding a new code is a single-file change and that the
|
||||
TypeScript union stays in sync with the runtime registry the conformance
|
||||
audit greps for.
|
||||
|
||||
## Features
|
||||
|
||||
- `DISCORD_CODES` — a `defineCodes("DISCORD", [...])` frozen record. Each entry
|
||||
has the form `DISCORD.<REASON>` so it is globally unique across the
|
||||
shared code space.
|
||||
- `DiscordClientCode` — type alias for the union of every value in
|
||||
`DISCORD_CODES`.
|
||||
- `DiscordClientError` — extends `SuaveplanError<DiscordClientCode>` so
|
||||
callers can do `instanceof DiscordClientError` to narrow on the code union
|
||||
AND/OR pattern-match on `.code` for stable programmatic handling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "@suaveplan/discord-client/errors";
|
||||
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.AUTH_ERROR,
|
||||
"discord.errors.authError"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DiscordClientError, DISCORD_CODES } from "@suaveplan/discord-client";
|
||||
|
||||
try {
|
||||
await client.sendMessage("1", { content: "hi" });
|
||||
} catch (err) {
|
||||
if (err instanceof DiscordClientError) {
|
||||
switch (err.code) {
|
||||
case DISCORD_CODES.RATE_LIMITED:
|
||||
// back off using `err.context?.retryAfterMs`
|
||||
break;
|
||||
case DISCORD_CODES.FORBIDDEN:
|
||||
case DISCORD_CODES.AUTH_ERROR:
|
||||
// surface to ops, do not retry
|
||||
break;
|
||||
default:
|
||||
// generic retry / report
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DISCORD_CODES`: frozen record. Keys: `API_ERROR`, `AUTH_ERROR`,
|
||||
`RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `GUILD_NOT_FOUND`, `CONFIG_MISSING`,
|
||||
`INVALID_CONFIG`, `INVALID_MESSAGE`, `WEBHOOK_FAILED`, `TIMEOUT`,
|
||||
`FORBIDDEN`. Values are the `DISCORD.<REASON>` strings.
|
||||
- `DiscordClientCode`: `typeof DISCORD_CODES[keyof typeof DISCORD_CODES]`.
|
||||
- `DiscordClientError(code, messageKey, options?)`: typed error subclass.
|
||||
`options` is the `SuaveplanErrorOptions` bag — pass `cause`, `context`,
|
||||
`traceId`, or `clock` through.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every code in this registry is thrown by at least one production code path
|
||||
inside `src/utils/http.ts`, `src/core/bot-client/discord-bot-client.ts`, or
|
||||
`src/core/webhook/discord-webhook.ts`. The conformance audit
|
||||
(`bun scripts/conformance.ts @suaveplan/discord-client`) greps for
|
||||
exactly that mapping; do not add a code here unless you wire a real throw
|
||||
site for it in the same change.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Discord Client Types
|
||||
|
||||
## Purpose
|
||||
|
||||
Authoritative public type surface for `@suaveplan/discord-client`. Defines every
|
||||
data shape the bot client and webhook client emit or accept, plus the runtime
|
||||
Zod schemas used to validate configuration and message payloads at the public
|
||||
boundary. Centralising the types here guarantees consumers can import them
|
||||
without pulling in any runtime code, and ensures Zod schemas stay in lockstep
|
||||
with the TypeScript surface they validate.
|
||||
|
||||
## Features
|
||||
|
||||
- `DiscordBotConfig` / `DiscordWebhookConfig` with injectable `clock` and
|
||||
`fetch` for deterministic testing.
|
||||
- `DiscordMessage` / `DiscordEmbed` / `DiscordEmbedField` mirroring the Discord
|
||||
v10 API payload contracts (with field-length caps enforced by Zod).
|
||||
- `DiscordResponse`, `DiscordChannel`, `DiscordDeliveryReport`,
|
||||
`DiscordDeliveryStatus` covering every value returned to callers.
|
||||
- `FetchLike` and `DiscordFetchResponse` describe the minimal subset of the
|
||||
fetch contract the client depends on — tests inject a deterministic stub.
|
||||
- Zod boundary schemas (`DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`,
|
||||
`DiscordMessageSchema`) validate config and message payloads.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DiscordMessageSchema, type DiscordMessage } from "@suaveplan/discord-client/types";
|
||||
|
||||
const msg: DiscordMessage = { content: "hello" };
|
||||
const parsed = DiscordMessageSchema.parse(msg);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DiscordBotConfigSchema,
|
||||
type DiscordBotConfig,
|
||||
type FetchLike,
|
||||
} from "@suaveplan/discord-client/types";
|
||||
|
||||
const stubFetch: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const config: DiscordBotConfig = {
|
||||
token: "abc",
|
||||
fetch: stubFetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
};
|
||||
DiscordBotConfigSchema.parse(config);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DiscordBotConfig`: token, baseUrl, timeoutMs, plus injectable `clock`/`fetch`.
|
||||
- `DiscordWebhookConfig`: webhookUrl (must contain `discord.com/api/webhooks/`),
|
||||
username, avatarUrl, timeoutMs, plus injectables.
|
||||
- `DiscordMessage`: content (≤2000), embeds (≤10), username, avatarUrl, tts,
|
||||
threadId.
|
||||
- `DiscordEmbed`: full Discord embed object with caps on each field.
|
||||
- `DiscordResponse`: id, channelId, content, timestamp, embeds.
|
||||
- `DiscordChannel`: id, name, type, guildId, topic.
|
||||
- `DiscordDeliveryReport`: messageId, channelId, status, timestamp, errorCode,
|
||||
errorMessage.
|
||||
- `DiscordMessageSchema` / `DiscordBotConfigSchema` /
|
||||
`DiscordWebhookConfigSchema`: Zod parsers used at every public entrypoint.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`FetchLike` purposefully omits headers initialisation, body streaming, and
|
||||
other parts of the WHATWG Response API the client never reads. This keeps the
|
||||
test stub small and avoids tying the package to a specific runtime's fetch
|
||||
quirks. `DiscordBotConfigSchema` accepts the injectables as `z.unknown()` —
|
||||
their structural shape is enforced by the static `FetchLike` / `Clock` types,
|
||||
not at runtime, because schemas cannot reliably introspect function signatures
|
||||
across Bun and Node without false negatives.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Discord HTTP Helper
|
||||
|
||||
## Purpose
|
||||
|
||||
Single funnel through which every Discord API and webhook request flows. It
|
||||
maps Discord response codes onto the `DiscordClientError` taxonomy, attaches
|
||||
OpenTelemetry counters and histograms, enforces an `AbortController`-driven
|
||||
timeout, and treats abort errors as `DISCORD.TIMEOUT`. Centralising this logic
|
||||
guarantees identical retry hints, error codes, and observability across the
|
||||
bot client and webhook client — the two entrypoints differ only in their URL
|
||||
construction and choice of `kind`.
|
||||
|
||||
## Features
|
||||
|
||||
- Maps `401 / 403 / 404 / 429 / 5xx / other-non-ok` Discord responses onto
|
||||
stable `DISCORD.*` codes, with per-kind dispatch for the webhook path.
|
||||
- Differentiates guild vs channel 404s via `isGuildRequest`.
|
||||
- Pulls `Retry-After` data via `parseRetryAfterMs` and surfaces it on the
|
||||
thrown error's `context.retryAfterMs` so callers can honour Discord's
|
||||
back-off recommendation without re-parsing headers.
|
||||
- Lazily creates `discord_client.request.total` counter and
|
||||
`discord_client.request.duration_ms` histogram against the global OTel
|
||||
meter provider. `resetDiscordHttpMetrics()` invalidates the cache so tests
|
||||
can swap in an in-memory `MeterProvider`.
|
||||
- Treats `AbortError` (raised by the timeout) as `DISCORD.TIMEOUT`; every
|
||||
other fetch rejection becomes `DISCORD.API_ERROR` or `DISCORD.WEBHOOK_FAILED`
|
||||
per the request kind.
|
||||
- Returns `undefined` for HTTP 204 (Discord's success-with-no-body status,
|
||||
used by `DELETE` and `PUT` reactions).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { executeDiscordRequest } from "@suaveplan/discord-client";
|
||||
|
||||
const channel = await executeDiscordRequest<DiscordChannel>({
|
||||
fetch,
|
||||
clock: Date.now,
|
||||
method: "GET",
|
||||
url: "https://discord.com/api/v10/channels/1",
|
||||
headers: { Authorization: "Bot abc" },
|
||||
timeoutMs: 10_000,
|
||||
kind: "bot",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
executeDiscordRequest,
|
||||
resetDiscordHttpMetrics,
|
||||
} from "@suaveplan/discord-client";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const mp = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(mp);
|
||||
resetDiscordHttpMetrics();
|
||||
|
||||
await executeDiscordRequest({
|
||||
fetch: stub,
|
||||
clock: () => 1700000000000,
|
||||
method: "POST",
|
||||
url: "https://discord.com/api/v10/channels/1/messages",
|
||||
headers: { Authorization: "Bot abc", "Content-Type": "application/json" },
|
||||
body: { content: "hi" },
|
||||
timeoutMs: 10_000,
|
||||
kind: "bot",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `executeDiscordRequest<T>(opts: DiscordRequestOptions): Promise<T>` — main
|
||||
entrypoint. Throws `DiscordClientError`.
|
||||
- `resetDiscordHttpMetrics()` — clears the cached meter / counter / histogram
|
||||
so the next call rebinds to the current global meter provider. Used by tests.
|
||||
- `DiscordRequestKind = "bot" | "webhook"` — drives the error code on
|
||||
`WEBHOOK_FAILED` vs `API_ERROR`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function intentionally records a metric on every outcome (`ok`,
|
||||
`auth_error`, `forbidden`, `not_found`, `rate_limited`, `timeout`,
|
||||
`network_error`, `api_error`). Histograms are emitted with the same label
|
||||
set as the counter so dashboards can divide one by the other directly.
|
||||
The 401/403/429/404/non-ok ordering matches Discord's documented
|
||||
precedence; non-ok responses fall through to the final clause, which
|
||||
distinguishes `DISCORD.API_ERROR` (bot) from `DISCORD.WEBHOOK_FAILED`
|
||||
(webhook) so the error registry's retry/severity hints stay accurate.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Rate-Limit Header Parser
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses Discord's three rate-limit response headers into a single millisecond
|
||||
back-off duration that callers can use to schedule a retry. Discord is
|
||||
inconsistent about which header it emits in which response (gateway-level
|
||||
429s use `Retry-After`, bucket-level 429s use `X-RateLimit-Reset-After`, and
|
||||
shard-level overflows can fall back to `X-RateLimit-Reset` as a wall-clock
|
||||
target), so a single canonical parser ensures every code path reacts the same
|
||||
way and that tests can pin every flavour with deterministic fixtures.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `Retry-After`, `X-RateLimit-Reset-After`, and `X-RateLimit-Reset`.
|
||||
- Honours both seconds-as-float and HTTP-date encodings of `Retry-After`.
|
||||
- Treats `X-RateLimit-Reset` values < 10^12 as Unix epoch seconds and ≥ 10^12
|
||||
as Unix epoch milliseconds (Discord switched encodings mid-2023).
|
||||
- Accepts an injectable `Clock` so tests stay deterministic.
|
||||
- Returns `undefined` when no header is present or every header is unparsable.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/discord-client";
|
||||
|
||||
const retryAfterMs = parseRetryAfterMs(response);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/discord-client";
|
||||
|
||||
const fixedClock = () => 1_700_000_000_000;
|
||||
const fakeResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
headers: { get: (name: string) => (name === "x-ratelimit-reset" ? "1700000060" : null) },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
|
||||
// Returns 60_000 — the difference between epoch-second 1700000060 and the
|
||||
// injected clock at 1_700_000_000_000 ms.
|
||||
parseRetryAfterMs(fakeResponse, fixedClock);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`parseRetryAfterMs(response: DiscordFetchResponse, clock?: Clock): number | undefined`
|
||||
|
||||
- `response`: any object exposing `.headers.get(name)`.
|
||||
- `clock`: optional `Clock` injectable; defaults to `Date.now`.
|
||||
- Returns the parsed millisecond back-off, or `undefined` when no header was
|
||||
parsable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Math.ceil` is applied to the floating-point seconds to avoid under-waiting
|
||||
when Discord emits fractional values like `0.250`. Negative deltas from
|
||||
`X-RateLimit-Reset` clamp to zero rather than returning a stale duration —
|
||||
the caller should retry immediately rather than reading a negative value. The
|
||||
parser does NOT throw on malformed header values; it returns `undefined`,
|
||||
which is the canonical "no advice from Discord, fall back to your local
|
||||
retry policy" signal.
|
||||
Reference in New Issue
Block a user