# HTTP Client ## 🏗 Base HTTP client (`@autofidev/base-http-client`) ### Library Dependencies - `axios` (HTTP layer) - We chose to go with Axios because of its feature rich functionality - We compared it with `node-fetch` and `undici` for performance improvements but we didn't see significant difference. - `axios-cache-interceptor` (Configurable Caching) - [Redis Cache Storage](https://axios-cache-interceptor.js.org/#/pages/storages?id=creating-your-own-storage) - The `axios-cache-interceptor` library has thorough documentation on how to configure caching and also an example Redis Cache Storage implementation - `mollitia` (Resilience) - Circuit breaking, timeouts and retries. ### Features - Cache - Configurable Storage (in-memory, Redis, MongoDB, etc) - Default 5 minute ttl - Resilience - retries (default: `3`) - exponential back off optional and enabled by default. Otherwise `back off time = timeout` (`1s` default) - timeout (default: `1s`) - Keep alive - Removes TCP overhead and improves performance by reusing the same connection for the same endpoint. Sample testing revealed 46% improvement. ### Options The following options can be passed when **instantiating** a class that implements the `BaseHttpClient`. | Option | Required | Description | Default Value | | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | ---------------- | | cacheSettings | No | `axios-cache-interceptor` cache settings (`CacheOptions`) | - | | exponentialBackoff | No | Whether to use exponential backoff for retries or not. | true | | keepAlive | No | This will allow reusing connections when making subsequent calls to the same host and remove the TCP overhead | true | | requestId | No | x-request-id header for each sent request, helpful for tracing a call across multiple services. | Random UUID | | defaultParams | No | Default Query Params to be used for all requests | - | | logger | No | The logger to be used by the API Client | - | | name | No | Name of the API client instance (used for setting mollitia) | - | | rejectAndRetryAfter | No | Reject request and retry it after the specified delay in milliseconds | 1000 ms | | retryAttempts | No | Retry attempts | 3 | ### Code ```javascript export abstract class BaseHttpClient { protected constructor(options: BaseHttpClientOptions) { // Keep alive is turned on by default this.keepAlive = options.keepAlive ?? true; this.httpAgent = new http.Agent({ keepAlive: this.keepAlive }); this.httpsAgent = new https.Agent({ keepAlive: this.keepAlive }); this.retryAttempts = options.retryAttempts ?? this.DEFAULT_RETRY_ATTEMPTS; this.rejectAndRetryAfter = options.rejectAndRetryAfter ?? this.DEFAULT_REJECT_AND_RETRY_AFTER_IN_MS; this.axios = this.prepareAxios(options); this.retryWithTimeoutCircuit = this.prepareRetryWithTimeoutCircuit(this.retryAttempts, this.rejectAndRetryAfter); this.logger = options.logger; this.instanceName = options.name; } private prepareAxios(options: BaseHttpClientOptions) { // Create and config axios client } private prepareRetryWithTimeoutCircuit = (retryAttempts: number, rejectAndRetryAfter: number): Circuit => { // Creates a Mollitia Retry and Timeout Circuit }; /** * composeBaseUrl can be used if base url needs to be dynamic. * It will be invoked before each request so use with care. */ protected composeBaseUrl?(): Promise<string> | string; /** * willSendRequest can be used to change the request options dynamically */ protected willSendRequest?(options: AxiosRequestConfig): Promise<AxiosRequestConfig> | AxiosRequestConfig; protected post<TResult = any>(path: string, body?: any, options?: HttpClientRequestConfig) {} protected get<TResult = any>(path: string, options?: HttpClientRequestConfig){} protected patch<TResult = any>(path: string, body?: any, options?: HttpClientRequestConfig){} protected delete<TResult = any>(path: string, body?: any, options?: HttpClientRequestConfig){} private async makeRequest<TResult>(options: HttpClientRequestConfig): Promise<CacheAxiosResponse<TResult>> { if (!this.composeBaseUrl && !options.baseURL) { throw new Error('Expected either baseUrl or composeBaseUrl to be defined'); } let { baseURL } = options; if (this.composeBaseUrl && !baseURL) { baseURL = await this.composeBaseUrl(); } if (this.willSendRequest) { this.axios.interceptors.request.use(this.willSendRequest); } return this.retryWithTimeoutCircuit.fn(this.axios.request).execute({ ...options, headers: { ...options.headers, // Set request id in headers for all requests 'x-request-id': options?.requestId, }, baseURL: options.baseURL ?? baseURL, }); } } ``` ## 💻 API Client To integrate with a service, an API Client that extends the `BaseHttpClient` should be created and placed in a new package. The `api-client` nx generator can be used to scaffold a generic API client. The concerns of this Client will be: - **Endpoints**: Exposing the public facing endpoints through different methods. - **Auth**: Setting the correct token in the header(through `willSendRequest`). - **Types**: Defining the Input and Output types of the service. ### Example: Estimate API Client (`@autofidev/estimate-api-client`) ```javascript export class EstimateApiClient extends BaseHttpClient { constructor(options: Options) { super({ ...options, name: 'EstimateApiClient' }); } // used to change the request options dynamically protected willSendRequest(options: HttpClientRequestConfig): HttpClientRequestConfig { return { ...options, headers: { ...options.headers, Authorization: `Bearer ${createServiceToken()}`, }, }; } public async getOfferMatrix(payload: OfferMatrixInput, options: Options = {}) { const { backendPath = BackendPath.Prod, requestId } = options; const { data: { data }, } = await this.post<OfferMatrixResponse>('/api/v1/offers', payload, { params: { backendPath: backendPath}, baseURL: process.env.SMARTCOW_ENDPOINT requestId, }); return data; } } ``` ## ✍️ Design Considerations and Challenges - Extract shared **API clients** vs Data Sources - Single package with all API clients vs **one package per client**. - **Axios** vs Fetch vs Undici. - base URL **per request** vs on initialization. - Implementation of the API client by **consumer** vs service team. ### Next Steps - ~~Fine tune timeout duration~~ ✅ - ~~Increase for each retry~~ - ~~set defaults from data / SLAs~~ - Request ID prefixing when generating within services. - Override options per request (retries, timeout, etc.). - Other than REST API Client (GraphQL, async, etc). ## ⚙️ Implementation ### Example: Estimate Subgraph - Smartcow DataSource ```javascript interface OfferMatrixOptions { backendPath?: BackendPath; smartCowPr?: number; } export class SmartCowSource extends DataSource<ApolloContext> { private estimateClient!: EstimateApiClient; private context!: ApolloContext; initialize(config: DataSourceConfig<ApolloContext>): void { this.context = config.context; this.estimateClient = new EstimateApiClient({ rejectAndRetryAfter: RuntimeConfiguration.getInstance().estimateClientRetryTimeout, logger: logger.log, }); logger.log.debug(`Initialized estimate data source.`); } async getOfferMatrix(payload: OfferMatrixInput, { backendPath, smartCowPr }: OfferMatrixOptions) { try { return this.estimateClient.getOfferMatrix(payload, { baseURL: RuntimeConfiguration.getInstance().smartcowEndpoint, backendPath, prNumber: smartCowPr, requestId: this.context.reqId ?? randomUUID(), }); } catch (err) { throw new ApolloError(String(err)); } } } ``` ![](https://i.imgur.com/XpWdHFW.png) ### Api Client Generator #### Command ```bash yarn nx workspace-generator api-client [name] [description] ``` #### Example ```shell yarn nx workspace-generator api-client taco "tacos for all" ```