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

http.ts

Language TypeScript3.8 KB109 lines

packages/fathom-client/src/http.ts

  1. /**
  2. * Fathom HTTP layer.
  3. *
  4. * Read-only by construction: this client can issue exactly one verb (GET) to
  5. * one path prefix. There is no write method here and adding one should be a
  6. * deliberate, reviewed act: the Fathom API can create and delete webhooks,
  7. * which would mean pointing someone's meeting content at a URL.
  8. */
  9. import { FATHOM_BASE_URL } from './constants.js'
  10. export interface FathomOptions {
  11. apiKey: string
  12. /** Whose key this is. Used only in error messages, so a failure names a person. */
  13. owner?: string
  14. baseUrl?: string
  15. maxRetries?: number
  16. fetchImpl?: typeof fetch
  17. sleep?: (ms: number) => Promise<void>
  18. }
  19. export class FathomError extends Error {
  20. constructor(
  21. message: string,
  22. readonly status?: number,
  23. readonly owner?: string,
  24. ) {
  25. super(owner ? `${message} [key: ${owner}]` : message)
  26. this.name = 'FathomError'
  27. }
  28. }
  29. export class FathomClient {
  30. private readonly apiKey: string
  31. private readonly owner: string | undefined
  32. private readonly baseUrl: string
  33. private readonly maxRetries: number
  34. private readonly fetchImpl: typeof fetch
  35. private readonly sleep: (ms: number) => Promise<void>
  36. constructor(opts: FathomOptions) {
  37. if (!opts.apiKey) {
  38. throw new FathomError(
  39. 'No Fathom API key. Keys are per USER, not per org. Generate one at ' +
  40. 'User Settings > API Access and export it from the shell profile. ' +
  41. 'This repo has no .env reader.',
  42. undefined,
  43. opts.owner,
  44. )
  45. }
  46. this.apiKey = opts.apiKey
  47. this.owner = opts.owner
  48. this.baseUrl = opts.baseUrl ?? FATHOM_BASE_URL
  49. this.maxRetries = opts.maxRetries ?? 3
  50. this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)
  51. this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))
  52. }
  53. /**
  54. * One GET, with 429 handled by waiting exactly as long as Fathom says to.
  55. *
  56. * A 401 reports what happened and does not diagnose it (rule 1). Fathom's
  57. * own FAQ lists several causes (wrong key, revoked key, deactivated user,
  58. * malformed header) and the response cannot tell them apart, so the error
  59. * lists all of them.
  60. */
  61. async get<T>(path: string, params: Record<string, string | number | boolean | undefined>): Promise<T> {
  62. const url = new URL(`${this.baseUrl}${path}`)
  63. for (const [k, v] of Object.entries(params)) {
  64. if (v !== undefined) url.searchParams.set(k, String(v))
  65. }
  66. for (let attempt = 0; ; attempt++) {
  67. const res = await this.fetchImpl(url.toString(), {
  68. method: 'GET',
  69. headers: { 'X-Api-Key': this.apiKey, accept: 'application/json' },
  70. })
  71. if (res.status === 429) {
  72. // Retry-After is authoritative; the heavy bucket can drop to 5/60s under
  73. // someone else's load, so guessing a backoff here would guess wrong.
  74. if (attempt >= this.maxRetries) {
  75. throw new FathomError('Fathom rate limit exceeded and retries exhausted.', 429, this.owner)
  76. }
  77. const after = Number(res.headers.get('retry-after') ?? '') || 60
  78. await this.sleep(after * 1000)
  79. continue
  80. }
  81. if (res.status === 401 || res.status === 403) {
  82. throw new FathomError(
  83. `Fathom refused the key (HTTP ${res.status}). Candidate causes, which the ` +
  84. `response cannot distinguish: the key is wrong or was regenerated; the key ` +
  85. `was revoked; the user it belongs to was deactivated or removed from the ` +
  86. `team, which stops their key working; or the header is malformed. Check the ` +
  87. `key in User Settings > API Access and that the user is still active.`,
  88. res.status,
  89. this.owner,
  90. )
  91. }
  92. if (!res.ok) {
  93. throw new FathomError(`Fathom returned HTTP ${res.status}.`, res.status, this.owner)
  94. }
  95. return (await res.json()) as T
  96. }
  97. }
  98. }