http.ts
Language TypeScript3.8 KB109 lines
/*** Fathom HTTP layer.** Read-only by construction: this client can issue exactly one verb (GET) to* one path prefix. There is no write method here and adding one should be a* deliberate, reviewed act: the Fathom API can create and delete webhooks,* which would mean pointing someone's meeting content at a URL.*/import { FATHOM_BASE_URL } from './constants.js'export interface FathomOptions {apiKey: string/** Whose key this is. Used only in error messages, so a failure names a person. */owner?: stringbaseUrl?: stringmaxRetries?: numberfetchImpl?: typeof fetchsleep?: (ms: number) => Promise<void>}export class FathomError extends Error {constructor(message: string,readonly status?: number,readonly owner?: string,) {super(owner ? `${message} [key: ${owner}]` : message)this.name = 'FathomError'}}export class FathomClient {private readonly apiKey: stringprivate readonly owner: string | undefinedprivate readonly baseUrl: stringprivate readonly maxRetries: numberprivate readonly fetchImpl: typeof fetchprivate readonly sleep: (ms: number) => Promise<void>constructor(opts: FathomOptions) {if (!opts.apiKey) {throw new FathomError('No Fathom API key. Keys are per USER, not per org. Generate one at ' +'User Settings > API Access and export it from the shell profile. ' +'This repo has no .env reader.',undefined,opts.owner,)}this.apiKey = opts.apiKeythis.owner = opts.ownerthis.baseUrl = opts.baseUrl ?? FATHOM_BASE_URLthis.maxRetries = opts.maxRetries ?? 3this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))}/*** One GET, with 429 handled by waiting exactly as long as Fathom says to.** A 401 reports what happened and does not diagnose it (rule 1). Fathom's* own FAQ lists several causes (wrong key, revoked key, deactivated user,* malformed header) and the response cannot tell them apart, so the error* lists all of them.*/async get<T>(path: string, params: Record<string, string | number | boolean | undefined>): Promise<T> {const url = new URL(`${this.baseUrl}${path}`)for (const [k, v] of Object.entries(params)) {if (v !== undefined) url.searchParams.set(k, String(v))}for (let attempt = 0; ; attempt++) {const res = await this.fetchImpl(url.toString(), {method: 'GET',headers: { 'X-Api-Key': this.apiKey, accept: 'application/json' },})if (res.status === 429) {// Retry-After is authoritative; the heavy bucket can drop to 5/60s under// someone else's load, so guessing a backoff here would guess wrong.if (attempt >= this.maxRetries) {throw new FathomError('Fathom rate limit exceeded and retries exhausted.', 429, this.owner)}const after = Number(res.headers.get('retry-after') ?? '') || 60await this.sleep(after * 1000)continue}if (res.status === 401 || res.status === 403) {throw new FathomError(`Fathom refused the key (HTTP ${res.status}). Candidate causes, which the ` +`response cannot distinguish: the key is wrong or was regenerated; the key ` +`was revoked; the user it belongs to was deactivated or removed from the ` +`team, which stops their key working; or the header is malformed. Check the ` +`key in User Settings > API Access and that the user is still active.`,res.status,this.owner,)}if (!res.ok) {throw new FathomError(`Fathom returned HTTP ${res.status}.`, res.status, this.owner)}return (await res.json()) as T}}}