From 916e3eba72b48f5de7193717baa8bb682c2d5ebf Mon Sep 17 00:00:00 2001 From: Roman Gafurov Date: Sun, 4 Aug 2024 16:43:45 +0400 Subject: [PATCH] Refactor client API (#61) * chore(lint): do not track unused vars in errors interfaces * fix(manager): save last token refresh result after retries * fix(manager): save last get token result after retries * chore(manager): store access token expiration timestamp instead of date * refactor(manager): simplified error handling with retries * refactor(http): separate auth management from client * fix(managers): do not create http client per each manager * refactor(http): do not redefine static headers for each request --- src/interfaces/Config.ts | 2 +- src/interfaces/Errors.ts | 1 + src/lib/Manager.ts | 11 +- src/lib/SpotifyAPI.ts | 27 ++- src/lib/http/AuthManager.ts | 163 +++++++++++++ src/lib/http/HttpManager.ts | 448 +++++++++++++----------------------- 6 files changed, 347 insertions(+), 305 deletions(-) create mode 100644 src/lib/http/AuthManager.ts diff --git a/src/interfaces/Config.ts b/src/interfaces/Config.ts index 34332db..190827d 100644 --- a/src/interfaces/Config.ts +++ b/src/interfaces/Config.ts @@ -61,5 +61,5 @@ export interface SpotifyConfig { } export interface PrivateConfig { - tokenExpire?: Date; + tokenExpireAt?: number; } diff --git a/src/interfaces/Errors.ts b/src/interfaces/Errors.ts index c1580a3..792acce 100644 --- a/src/interfaces/Errors.ts +++ b/src/interfaces/Errors.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ export class AuthError extends Error { data: Record; name = AuthError.name; diff --git a/src/lib/Manager.ts b/src/lib/Manager.ts index 176ea44..70823dc 100644 --- a/src/lib/Manager.ts +++ b/src/lib/Manager.ts @@ -1,11 +1,6 @@ -import { PrivateConfig, SpotifyConfig } from '../interfaces/Config'; import { HttpClient } from './http/HttpManager'; -export class Manager { - protected http = new HttpClient(this.config, this.privateConfig); - - constructor( - protected config: SpotifyConfig, - protected privateConfig: PrivateConfig - ) {} +export abstract class Manager { + // eslint-disable-next-line no-unused-vars + constructor(protected readonly http: HttpClient) {} } diff --git a/src/lib/SpotifyAPI.ts b/src/lib/SpotifyAPI.ts index ac6947b..3bc95ab 100644 --- a/src/lib/SpotifyAPI.ts +++ b/src/lib/SpotifyAPI.ts @@ -2,6 +2,7 @@ import { PrivateConfig, SpotifyConfig } from '../interfaces/Config'; import { AlbumManager } from './album/AlbumManager'; import { ArtistManager } from './artist/ArtistManager'; import { AudioManager } from './audio/AudioManager'; +import { HttpClient } from './http/HttpManager'; import { MeManager } from './me/MeManager'; import { PlaylistManager } from './playlist/PlaylistManager'; import { RecommendationsManager } from './recommendations/RecommendationsManager'; @@ -30,11 +31,7 @@ export class SpotifyAPI { private privateConfig: PrivateConfig = {}; - config: SpotifyConfig; - - constructor(config: SpotifyConfig) { - this.config = config; - + constructor(public config: SpotifyConfig) { // TODO: remove for v2 // eslint-disable-next-line deprecation/deprecation if (!this.config.accessToken && config.acccessToken) { @@ -42,14 +39,16 @@ export class SpotifyAPI { this.config.accessToken = config.acccessToken; } - this.tracks = new TrackManager(this.config, this.privateConfig); - this.albums = new AlbumManager(this.config, this.privateConfig); - this.artists = new ArtistManager(this.config, this.privateConfig); - this.users = new UserManager(this.config, this.privateConfig); - this.me = new MeManager(this.config, this.privateConfig); - this.search = new SearchManager(this.config, this.privateConfig); - this.recommendations = new RecommendationsManager(this.config, this.privateConfig); - this.audio = new AudioManager(this.config, this.privateConfig); - this.playlist = new PlaylistManager(this.config, this.privateConfig); + const client = new HttpClient(this.config, this.privateConfig); + + this.tracks = new TrackManager(client); + this.albums = new AlbumManager(client); + this.artists = new ArtistManager(client); + this.users = new UserManager(client); + this.me = new MeManager(client); + this.search = new SearchManager(client); + this.recommendations = new RecommendationsManager(client); + this.audio = new AudioManager(client); + this.playlist = new PlaylistManager(client); } } diff --git a/src/lib/http/AuthManager.ts b/src/lib/http/AuthManager.ts new file mode 100644 index 0000000..334743d --- /dev/null +++ b/src/lib/http/AuthManager.ts @@ -0,0 +1,163 @@ +/* eslint-disable no-console */ +import axios, { AxiosInstance } from 'axios'; +import { AuthError } from '../../interfaces/Errors'; +import { PrivateConfig, SpotifyConfig } from '../../interfaces/Config'; + +const accountsApiUrl = 'https://accounts.spotify.com/api'; + +const accessTokenExpireTTL = 60 * 60 * 1_000; // 1hour + +export class AuthManager { + protected client: AxiosInstance; + + constructor( + // eslint-disable-next-line no-unused-vars + protected config: SpotifyConfig, + // eslint-disable-next-line no-unused-vars + protected privateConfig: PrivateConfig + ) { + this.client = axios.create({ + baseURL: accountsApiUrl, + auth: { + username: this.config.clientCredentials?.clientId, + password: this.config.clientCredentials?.clientSecret + }, + validateStatus: () => true + }); + } + + /** + * @description Get a refresh token. + * @param {number} retryAttempt Number of of retries. + * @returns {string} Returns the refresh token. + */ + private async refreshToken(retryAttempt: number): Promise { + if ( + !this.config.clientCredentials.clientId || + !this.config.clientCredentials.clientSecret || + !this.config.refreshToken + ) { + throw new AuthError( + 'Missing information needed to refresh token, required: client id, client secret, refresh token' + ); + } + + const response = await this.client.post( + '/token', + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.config.refreshToken + }) + ); + + const { status: statusCode } = response; + + if (statusCode === 200) { + return response.data.access_token; + } + + if (statusCode === 400) { + throw new AuthError('Failed to refresh token: bad request', { + data: response.data + }); + } + + if (retryAttempt >= 5) { + if (statusCode >= 500 && statusCode < 600) { + throw new AuthError(`Failed to refresh token: server error (${statusCode})`); + } + + throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`); + } + + if (this.config.debug) { + console.log( + `Failed to refresh token: got (${statusCode}) response. Retrying... (${retryAttempt + 1})` + ); + } + + return await this.refreshToken(retryAttempt + 1); + } + + /** + * Get authorization token with client credentials flow. + * @param {number} retryAttempt Number of of retries. + * @returns {string} Returns the authorization token. + */ + private async requestToken(retryAttempt: number): Promise { + const response = await this.client.post( + '/token', + new URLSearchParams({ + grant_type: 'client_credentials' + }) + ); + + const { status: statusCode } = response; + + if (statusCode === 200) { + return response.data.access_token; + } + + if (statusCode === 400) { + throw new AuthError(`Failed to get token: bad request`, { + data: response.data + }); + } + + if (retryAttempt >= 5) { + if (statusCode >= 500 && statusCode < 600) { + throw new AuthError(`Failed to get token: server error (${statusCode})`); + } + + throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`); + } + + if (typeof this.config.debug === 'boolean' && this.config.debug === true) { + console.log( + `Failed to get token: got (${statusCode}) response. retrying... (${retryAttempt + 1})` + ); + } + + return await this.requestToken(retryAttempt + 1); + } + + /** + * @description Handles the auth tokens. + * @returns {string} Returns a auth token. + */ + async getToken(): Promise { + if (this.config.accessToken) { + // check if token is expired + if (Date.now() < this.privateConfig.tokenExpireAt) { + // return already defined access token + return this.config.accessToken; + } + } + + // refresh token + if ( + this.config.clientCredentials?.clientId && + this.config.clientCredentials?.clientSecret && + this.config.refreshToken + ) { + const accessToken = await this.refreshToken(1); + + this.config.accessToken = accessToken; + this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL; + + return accessToken; + } + + // add credentials flow + if (this.config.clientCredentials?.clientId && this.config.clientCredentials?.clientSecret) { + const accessToken = await this.requestToken(1); + + this.config.accessToken = accessToken; + this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL; + + return accessToken; + } + + throw new AuthError('auth failed: missing information to handle auth'); + } +} diff --git a/src/lib/http/HttpManager.ts b/src/lib/http/HttpManager.ts index 22eff56..731609c 100644 --- a/src/lib/http/HttpManager.ts +++ b/src/lib/http/HttpManager.ts @@ -2,13 +2,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + InternalAxiosRequestConfig +} from 'axios'; import { URL, URLSearchParams } from 'url'; import * as https from 'https'; import { ClientRequest } from 'http'; import axiosBetterStacktrace from 'axios-better-stacktrace'; import { - AuthError, BadRequestError, ForbiddenError, NotFoundError, @@ -18,21 +23,25 @@ import { } from '../../interfaces/Errors'; import { PrivateConfig, SpotifyConfig } from '../../interfaces/Config'; import { sleep } from '../../util/sleep'; +import { AuthManager } from './AuthManager'; + +type ConfigWithRetry = InternalAxiosRequestConfig & { retryAttempt?: number }; export class HttpClient { protected baseURL = 'https://api.spotify.com'; - protected tokenURL = 'https://accounts.spotify.com/api/token'; - - protected client = this.create({ resInterceptor: true }); + protected auth: AuthManager; + protected client = this.createClient(); constructor( protected config: SpotifyConfig, - protected privateConfig: PrivateConfig + privateConfig: PrivateConfig ) { if (config.http?.baseURL) { this.baseURL = config.http.baseURL; } + + this.auth = new AuthManager(config, privateConfig); } /** @@ -42,162 +51,25 @@ export class HttpClient { */ getURL(slug: string, query?: Record): string { const url = new URL(this.baseURL); + url.pathname = slug; url.search = new URLSearchParams(query).toString(); return url.toString(); } - /** - * @description Get a refresh token. - * @param {number} retryAmount The amount of retries. - * @returns {string} Returns the refresh token. - */ - private async refreshToken(retryAmount = 0): Promise { - if ( - !this.config.clientCredentials.clientId || - !this.config.clientCredentials.clientSecret || - !this.config.refreshToken - ) { - throw new AuthError( - 'Missing information needed to refresh token, required: client id, client secret, refresh token' - ); - } - - const res = await axios.post( - this.tokenURL, - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: this.config.refreshToken - }), - { - headers: { - authorization: `Basic ${Buffer.from( - `${this.config.clientCredentials.clientId}:${this.config.clientCredentials.clientSecret}` - ).toString('base64')}` - }, - validateStatus: () => true - } - ); - - if (res.status !== 200) { - if (res.status === 400) { - throw new AuthError(`Refreshing token failed: Bad request`, { data: res.data }); - } - - if (res.status >= 500 && res.status < 600) { - throw new AuthError(`Refreshing token failed: server error (${res.status})`); - } - - if (retryAmount < 5) { - if (this.config.debug) { - console.log(`Refreshing token failed (${res.status}). Retrying... (${retryAmount + 1})`); - } - await this.refreshToken(retryAmount + 1); - } else { - throw new AuthError(`Refreshing token failed (${res.status})`); - } - } - - this.config.accessToken = res.data.access_token; - - // save expire now - this.privateConfig.tokenExpire = new Date( - new Date().setSeconds(new Date().getSeconds() + 3600) - ); - - return this.config.accessToken; - } - - /** - * Get authorization token with client credentials flow. - * @param {number} retryAmount The amount of retries. - * @returns {string} Returns the authorization token. - */ - private async getToken(retryAmount = 0): Promise { - const res = await axios.post( - this.tokenURL, - new URLSearchParams({ - grant_type: 'client_credentials' - }), - { - headers: { - authorization: `Basic ${Buffer.from( - `${this.config.clientCredentials.clientId}:${this.config.clientCredentials.clientSecret}` - ).toString('base64')}` - }, - validateStatus: () => true - } - ); - - // error handling - if (res.status !== 200) { - if (res.status === 400) { - throw new AuthError( - `getting token failed: bad request\n${JSON.stringify(res.data, null, ' ')}` - ); - } - - if (retryAmount < 5) { - if (typeof this.config.debug === 'boolean' && this.config.debug === true) { - console.log(`getting token failed (${res.status}). retrying... (${retryAmount + 1})`); - } - await this.getToken(retryAmount + 1); - } else if (res.status < 600 && res.status >= 500) { - throw new AuthError(`getting token failed: server error (${res.status})`); - } else { - throw new AuthError(`getting token failed (${res.status})`); - } - } - - this.config.accessToken = res.data.access_token; - - this.privateConfig.tokenExpire = new Date( - new Date().setSeconds(new Date().getSeconds() + 3600) - ); - - return this.config.accessToken; - } - - /** - * @description Handles the auth tokens. - * @returns {string} Returns a auth token. - */ - private async handleAuth(): Promise { - if (this.config.accessToken) { - // check if token is expired - if (new Date() >= this.privateConfig.tokenExpire) { - this.config.accessToken = undefined; - return await this.handleAuth(); - } - - // return already defined access token - return this.config.accessToken; - } - - // refresh token - if ( - this.config?.clientCredentials?.clientId && - this.config?.clientCredentials?.clientSecret && - this.config?.refreshToken - ) { - return await this.refreshToken(); // refresh token - } - - // add credentials flow - if (this.config?.clientCredentials?.clientId && this.config?.clientCredentials?.clientSecret) { - return await this.getToken(); - } - - throw new AuthError('auth failed: missing information to handle auth'); - } - /** * Create an axios instance, set interceptors, handle errors & auth. */ - private create(options: { resInterceptor?: boolean }): AxiosInstance { + private createClient(): AxiosInstance { const config: AxiosRequestConfig = { - proxy: this.config.http?.proxy + proxy: this.config.http?.proxy, + headers: { + ...this.config.http?.headers, + 'User-Agent': + this.config.http?.userAgent ?? `@statsfm/spotify.js https://github.com/statsfm/spotify.js` + }, + validateStatus: (status) => status >= 200 && status < 300 }; if (this.config.http?.localAddress) { @@ -214,177 +86,189 @@ export class HttpClient { ) }; } + const client = axios.create(config); + axiosBetterStacktrace(client); // request interceptor client.interceptors.request.use(async (config) => { - config.headers.Authorization = `Bearer ${await this.handleAuth()}`; - config.headers['User-Agent'] = - this.config.http?.userAgent ?? `@statsfm/spotify.js https://github.com/statsfm/spotify.js`; - config.headers = Object.assign(this.config.http?.headers ?? {}, config.headers); + const accessToken = await this.auth.getToken(); + + config.headers.Authorization = `Bearer ${accessToken}`; return config; }); - if (options.resInterceptor || options.resInterceptor === undefined) { - // Response interceptor - client.interceptors.response.use((config) => config, this.errorHandler.bind(this, client)); - } + // error handling interceptor + client.interceptors.response.use( + (response) => response, + (err: unknown) => this.handleError(client, err) + ); return client; } - private async errorHandler( - client: AxiosInstance, - err: AxiosError> - ): Promise { - if (!axios.isAxiosError(err) || !err.response) { - throw err; + private async handleError(client: AxiosInstance, err: unknown): Promise { + if (axios.isCancel(err) || axios.isAxiosError(err) === false || !this.shouldRetryRequest(err)) { + return await Promise.reject(this.extractResponseError(err)); } - const { response } = err; + const requestConfig = err.config as ConfigWithRetry; - const { status: statusCode } = response; + requestConfig.retryAttempt ||= 0; - switch (statusCode) { - case 400: - throw new BadRequestError(err.config.url, { - stack: err.stack, - data: response.data - }); + const isRateLimited = err.response && err.response.status === 429; - case 401: - throw new UnauthorizedError(err.config.url, { - stack: err.stack, - data: response.data - }); + if (isRateLimited) { + if (this.config.logRetry) { + console.log(err.response); + } - case 403: - throw new ForbiddenError(err.config.url, { - stack: err.stack, - data: response.data - }); + const retryAfter = Number(err.response.headers['retry-after']) || 0; - case 404: - throw new NotFoundError(err.config.url, err.stack); + if (this.config.logRetry || this.config.logRetry === undefined) { + console.error( + `Hit ratelimit, retrying in ${retryAfter} second(s), client id: ${this.config.clientCredentials?.clientId ?? 'none'}, path: ${err.request.path}` + ); + } - case 429: - await this.handleRateLimit(response, err); - break; - - default: - if (statusCode >= 500 && statusCode < 600) { - return await this.handle5xxErrors(response, err, statusCode); - } else { - throw err; - } + await sleep(retryAfter * 1_000); + + requestConfig.retryAttempt = 0; + } else { + await sleep(1_000); + + requestConfig.retryAttempt! += 1; + + if (this.config.debug) { + console.log( + `(${requestConfig.retryAttempt}/${this.maxRetryAttempts}) retry ${requestConfig.url} - ${err}` + ); + } } + + return await client.request(requestConfig); } - private async handleRateLimit(res: AxiosResponse, err: AxiosError): Promise { - if (this.config.logRetry) { - console.log(res); + private shouldRetryRequest(err: AxiosError): boolean { + // non-response errors should clarified as 5xx and retried (socket hangup, ECONNRESET, etc.) + if (!err.response) { + if (this.config.retry5xx === false) { + return false; + } + + const { retryAttempt = 0 } = err.config as ConfigWithRetry; + + return retryAttempt < this.maxRetryAttempts; } - if (this.config.retry || this.config.retry === undefined) { - const retryAfter = parseInt(res.headers['retry-after']) || 0; + const { status } = err.response; - if (this.config.logRetry || this.config.logRetry === undefined) { - console.error( - `Hit ratelimit, retrying in ${retryAfter} second(s), client id: ${this.config.clientCredentials?.clientId ?? 'none'}, path: ${err.request.path}` - ); + if (status === 429) { + return this.config.retry !== false; + } + + if (status >= 500 && status < 600) { + if (this.config.retry5xx === false) { + return false; } - await sleep(retryAfter * 1_000); - return await this.client.request(err.config); - } else { - throw new RatelimitError( - `Hit ratelimit, retry after ${res.headers['retry-after']} seconds`, - err.config.url, - { - stack: err.stack, - data: res.data - } - ); + const { retryAttempt = 0 } = err.config as ConfigWithRetry; + + return retryAttempt < this.maxRetryAttempts; } + + return false; } - private async handle5xxErrors( - res: AxiosResponse, - err: AxiosError, - statusCode: number - ): Promise { - if (!this.config.retry5xx && this.config.retry5xx !== undefined) { - throw err; + private extractResponseError(err: unknown): unknown { + if (axios.isCancel(err) || axios.isAxiosError(err) === false) { + return err; } - this.config.retry5xxAmount = this.config.retry5xxAmount || 3; - const nClient = this.create({ resInterceptor: false }); + // non-response errors should clarified as 5xx and retried (socket hangup, ECONNRESET, etc.) + if (!err.response) { + const { retryAttempt = 0 } = err.config as ConfigWithRetry; - for (let i = 1; i <= this.config.retry5xxAmount; i++) { - if (this.config.debug) { - console.log(`(${i}/${this.config.retry5xxAmount}) retry ${err.config.url} - ${statusCode}`); + if (this.config.retry5xx === false || retryAttempt < this.maxRetryAttempts) { + return err; } - await sleep(1_000); + return new RequestRetriesExceededError( + `Request max${this.maxRetryAttempts} retry attempts exceeded`, + err.config.url, + err.stack + ); + } + + const { stack, config, response } = err; + const { status, headers, data } = response; - try { - const nRes = await nClient.request(err.config); - - if (nRes.status >= 200 && nRes.status < 300) { - return nRes; - } - statusCode = nRes.status; - } catch (error) { - if (!axios.isAxiosError(error) || !error.response) { - throw error; - } - - statusCode = error.response.status; - switch (statusCode) { - case 429: - await this.handleRateLimit(error.response, error); - break; - - case 401: - throw new UnauthorizedError(error.config.url, { - stack: error.stack, - data: res.data - }); - - case 403: - throw new ForbiddenError(error.config.url, { - stack: error.stack, - data: res.data - }); - - case 404: - throw new NotFoundError(error.config.url, error.stack); - - default: - if (i === this.config.retry5xxAmount) { - throw new RequestRetriesExceededError( - `Request exceeded ${this.config.retry5xxAmount} number of retry attempts, failed with status code ${statusCode}`, - error.config.url, - error.stack - ); - } - } + if (status >= 500 && status < 600) { + const { retryAttempt } = err.config as ConfigWithRetry; + + if (this.config.retry5xx === false || retryAttempt < this.maxRetryAttempts) { + return err; } + + return new RequestRetriesExceededError( + `Request ${this.maxRetryAttempts} retry attempts exceeded`, + err.config.url, + err.stack + ); + } + + switch (status) { + case 400: + return new BadRequestError(config.url, { + stack, + data + }); + + case 401: + return new UnauthorizedError(config.url, { + stack, + data + }); + + case 403: + return new ForbiddenError(config.url, { + stack, + data + }); + + case 404: + throw new NotFoundError(config.url, stack); + + case 429: + return new RatelimitError( + `Hit ratelimit, retry after ${headers['retry-after']} seconds`, + err.config.url, + { + stack, + data + } + ); } + + return err; + } + + private get maxRetryAttempts(): number { + return this.config.retry5xxAmount ?? 3; } /** * @param {string} slug The slug to get. - * @param {{query?: Record & AxiosRequestConfig}} options Options. + * @param {{query?: Record & AxiosRequestConfig}} config Config. * @returns {Promise} Returns a promise with the response. */ async get( slug: string, - options?: { query?: Record } & AxiosRequestConfig + config?: { query?: Record } & AxiosRequestConfig ): Promise { - return await this.client.get(this.getURL(slug, options?.query), options); + return await this.client.get(this.getURL(slug, config?.query), config); } /** @@ -395,7 +279,7 @@ export class HttpClient { */ async post( slug: string, - data: any, + data: unknown, config?: { query?: Record } & AxiosRequestConfig ): Promise { return await this.client.post(this.getURL(slug, config?.query), data, config); @@ -409,7 +293,7 @@ export class HttpClient { */ async put( slug: string, - data: any, + data: unknown, config?: { query?: Record } & AxiosRequestConfig ): Promise { return await this.client.put(this.getURL(slug, config?.query), data, config); @@ -417,17 +301,17 @@ export class HttpClient { /** * @param {string} slug The slug to delete. - * @param {any} data Body data. - * @param {{Record & RequestInit}} options Options. + * @param {unknown} data Body data. + * @param {{Record & RequestInit}} config Config. * @returns {Promise} Returns a promise with the response. */ async delete( slug: string, - data: any, - options?: { query?: Record } & AxiosRequestConfig + data: unknown, + config?: { query?: Record } & AxiosRequestConfig ): Promise { - return await this.client.delete(this.getURL(slug, options?.query), { - ...options, + return await this.client.delete(this.getURL(slug, config?.query), { + ...config, data }); }