mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

errors.ts

Language TypeScript4.4 KB115 lines

packages/ninety-client/src/errors.ts

  1. /**
  2. * Errors carry the endpoint and page that failed. When a nightly sync breaks a
  3. * year from now, the log line has to say which call died and where it was in the
  4. * pagination, or whoever is on call is reading raw JSON at 6am.
  5. */
  6. export interface NinetyErrorContext {
  7. /** e.g. "POST /todos/query" */
  8. endpoint: string
  9. /** 0-based internal page index, when the failure happened mid-pagination. */
  10. pageIndex?: number
  11. /** Ninety's response body, truncated. */
  12. body?: string
  13. }
  14. export class NinetyError extends Error {
  15. readonly status: number | undefined
  16. readonly context: NinetyErrorContext
  17. constructor(message: string, context: NinetyErrorContext, status?: number) {
  18. const where = context.pageIndex === undefined ? context.endpoint : `${context.endpoint} (page ${context.pageIndex})`
  19. super(`${message} [${where}]`)
  20. this.name = 'NinetyError'
  21. this.status = status
  22. this.context = context
  23. }
  24. }
  25. /**
  26. * 401.
  27. *
  28. * Says what happened, not why (rule 1). A 401 means Ninety rejected the token;
  29. * the cause is not in the response. Expiry is only one candidate (Ninety tokens
  30. * expire and are shown only once at generation), and generating a replacement
  31. * is not free: doing so invalidates the current token, so a wrong guess here
  32. * can turn a transient failure into a real one. The message lists candidates
  33. * and is actionable, because the person who reads it may not be the person who
  34. * built this.
  35. */
  36. export class NinetyAuthError extends NinetyError {
  37. constructor(context: NinetyErrorContext) {
  38. super(
  39. 'Ninety rejected the token (401). The response does not say why. Check, in order:\n' +
  40. ' 1. Does NINETY_TOKEN match the token currently shown in Ninety? Generating a new ' +
  41. 'Personal Access Token INVALIDATES the previous one, so a recent regeneration ' +
  42. 'elsewhere will break a stale copy: in your shell profile, in a terminal that ' +
  43. 'has not re-sourced it, or in the Worker secret.\n' +
  44. ' 2. Is the token past its expiry? Ninety shows the date on the token itself.\n' +
  45. ' 3. Is the account still active and still an Owner or Admin?\n' +
  46. 'If all three are fine, retry; a rejection with a valid token can be transient. ' +
  47. 'Regenerate only after ruling the first three out, because that invalidates whatever ' +
  48. 'is currently deployed.',
  49. context,
  50. 401,
  51. )
  52. this.name = 'NinetyAuthError'
  53. }
  54. }
  55. /**
  56. * 403. Wrong plan, or the account is an Observer (Observers cannot use the API).
  57. *
  58. * The endpoint-specific note belongs here, where the 403 is actually known.
  59. * Appended by a caller to every Ninety failure, it would put a 403's
  60. * explanation beside a 429: a cause nobody observed (rule 1). The status is
  61. * known here and the path is in the context, so the note is conditional on
  62. * both and the caller appends nothing.
  63. */
  64. export class NinetyForbiddenError extends NinetyError {
  65. constructor(context: NinetyErrorContext) {
  66. super(
  67. 'Ninety refused the request (403). Check that the account is on the Thrive plan ' +
  68. 'and is not an Observer, since Observers cannot generate or use API tokens.' +
  69. (context.endpoint.includes('/todos/company')
  70. ? ' This endpoint is also restricted to company Owners and Admins, so a role ' +
  71. 'change on the token’s account produces exactly this.'
  72. : ''),
  73. context,
  74. 403,
  75. )
  76. this.name = 'NinetyForbiddenError'
  77. }
  78. }
  79. /** 429. Surfaced only after retries are exhausted. */
  80. export class NinetyRateLimitError extends NinetyError {
  81. readonly retryAfterMs: number | undefined
  82. constructor(context: NinetyErrorContext, retryAfterMs?: number) {
  83. super(
  84. `Ninety rate limit hit (429) and retries are exhausted. Documented limit is 25 req/s per user. ` +
  85. `Lower NINETY_RPS.`,
  86. context,
  87. 429,
  88. )
  89. this.name = 'NinetyRateLimitError'
  90. this.retryAfterMs = retryAfterMs
  91. }
  92. }
  93. /**
  94. * The response did not match the expected schema. The shapes in types.ts are
  95. * verified against live responses, so this means a response no longer matches
  96. * them.
  97. */
  98. export class NinetySchemaError extends NinetyError {
  99. constructor(context: NinetyErrorContext, detail: string) {
  100. super(
  101. `Ninety returned a shape this client did not expect: ${detail}. ` +
  102. `The assumed shapes are unverified (see docs/ARCHITECTURE.md); fix the adapter, not the caller.`,
  103. context,
  104. )
  105. this.name = 'NinetySchemaError'
  106. }
  107. }