ts/utils/SessionManager.ts
/* eslint-disable functional/no-let */
/* eslint-disable functional/immutable-data */
import { Mutex } from "async-mutex";
import { FunctionN, Lazy, pipe } from "fp-ts/lib/function";
import * as O from "fp-ts/lib/Option";
import * as E from "fp-ts/lib/Either";
import * as t from "io-ts";
import { IResponseType } from "@pagopa/ts-commons/lib/requests";
import { Millisecond } from "@pagopa/ts-commons/lib/units";
import { delayAsync } from "./timer";
const waitRetry = 8000 as Millisecond;
/**
* Provides the logic for caching and updating a session token by wrapping
* concurrent functions that need the token and may detect expired tokens.
*/
export class SessionManager<T> {
private token?: T;
private isSessionEnabled: boolean = true;
private mutex = new Mutex();
private isRefreshing: boolean = false;
/**
* Critical section:
*
* 1) Request (A) arrives and it needs to update the token, so it enters this
* section.
* 2) While updating the token, another request arrives (B) that also sees the
* token is invalid and enters this section to update the token.
* 3) (B) gets paused up since the mutex is locked by (A).
* 4) (A) finishes updating the token with either a success or a failure.
* 5) (B) gets resumed and could either find a valid token refreshed by (A)
* or a still invalid (undefined) token if (A)'s refresh failed.
* 6) If the refresh failed, one or both (A) and (B) could retry the refresh
* again (depending on the state of their retry policy) and the flow starts
* again from (1)
*/
private exclusiveTokenUpdate = async (forceUpdate = false) => {
this.isRefreshing = this.token === undefined;
await this.mutex.runExclusive(async () => {
if (this.token === undefined || forceUpdate) {
// token is not available, attempt to fetch a new token
try {
this.token = pipe(await this.refreshSession(), O.toUndefined);
} catch {
// token is expired and we can't refresh, clear the current token
// since it's useless.
this.token = undefined;
}
}
this.isRefreshing = false;
});
return this.token;
};
/**
* if enabled is false it invalidates the session token
* and sets a block to avoid to perform any token refreshing and
* requests that need token
*
* if enable is true the block will be removed and token requesting
* will be performed
*/
private setEnabledSession = async (enabled: boolean) => {
await this.mutex.runExclusive(() => {
this.isSessionEnabled = enabled;
if (!this.isSessionEnabled) {
this.token = undefined;
}
});
};
/**
* enable/disable to perform action that need token and token refreshing too
*/
public setSessionEnabled = async (enabled: boolean) =>
await this.setEnabledSession(enabled);
constructor(
private refreshSession: () => Promise<O.Option<T>>,
private maxRetries: number = 0
) {}
/**
* Returns the current token, if there's one
*/
public get = () => O.fromNullable(this.token);
/**
* Returns a new token
*/
public getNewToken = async (): Promise<O.Option<T>> => {
let count = 0;
while (count <= this.maxRetries) {
count += 1;
await this.exclusiveTokenUpdate(true);
if (this.token === undefined) {
await delayAsync(waitRetry);
continue;
}
return O.fromNullable(this.token);
}
return O.none;
};
/**
* Returns a new function, with the same params of the provided function but
* the first one, the token, that gets provided by the internal logic.
*/
public withRefresh<R>(
f: FunctionN<[T], Promise<t.Validation<IResponseType<401, any> | R>>>
): Lazy<ReturnType<typeof f>> {
return async () => {
let count = 0;
while (count <= this.maxRetries) {
if (!this.isSessionEnabled) {
throw new Error(
"cant perform any requests cause the session is not enabled"
);
}
count += 1;
// FIXME remove this condition after cashback emergency https://www.pivotaltracker.com/story/show/176051000
// this ensures that only a request at time could be done (if token is undefined)
// ex: if a request is running (waiting for acquiring session token) no other requests will be processed
// all other requests will fail
if (this.isRefreshing) {
throw new Error("max-retries");
}
await this.exclusiveTokenUpdate();
if (this.token === undefined) {
// if the token is still undefined, the refresh failed, try again
// with a random delay to prevent the dogpile effect
await delayAsync(waitRetry);
// TODO: add customizable retry/backoff policy (https://www.pivotaltracker.com/story/show/170819459)
continue;
}
const response = await f(this.token);
// BEWARE: we can cast to any only because we know for sure that f will
// always return a Promise<IResponseType<A, B>>
if (E.isRight(response) && (response.right as any).status === 401) {
// our token is expired, reset it
this.token = undefined;
continue;
}
return response;
}
// max retries reached, reject the promise
throw new Error("max-retries");
};
}
}