first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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` |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.