/ src / lib / utils / wave / call.ts
call.ts
  1  import { browser } from '$app/environment';
  2  import { error } from '@sveltejs/kit';
  3  import getOptionalEnvVarPublic from '../get-optional-env-var/public';
  4  import { getRefreshedAuthToken } from './auth';
  5  
  6  export class AccountSuspendedError extends Error {
  7    constructor() {
  8      super('Account suspended');
  9      this.name = 'AccountSuspendedError';
 10    }
 11  }
 12  
 13  function isAccountSuspendedResponse(status: number, body: string): boolean {
 14    return status === 403 && body.includes('suspended');
 15  }
 16  
 17  const MAX_RETRIES = 3;
 18  const INITIAL_RETRY_DELAY_MS = 500;
 19  
 20  function isRetryableError(err: unknown): boolean {
 21    if (err instanceof TypeError && err.message.includes('fetch')) {
 22      // Network errors like "Failed to fetch"
 23      return true;
 24    }
 25    return false;
 26  }
 27  
 28  function isRetryableStatus(status: number): boolean {
 29    // Retry on server errors and rate limiting
 30    return status >= 500 || status === 429;
 31  }
 32  
 33  async function sleep(ms: number): Promise<void> {
 34    return new Promise((resolve) => setTimeout(resolve, ms));
 35  }
 36  
 37  const PUBLIC_WAVE_API_URL = getOptionalEnvVarPublic(
 38    'PUBLIC_WAVE_API_URL',
 39    true,
 40    'Wave functionality will not work.',
 41  );
 42  
 43  const INTERNAL_WAVE_API_URL = getOptionalEnvVarPublic(
 44    'PUBLIC_INTERNAL_WAVE_API_URL',
 45    true,
 46    'Wave functionality will not work.',
 47  );
 48  
 49  const WAVE_API_URL = browser ? PUBLIC_WAVE_API_URL : INTERNAL_WAVE_API_URL;
 50  
 51  export async function call(path: string, options: RequestInit = {}) {
 52    if (!WAVE_API_URL) {
 53      throw new Error('Wave API URL is not configured.');
 54    }
 55  
 56    const response = await fetch(`${WAVE_API_URL}${path}`, options);
 57  
 58    if (!response.ok) {
 59      const errorText = await response.text();
 60  
 61      if (isAccountSuspendedResponse(response.status, errorText)) {
 62        throw new AccountSuspendedError();
 63      }
 64  
 65      throw new Error(`API call failed: ${response.status} ${response.statusText} - ${errorText}`);
 66    }
 67    return response.json();
 68  }
 69  
 70  export async function authenticatedCall(
 71    f = fetch,
 72    path: string,
 73    options: RequestInit = {},
 74    refreshOnUnauthorized = browser,
 75  ) {
 76    if (!WAVE_API_URL) {
 77      throw new Error('Wave API URL is not configured.');
 78    }
 79  
 80    const method = options.method?.toUpperCase() ?? 'GET';
 81    const isGetRequest = method === 'GET';
 82  
 83    let lastError: unknown;
 84  
 85    for (let attempt = 0; attempt <= (isGetRequest ? MAX_RETRIES : 0); attempt++) {
 86      if (attempt > 0) {
 87        // Exponential backoff: 500ms, 1000ms, 2000ms
 88        await sleep(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1));
 89      }
 90  
 91      let res: Response;
 92  
 93      try {
 94        res = await f(`${WAVE_API_URL}${path}`, {
 95          ...options,
 96          credentials: 'include',
 97          headers: {
 98            'Content-Type': 'application/json',
 99            'X-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone,
100            ...(options.headers || {}),
101          },
102        });
103      } catch (err) {
104        // Network error (e.g., "Failed to fetch")
105        if (isGetRequest && isRetryableError(err) && attempt < MAX_RETRIES) {
106          lastError = err;
107          continue;
108        }
109        throw err;
110      }
111  
112      // Retry on 5xx or 429 for GET requests
113      if (isGetRequest && isRetryableStatus(res.status) && attempt < MAX_RETRIES) {
114        lastError = new Error(`API call failed: ${res.status} ${res.statusText}`);
115        continue;
116      }
117  
118      // if the response is 401, it means the token is invalid/expired, so we should try to refresh it
119      if (res.status === 401 && refreshOnUnauthorized) {
120        // try to refresh the token
121        await getRefreshedAuthToken();
122  
123        // retry the original request with the new token
124        return authenticatedCall(f, path, options, false);
125      } else if ((!res.ok && res.status !== 404) || res.status === 403) {
126        const errorText = await res.text();
127  
128        if (isAccountSuspendedResponse(res.status, errorText)) {
129          throw new AccountSuspendedError();
130        }
131  
132        if (res.status === 401) {
133          throw error(401, 'Unauthorized');
134        }
135  
136        if (res.status === 403) {
137          throw error(403, 'Forbidden');
138        }
139  
140        throw new Error(`API call failed: ${res.status} ${res.statusText} - ${errorText}`);
141      }
142  
143      return res;
144    }
145  
146    // If we exhausted all retries, throw the last error
147    throw lastError;
148  }