Skip to main content

Migration Guide

Numeric → semantic error codes

v5.3.0-alpha.4 — every error code switched from opaque numerics (HM-AUTH-001) to SCREAMING_SNAKE_CASE semantics (HM-AUTH-MISSING). Status codes preserved; multi-meaning numerics split.

DocsMigrationsError codes (v5.3.0-alpha.4)

Breaking change for code that pins on err.code string values.

Response bodies now emit semantic codes only — they are not bilingual. If your code does if (err.code === 'HM-AUTH-001') it will silently miss every error after this version. Search and replace using the full mapping table below, or use the LEGACY_CODE_ALIASES map strategy described later on this page.

What changed

Before this ship, HM-AUTH-001 meant “Unauthorized” on one route, “Sign-in required” on another, “User not found” on a third, and “Invalid email or password” in the registry. Same code, four different conditions. An SDK consumer trying to handle the error class couldn't pin reliably — they had to parse the message string, which is brittle (the messages are user-facing English and may be localized in the future).

Each numeric was split into 1–3 specific semantic codes that name the actual condition. The code is the documentation now: HM-AUTH-MISSING tells you exactly what to fix without parsing English.

Format

HM-{DOMAIN}-{CONDITION}

Examples:
  HM-AUTH-MISSING
  HM-AUTH-INVALID_KEY
  HM-VALIDATION-FAILED
  HM-RATELIMIT-EXCEEDED
  HM-THREAD-NOT_FOUND

Domain is the resource or concern (AUTH, TENANT, THREAD, AI, WEBHOOK, etc.). Condition is SCREAMING_SNAKE_CASE describing what failed. The pair is unique. No version is encoded — codes are stable contracts.

Migration steps

  1. 1

    Search your codebase for legacy numeric codes.

    Grep for HM-[A-Z]+-\d+ across your project. Common matches: error-handling middleware, alert rules, dashboard filters, retry-classification helpers.

  2. 2

    For each match, find its semantic equivalent.

    Use the mapping table below. Where one numeric maps to several semantics, the call-site context tells you which one — e.g. on a sign-in route, HM-AUTH-001 almost always meant HM-AUTH-INVALID_CREDENTIALS; on a protected API route it meant HM-AUTH-MISSING.

  3. 3

    If you cannot enumerate all sites cleanly, use a translation map.

    Wrap your error-handling at one place with a function that translates legacy codes to semantics for backwards-compat downstream consumers (alerts, dashboards). Example below.

  4. 4

    Verify with a smoke test.

    Trigger a known auth failure (e.g. omit the Authorization header) and confirm the returned err.code is HM-AUTH-MISSING, not HM-AUTH-001.

Before / after — Node.js

Before (broken after upgrade)

try {
  await client.threads.list();
} catch (err) {
  if (err.code === 'HM-AUTH-001') {
    // Was: catch all auth failures.
    // After upgrade: NEVER fires — code is now HM-AUTH-MISSING / HM-AUTH-INVALID_KEY.
    refreshApiKey();
  }
}

After (handles every auth case explicitly)

try {
  await client.threads.list();
} catch (err) {
  // Domain prefix matches every auth-related code.
  if (err.code?.startsWith('HM-AUTH-')) {
    refreshApiKey();
  }

  // Or, narrower handlers when the recovery differs:
  if (err.code === 'HM-AUTH-INVALID_KEY') {
    promptUserToGenerateNewKey();
  }
  if (err.code === 'HM-RATELIMIT-EXCEEDED') {
    backoffAndRetry(err.headers['retry-after']);
  }
}

Before / after — Python

Before

try:
    threads = client.threads.list()
except HelpMeshError as err:
    if err.code == "HM-AUTH-001":
        refresh_api_key()

After

try:
    threads = client.threads.list()
except HelpMeshError as err:
    if err.code.startswith("HM-AUTH-"):
        refresh_api_key()
    elif err.code == "HM-RATELIMIT-EXCEEDED":
        retry_after = err.headers.get("retry-after", 1)
        time.sleep(int(retry_after))

Translation map strategy (LEGACY_CODE_ALIASES)

The HelpMesh source ships a LEGACY_CODE_ALIASES reverse-lookup map you can mirror in your own code. It maps every retired numeric code to its modern semantic(s). If your error-handling has many call sites and refactoring them all in one PR is risky, wrap responses at the boundary instead and translate downstream:

// Wrap your existing alerting/dashboard code so old rules keep firing
// during the transition window. Remove this shim once all consumers
// are updated to recognize semantic codes directly.

const SEMANTIC_TO_LEGACY: Record<string, string> = {
  'HM-AUTH-MISSING':         'HM-AUTH-001',
  'HM-AUTH-INVALID_KEY':     'HM-AUTH-001',
  'HM-AUTH-INVALID_CREDENTIALS': 'HM-AUTH-001',
  'HM-AUTH-USER_NOT_FOUND':  'HM-AUTH-001',
  'HM-RATELIMIT-EXCEEDED':   'HM-RATE-001',
  // ...full map mirrors LEGACY_CODE_ALIASES from packages/core/src/errors/registry.ts
};

function classifyError(err: { code: string }) {
  // New code paths see semantics:
  newAlertingPath(err.code);

  // Legacy dashboards keep working:
  const legacy = SEMANTIC_TO_LEGACY[err.code];
  if (legacy) legacyAlertingPath(legacy);
}

Mapping table — retired numeric codes

The 10 highest-frequency numerics. The full 44-entry map ships with the source at packages/core/src/errors/registry.ts.

Old codeNew code(s)What changed
HM-AUTH-001
HM-AUTH-MISSINGHM-AUTH-INVALID_CREDENTIALSHM-AUTH-USER_NOT_FOUND
Was overloaded across "missing key", "wrong password", and "user deleted". Now split.

