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 }