Files
ente/web/packages/base/http.ts
Manav Rathi c9175bee04 Sketch
2025-07-01 13:27:35 +05:30

315 lines
11 KiB
TypeScript

import { desktopAppVersion, isDesktop } from "ente-base/app";
import { wait } from "ente-utils/promise";
import { z } from "zod/v4";
import { clientPackageName } from "./app";
import log from "./log";
import { ensureAuthToken } from "./token";
/**
* Return headers that should be passed alongwith (almost) all authenticated
* `fetch` calls that we make to our API servers.
*
* - The auth token
* - The client package name.
*/
export const authenticatedRequestHeaders = async () => ({
"X-Auth-Token": await ensureAuthToken(),
"X-Client-Package": clientPackageName,
...(isDesktop && { "X-Client-Version": desktopAppVersion }),
});
/**
* Return headers that should be passed alongwith (almost) all unauthenticated
* `fetch` calls that we make to our remotes like our API servers (museum), or
* to pre-signed URLs that are handled by the S3 storage buckets themselves.
*
* - The client package name.
*/
export const publicRequestHeaders = () => ({
"X-Client-Package": clientPackageName,
...(isDesktop && { "X-Client-Version": desktopAppVersion }),
});
/**
* A set of credentials needed to make public collections related API requests.
*/
export interface PublicAlbumsCredentials {
/**
* [Note: Public album access token]
*
* The public album access is a token that serves a similar purpose as the
* "X-Auth-Token" for usual authenticated API requests that happen for a
* logged in user, except:
*
* - It will be passed as the "X-Auth-Access-Token" header, and
* - It also tells remote about the public album under consideration.
*
* This access token is variously referred to as the album token, or the
* auth token, when the context is clear. The client obtains this from the
* "t" query parameter of a public album URL, and then uses it both to:
*
* 1. Identify and authenticate itself with remote (this header).
*
* 2. Scope local storage per public album by using this access token as a
* part of the local storage key. In this context it is sometimes also
* referred to as a "collectionUID" by old code.
*/
accessToken: string;
/**
* [Note: Password token for public albums requests].
*
* A password protected access token. This is only needed for albums that
* are behind a password. In such cases, the client needs to fetch this
* extra token from remote (in exchange for the public album's password),
* and then pass it as the "X-Auth-Access-Token-JWT" header in authenticated
* public collections related API requests.
*/
accessTokenJWT?: string | undefined;
}
/**
* Return headers that should be passed alongwith public collection related
* authenticated `fetch` calls that we make to our API servers.
*
* - The auth token.
* - The password protected auth token (if provided).
* - The client package name.
*/
export const authenticatedPublicAlbumsRequestHeaders = ({
accessToken,
accessTokenJWT,
}: PublicAlbumsCredentials) => ({
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && { "X-Auth-Access-Token-JWT": accessTokenJWT }),
"X-Client-Package": clientPackageName,
});
/**
* A custom Error that is thrown if a fetch fails with a non-2xx HTTP status.
*/
export class HTTPError extends Error {
res: Response;
details: Record<string, string>;
constructor(res: Response) {
// Trim off any query parameters from the URL before logging, it may
// have tokens.
//
// Nb: res.url is URL obtained after any redirects, and thus is not
// necessarily the same as the request's URL.
const url = new URL(res.url);
url.search = "";
super(`HTTP ${res.status} ${res.statusText} (${url.pathname})`);
const requestID = res.headers.get("x-request-id");
const details = { url: url.href, ...(requestID && { requestID }) };
// Cargo culted from
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (Error.captureStackTrace) Error.captureStackTrace(this, HTTPError);
this.name = this.constructor.name;
this.res = res;
this.details = details;
}
}
/**
* A convenience method that throws an {@link HTTPError} if the given
* {@link Response} does not have a HTTP 2xx status.
*/
export const ensureOk = (res: Response) => {
if (!res.ok) {
const e = new HTTPError(res);
log.error(`${e.message} ${JSON.stringify(e.details)}`);
throw e;
}
};
/**
* Return true if this is a HTTP error with the given {@link httpStatus}.
*/
export const isHTTPErrorWithStatus = (e: unknown, httpStatus: number) =>
e instanceof HTTPError && e.res.status == httpStatus;
/**
* Return true if this is a HTTP "client" error.
*
* This is a convenience matcher to check if {@link e} is an instance of
* {@link HTTPError} with a 4xx status code. Such errors are client errors, and
* (generally) retrying them will not help.
*/
export const isHTTP4xxError = (e: unknown) =>
e instanceof HTTPError && e.res.status >= 400 && e.res.status <= 499;
/**
* Return true if this is a HTTP 401 error.
*
* For authenticated requests, an HTTP "401 Unauthorized" indicates that the
* credentials (auth token) is not valid.
*/
export const isHTTP401Error = (e: unknown) =>
e instanceof HTTPError && e.res.status == 401;
/**
* Return `true` if this is an error because of a HTTP failure response returned
* by museum with the given "code" and HTTP status.
*
* > The function is async because it needs to parse the payload.
*
* For some known set of errors, museum returns a payload of the form
*
* {"code":"USER_NOT_REGISTERED","message":"User is not registered"}
*
* where the code can be used to match a specific reason for the HTTP request
* failing. This function can be used as a predicate to check both the HTTP
* status code and the "code" within the payload.
*/
export const isMuseumHTTPError = async (
e: unknown,
httpStatus: number,
code: string,
) => {
if (e instanceof HTTPError && e.res.status == httpStatus) {
try {
const payload = z
.object({ code: z.string() })
.parse(await e.res.json());
return payload.code == code;
} catch (e) {
log.warn("Ignoring error when parsing error payload", e);
return false;
}
}
return false;
};
interface RetryAsyncOperationOpts {
/**
* An optional modification to the default wait times between retries.
*
* - default 1 orig + 3 retries (2s, 5s, 10s)
* - "background" 1 orig + 3 retries (5s, 25s, 120s)
*
* default is fine for most operations, including interactive ones where we
* don't want to wait too long before giving up. "background" is suitable
* for non-interactive operations where we can wait for longer (thus better
* handle remote hiccups) without degrading the user's experience.
*/
retryProfile?: "background";
/**
* An optional function that is called with the corresponding error whenever
* {@link op} rejects. It should throw the error if the retries should
* immediately be aborted.
*/
abortIfNeeded?: (error: unknown) => void;
}
/**
* Retry a async operation on failure up to 4 times (1 original + 3 retries)
* with exponential backoff.
*
* [Note: Retries of network requests should be idempotent]
*
* When dealing with network requests, avoid using this function directly, use
* one of its wrappers like {@link retryEnsuringHTTPOk} instead. Those wrappers
* ultimately use this function only, and there is nothing wrong with this
* function generally, however since this function allows retrying arbitrary
* promises, it is easy accidentally try and attempt retries of non-idemponent
* requests, while the more restricted API of {@link retryEnsuringHTTPOk} and
* other {@link HTTPRequestRetrier}s makes such misuse less likely.
*
* @param op A function that performs the operation, returning the promise for
* its completion.
*
* @param opts Optional tweaks to the default implementation. See
* {@link RetryAsyncOperationOpts}.
*
* @returns A promise that fulfills with to the result of a first successfully
* fulfilled promise of the 4 (1 + 3) attempts, or rejects with the error
* obtained either when {@link abortIfNeeded} throws, or with the error from the
* last attempt otherwise.
*/
export const retryAsyncOperation = async <T>(
op: () => Promise<T>,
opts?: RetryAsyncOperationOpts,
): Promise<T> => {
const { retryProfile, abortIfNeeded } = opts ?? {};
const waitTimeBeforeNextTry =
retryProfile == "background"
? [10000, 30000, 120000]
: [2000, 5000, 10000];
while (true) {
try {
return await op();
} catch (e) {
if (abortIfNeeded) {
abortIfNeeded(e);
}
const t = waitTimeBeforeNextTry.shift();
if (!t) throw e;
log.warn("Will retry potentially transient request failure", e);
await wait(t);
}
}
};
/**
* A function that wraps the request(s) in retries if needed.
*
* See {@link retryEnsuringHTTPOk} for the canonical example. This typedef is to
* allow us to talk about and pass functions that behave similar to
* {@link retryEnsuringHTTPOk}, but perhaps with other additional checks.
*
* See also: [Note: Retries of network requests should be idempotent]
*/
export type HTTPRequestRetrier = (
request: () => Promise<Response>,
opts?: HTTPRequestRetrierOpts,
) => Promise<Response>;
type HTTPRequestRetrierOpts = Pick<RetryAsyncOperationOpts, "retryProfile">;
/**
* A helper function to adapt {@link retryAsyncOperation} for HTTP fetches.
*
* This extends {@link retryAsyncOperation} by treating any non-200 OK status
* (as matched by {@link ensureOk}) as an error that should be retried.
*/
export const retryEnsuringHTTPOk: HTTPRequestRetrier = (
request: () => Promise<Response>,
opts?: HTTPRequestRetrierOpts,
) =>
retryAsyncOperation(async () => {
const r = await request();
ensureOk(r);
return r;
}, opts);
/**
* A helper function to adapt {@link retryAsyncOperation} for HTTP fetches, but
* treating any 4xx HTTP responses as irrecoverable failures.
*
* This is similar to {@link retryEnsuringHTTPOk}, except it stops retrying if
* remote responds with a 4xx HTTP status.
*/
export const retryEnsuringHTTPOkOr4xx: HTTPRequestRetrier = (
request: () => Promise<Response>,
opts?: HTTPRequestRetrierOpts,
) =>
retryAsyncOperation(
async () => {
const r = await request();
ensureOk(r);
return r;
},
{
...opts,
abortIfNeeded(e) {
if (isHTTP4xxError(e)) throw e;
},
},
);