Note: Status changed for one case: USER_NOT_FOUND now returns 404 (was 401).

HM-AUTH-002
HM-AUTH-TOKEN_EXPIREDHM-AUTH-PASSWORD_INCORRECTHM-AUTH-PASSWORD_NOT_SET
Different routes meant different things. Token-flow vs password-flow vs OAuth-only-account.
HM-AUTH-003
HM-AUTH-FORBIDDENHM-AUTH-RESET_LINK_INVALID
Permissions denial vs broken reset link — same code, very different fixes.
HM-VALIDATION-001
HM-VALIDATION-FAILEDHM-VALIDATION-MISSING_FIELDHM-VALIDATION-INVALID_FIELD
Generic Zod validation, plus two narrower codes for "field not provided" vs "field bad value".
HM-AI-001
HM-AI-DISABLEDHM-AI-PROVIDER_ERROR
"AI off for this tenant" vs "Anthropic returned 503" — wildly different remediation.
HM-AI-002
HM-AI-RATE_LIMITEDHM-AI-SEARCH_FAILED
AI rate-limit hits vs KB search internal error.
HM-CONFLICT-001
HM-CONFLICT-DUPLICATE
Resource already exists. Renamed for clarity.
HM-WIDGET-003
HM-WIDGET-SESSION_REQUIREDHM-WIDGET-IDENTIFIED_REQUIRED
Widget anonymous-session vs identified-customer-session distinction.
HM-RATE-001 + HM-RATELIMIT-001
HM-RATELIMIT-EXCEEDED
The codebase had two codes for the same condition. Collapsed.
HM-TENANT-001
HM-TENANT-NOT_FOUNDHM-TENANT-SUSPENDED
Subdomain → tenant lookup miss vs explicitly suspended account.

HTTP status codes preserved.

Every status code matches the prior behavior except two specific corrections: the two “User not found” sites previously returned 401 with HM-AUTH-001. Those should be 404 with HM-AUTH-USER_NOT_FOUND (the user could be deleted, not just unauthenticated). If your retry logic was based on the 401, it will need to handle the 404 case explicitly.

All semantic codes in one place

The complete public-facing error registry (39 codes shown — the registry has 47 entries; the remainder are internal-only).

CodeStatusMeaning
HM-AUTH-MISSING401No API key or session.
HM-AUTH-INVALID_KEY401API key is invalid, revoked, or expired.
HM-AUTH-INVALID_CREDENTIALS401Wrong email or password.
HM-AUTH-USER_NOT_FOUND404User account does not exist.
HM-AUTH-TOKEN_EXPIRED401Auth token has expired.
HM-AUTH-PASSWORD_INCORRECT401Current password is wrong (during change-password).
HM-AUTH-PASSWORD_NOT_SET400OAuth-only account — set a password first.
HM-AUTH-FORBIDDEN403Authenticated but not permitted.
HM-AUTH-ROLE403Role does not permit this action.
HM-AUTH-SCOPE403API key missing required scope.
HM-AUTH-RESET_LINK_INVALID400Password reset link expired or already used.
HM-AUTH-ACCOUNT_LOCKED423Account locked after too many failed attempts.
HM-AUTH-SESSION_INVALID401Session revoked or invalid.
HM-TENANT-NOT_FOUND404Subdomain does not match a tenant.
HM-TENANT-SUSPENDED403Tenant account suspended.
HM-TENANT-LIMIT_REACHED429Plan limit reached.
HM-THREAD-NOT_FOUND404Thread id or ticket number does not match.
HM-THREAD-ALREADY_CLOSED409Action requires an open thread.
HM-THREAD-ASSIGNMENT_FAILED422Cannot assign — agent inactive or off team.
HM-MESSAGE-NOT_FOUND404Message id does not match.
HM-MESSAGE-TOO_LARGE413Message body or attachments over limit.
HM-CUSTOMER-NOT_FOUND404Customer id does not match.
HM-CUSTOMER-DUPLICATE409Email or external id already in use.
HM-VALIDATION-FAILED400Generic Zod validation error. `error.details[]` carries field-level info.
HM-VALIDATION-MISSING_FIELD400Required field omitted.
HM-VALIDATION-INVALID_FIELD400Field present but value is invalid.
HM-RATELIMIT-EXCEEDED429Per-IP, per-tenant, or auth-bucket rate limit hit. Honor `Retry-After`.
HM-AI-DISABLED403AI features off for this tenant.
HM-AI-PROVIDER_ERROR502Upstream AI provider returned an error.
HM-AI-RATE_LIMITED429AI provider rate-limited the call.
HM-AI-SEARCH_FAILED500KB semantic search failed.
HM-AI-CONTENT_FILTERED422Output blocked by AI safety policy.
HM-WEBHOOK-DELIVERY_FAILED502Webhook receiver returned non-2xx after retries.
HM-WEBHOOK-INVALID_SIGNATURE401HMAC verification failed on inbound webhook.
HM-CONFLICT-DUPLICATE409Resource already exists (slug, shortcut, etc).
HM-KB-ARTICLE_NOT_FOUND404KB article slug does not match.
HM-INBOX-AMBIGUOUS_ROUTING422Cannot route inbound to a single inbox.
HM-INVITE-INVALID400Invite token expired or already used.
HM-SLA-BREACH422SLA policy was breached.

Using the official SDK?

@helpmesh-io/sdk ≥ 5.1.0 already emits semantic codes. Run npm i @helpmesh-io/sdk@latest to pick up the latest typed error class. Code that does err instanceof HelpMeshAuthError instead of pinning on string codes is unaffected by this migration.