# 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));
}
}
}
```

### 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"
```