errors.ts
Language TypeScript4.4 KB115 lines
/*** Errors carry the endpoint and page that failed. When a nightly sync breaks a* year from now, the log line has to say which call died and where it was in the* pagination, or whoever is on call is reading raw JSON at 6am.*/export interface NinetyErrorContext {/** e.g. "POST /todos/query" */endpoint: string/** 0-based internal page index, when the failure happened mid-pagination. */pageIndex?: number/** Ninety's response body, truncated. */body?: string}export class NinetyError extends Error {readonly status: number | undefinedreadonly context: NinetyErrorContextconstructor(message: string, context: NinetyErrorContext, status?: number) {const where = context.pageIndex === undefined ? context.endpoint : `${context.endpoint} (page ${context.pageIndex})`super(`${message} [${where}]`)this.name = 'NinetyError'this.status = statusthis.context = context}}/*** 401.** Says what happened, not why (rule 1). A 401 means Ninety rejected the token;* the cause is not in the response. Expiry is only one candidate (Ninety tokens* expire and are shown only once at generation), and generating a replacement* is not free: doing so invalidates the current token, so a wrong guess here* can turn a transient failure into a real one. The message lists candidates* and is actionable, because the person who reads it may not be the person who* built this.*/export class NinetyAuthError extends NinetyError {constructor(context: NinetyErrorContext) {super('Ninety rejected the token (401). The response does not say why. Check, in order:\n' +' 1. Does NINETY_TOKEN match the token currently shown in Ninety? Generating a new ' +'Personal Access Token INVALIDATES the previous one, so a recent regeneration ' +'elsewhere will break a stale copy: in your shell profile, in a terminal that ' +'has not re-sourced it, or in the Worker secret.\n' +' 2. Is the token past its expiry? Ninety shows the date on the token itself.\n' +' 3. Is the account still active and still an Owner or Admin?\n' +'If all three are fine, retry; a rejection with a valid token can be transient. ' +'Regenerate only after ruling the first three out, because that invalidates whatever ' +'is currently deployed.',context,401,)this.name = 'NinetyAuthError'}}/*** 403. Wrong plan, or the account is an Observer (Observers cannot use the API).** The endpoint-specific note belongs here, where the 403 is actually known.* Appended by a caller to every Ninety failure, it would put a 403's* explanation beside a 429: a cause nobody observed (rule 1). The status is* known here and the path is in the context, so the note is conditional on* both and the caller appends nothing.*/export class NinetyForbiddenError extends NinetyError {constructor(context: NinetyErrorContext) {super('Ninety refused the request (403). Check that the account is on the Thrive plan ' +'and is not an Observer, since Observers cannot generate or use API tokens.' +(context.endpoint.includes('/todos/company')? ' This endpoint is also restricted to company Owners and Admins, so a role ' +'change on the token’s account produces exactly this.': ''),context,403,)this.name = 'NinetyForbiddenError'}}/** 429. Surfaced only after retries are exhausted. */export class NinetyRateLimitError extends NinetyError {readonly retryAfterMs: number | undefinedconstructor(context: NinetyErrorContext, retryAfterMs?: number) {super(`Ninety rate limit hit (429) and retries are exhausted. Documented limit is 25 req/s per user. ` +`Lower NINETY_RPS.`,context,429,)this.name = 'NinetyRateLimitError'this.retryAfterMs = retryAfterMs}}/*** The response did not match the expected schema. The shapes in types.ts are* verified against live responses, so this means a response no longer matches* them.*/export class NinetySchemaError extends NinetyError {constructor(context: NinetyErrorContext, detail: string) {super(`Ninety returned a shape this client did not expect: ${detail}. ` +`The assumed shapes are unverified (see docs/ARCHITECTURE.md); fix the adapter, not the caller.`,context,)this.name = 'NinetySchemaError'}}