diff --git a/oxide-api/src/http-client.ts b/oxide-api/src/http-client.ts index b820c3b..bbfda92 100644 --- a/oxide-api/src/http-client.ts +++ b/oxide-api/src/http-client.ts @@ -117,6 +117,12 @@ export interface FullParams extends FetchParams { method?: string; } +export type RetryHandler = (err: any) => boolean; +export type RetryHandlerFactory = ( + url: RequestInfo | URL, + init: RequestInit, +) => RetryHandler; + export interface ApiConfig { /** * No host means requests will be sent to the current host. This is used in @@ -125,14 +131,21 @@ export interface ApiConfig { host?: string; token?: string; baseParams?: FetchParams; + retryHandler?: RetryHandlerFactory; } export class HttpClient { host: string; token?: string; baseParams: FetchParams; - - constructor({ host = "", baseParams = {}, token }: ApiConfig = {}) { + retryHandler: RetryHandlerFactory; + + constructor({ + host = "", + baseParams = {}, + token, + retryHandler, + }: ApiConfig = {}) { this.host = host; this.token = token; @@ -141,6 +154,7 @@ export class HttpClient { headers.append("Authorization", `Bearer ${token}`); } this.baseParams = mergeParams({ headers }, baseParams); + this.retryHandler = retryHandler ? retryHandler : () => () => false; } public async request({ @@ -155,7 +169,24 @@ export class HttpClient { ...mergeParams(this.baseParams, fetchParams), body: JSON.stringify(snakeify(body), replacer), }; + return fetchWithRetry(fetch, url, init, this.retryHandler(url, init)); + } +} + +export async function fetchWithRetry( + fetch: any, + url: string, + init: RequestInit, + retry: RetryHandler, +): Promise> { + try { return handleResponse(await fetch(url, init)); + } catch (err) { + if (retry(err)) { + return await fetchWithRetry(fetch, url, init, retry); + } + + throw err; } } diff --git a/oxide-openapi-gen-ts/src/client/static/http-client.test.ts b/oxide-openapi-gen-ts/src/client/static/http-client.test.ts index ae75d50..4285d3f 100644 --- a/oxide-openapi-gen-ts/src/client/static/http-client.test.ts +++ b/oxide-openapi-gen-ts/src/client/static/http-client.test.ts @@ -8,7 +8,7 @@ * Copyright Oxide Computer Company */ -import { handleResponse, mergeParams } from "./http-client"; +import { fetchWithRetry, handleResponse, mergeParams } from "./http-client"; import { describe, expect, it } from "vitest"; const headers = { "Content-Type": "application/json" }; @@ -17,6 +17,41 @@ const headers = { "Content-Type": "application/json" }; const json = (body: any, status = 200) => new Response(JSON.stringify(body), { status, headers }); +describe("fetchWithRetry", () => { + it("retries request when handler returns true", async () => { + const retryLimit = 1 + let retries = 0 + const retryHandler = () => { + if (retries >= retryLimit) { + return false + } else { + retries += 1 + return true + } + } + + try { + await fetchWithRetry(() => { throw new Error("unimplemented") }, "empty_url", {}, retryHandler) + } catch { + // Throw away any errors we receive, we are only interested in ensuring the retry handler + // gets called and that retries terminate + } + + expect(retries).toEqual(1) + }); + + it("rethrows error when handler returns false", async () => { + const retryHandler = () => false + + try { + await fetchWithRetry(() => { throw new Error("unimplemented") }, "empty_url", {}, retryHandler) + throw new Error("Unreachable. This is a bug") + } catch (err: any) { + expect(err.message).toEqual("unimplemented") + } + }); +}); + describe("handleResponse", () => { it("handles success", async () => { const { response, ...rest } = await handleResponse(json({ abc: 123 })); diff --git a/oxide-openapi-gen-ts/src/client/static/http-client.ts b/oxide-openapi-gen-ts/src/client/static/http-client.ts index 4cf3a72..9604930 100644 --- a/oxide-openapi-gen-ts/src/client/static/http-client.ts +++ b/oxide-openapi-gen-ts/src/client/static/http-client.ts @@ -117,6 +117,9 @@ export interface FullParams extends FetchParams { method?: string; } +export type RetryHandler = (err: any) => boolean; +export type RetryHandlerFactory = (url: RequestInfo | URL, init: RequestInit) => RetryHandler; + export interface ApiConfig { /** * No host means requests will be sent to the current host. This is used in @@ -125,14 +128,16 @@ export interface ApiConfig { host?: string; token?: string; baseParams?: FetchParams; + retryHandler?: RetryHandlerFactory; } export class HttpClient { host: string; token?: string; baseParams: FetchParams; + retryHandler: RetryHandlerFactory; - constructor({ host = "", baseParams = {}, token }: ApiConfig = {}) { + constructor({ host = "", baseParams = {}, token, retryHandler }: ApiConfig = {}) { this.host = host; this.token = token; @@ -141,6 +146,7 @@ export class HttpClient { headers.append("Authorization", `Bearer ${token}`); } this.baseParams = mergeParams({ headers }, baseParams); + this.retryHandler = retryHandler ? retryHandler : () => () => false; } public async request({ @@ -155,7 +161,19 @@ export class HttpClient { ...mergeParams(this.baseParams, fetchParams), body: JSON.stringify(snakeify(body), replacer), }; + return fetchWithRetry(fetch, url, init, this.retryHandler(url, init)) + } +} + +export async function fetchWithRetry(fetch: any, url: string, init: RequestInit, retry: RetryHandler): Promise> { + try { return handleResponse(await fetch(url, init)); + } catch (err) { + if (retry(err)) { + return await fetchWithRetry(fetch, url, init, retry) + } + + throw err } }