Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/environment

src/open-meteo-client.ts

114 lines · 3.3 KB · TypeScript
const defaultRetryAttempts = 4
const defaultRetryBaseDelayMs = 500
const defaultTimeoutMs = 8_000
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504])

export type RetryOptions = {
	attempts?: number
	baseDelayMs?: number
	timeoutMs?: number
}

function sleep(ms: number) {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

function retryAfterMs(value: string | null): number | null {
	if (!value) return null
	const seconds = Number(value)
	if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000)
	const dateMs = Date.parse(value)
	if (!Number.isFinite(dateMs)) return null
	return Math.max(0, dateMs - Date.now())
}

function backoffMs(
	attempt: number,
	baseDelayMs: number,
	retryAfter: number | null,
): number {
	if (retryAfter !== null) return retryAfter
	const jitter = 0.75 + Math.random() * 0.5
	return Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter)
}

function shouldRetry(status: number): boolean {
	return retryableStatuses.has(Number(status))
}

async function fetchWithTimeout(url: string, timeoutMs: number) {
	const controller = new AbortController()
	const timeout = setTimeout(() => controller.abort('timeout'), timeoutMs)
	try {
		return await fetch(url, { signal: controller.signal })
	} finally {
		clearTimeout(timeout)
	}
}

/** GET a JSON endpoint with timeout, retry-after handling, and jittered exponential backoff. */
export async function fetchJsonWithRetry(
	url: string,
	options: RetryOptions = {},
): Promise<{ data: any; attempts: number }> {
	const attempts = Number.isFinite(Number(options.attempts))
		? Math.max(1, Math.floor(Number(options.attempts)))
		: defaultRetryAttempts
	const baseDelayMs = Number.isFinite(Number(options.baseDelayMs))
		? Math.max(0, Number(options.baseDelayMs))
		: defaultRetryBaseDelayMs
	const timeoutMs = Number.isFinite(Number(options.timeoutMs))
		? Math.max(1, Number(options.timeoutMs))
		: defaultTimeoutMs
	let lastError: Error | null = null
	for (let attempt = 1; attempt <= attempts; attempt += 1) {
		let retryAfter: number | null = null
		try {
			const response = await fetchWithTimeout(url, timeoutMs)
			retryAfter = retryAfterMs(response.headers.get('retry-after'))
			const text = await response.text()
			let body: any = null
			if (text) {
				try {
					body = JSON.parse(text)
				} catch {
					body = null
				}
			}
			if (!response.ok) {
				const detail =
					body && typeof body === 'object'
						? body.reason || body.error || JSON.stringify(body).slice(0, 300)
						: text.slice(0, 300)
				throw Object.assign(
					new Error(
						'Request failed for ' +
							url +
							': HTTP ' +
							response.status +
							(detail ? ' - ' + detail : ''),
					),
					{
						status: response.status,
						retryable: shouldRetry(response.status),
					},
				)
			}
			if (!body) {
				throw new Error(
					'Request failed for ' + url + ': empty or invalid JSON response.',
				)
			}
			return { data: body, attempts: attempt }
		} catch (error) {
			lastError = error instanceof Error ? error : new Error(String(error))
			const retryable =
				error && typeof error === 'object' && 'retryable' in error
					? Boolean((error as { retryable: unknown }).retryable)
					: true
			if (!retryable || attempt >= attempts) break
			await sleep(backoffMs(attempt, baseDelayMs, retryAfter))
		}
	}
	throw lastError || new Error('Request failed for ' + url)
}