Skip to content

Commit

Permalink
HTTP2 support (#153)
Browse files Browse the repository at this point in the history
* proof of concept for using http2 instead of axios

* fix TS build issues

* error handling improvements

* clean up a bunch of dangling references

* clean up all dangling http2 sessions in tests so test process exits after tests pass

* make collections reuse provided http client

* reuse same HTTP client for all databases as well as collections

* refactor out HTTP2 request logic into a separate function

* confirm that Mongoose close() and disconnect() correctly disconnect http2 client

* better typings for http2 internals

* test double closing httpClient

* add switch to enable/disable http2

* properly pass useHTTP2 down to httpClient

* improve error message when making http2 request after closing client

* fix merge conflicts with listCollections

* enable HTTP2 by default
  • Loading branch information
vkarpov15 authored Feb 8, 2024
1 parent 45a116b commit 4d79459
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 67 deletions.
126 changes: 109 additions & 17 deletions src/client/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { inspect } from 'util';
import { LIB_NAME, LIB_VERSION } from '../version';
import { getStargateAccessToken } from '../collections/utils';
import { EJSON } from 'bson';
import http2 from 'http2';

const REQUESTED_WITH = LIB_NAME + '/' + LIB_VERSION;
const DEFAULT_AUTH_HEADER = 'X-Cassandra-Token';
Expand All @@ -45,6 +46,7 @@ interface APIClientOptions {
authUrl?: string;
isAstra?: boolean;
logSkippedOptions?: boolean;
useHTTP2?: boolean;
}

export interface APIResponse {
Expand Down Expand Up @@ -88,6 +90,7 @@ axiosAgent.interceptors.request.use(requestInterceptor);
axiosAgent.interceptors.response.use(responseInterceptor);

export class HTTPClient {
origin: string;
baseUrl: string;
applicationToken: string;
authHeaderName: string;
Expand All @@ -96,9 +99,11 @@ export class HTTPClient {
authUrl: string;
isAstra: boolean;
logSkippedOptions: boolean;
http2Session?: http2.ClientHttp2Session;
closed: boolean;

constructor(options: APIClientOptions) {
// do not support usage in browsers
// do not support usage in browsers
if (typeof window !== 'undefined') {
throw new Error('not for use in a web browser');
}
Expand All @@ -120,6 +125,20 @@ export class HTTPClient {
this.applicationToken = '';//We will set this by accessing the auth url when the first request is received
}

this.closed = false;
this.origin = new URL(this.baseUrl).origin;

const useHTTP2 = options.useHTTP2 == null ? true : !!options.useHTTP2;
if (useHTTP2) {
this.http2Session = http2.connect(this.origin);

// Without these handlers, any errors will end up as uncaught exceptions,
// even if they are handled in `_request()`.
// More info: https://github.com/nodejs/node/issues/16345
this.http2Session.on('error', () => {});
this.http2Session.on('socketError', () => {});
}

if (options.logLevel) {
setLevel(options.logLevel);
}
Expand All @@ -131,6 +150,13 @@ export class HTTPClient {
this.logSkippedOptions = options.logSkippedOptions || false;
}

close() {
if (this.http2Session != null) {
this.http2Session.close();
}
this.closed = true;
}

async _request(requestInfo: AxiosRequestConfig): Promise<APIResponse> {
try {
if (this.applicationToken === '') {
Expand All @@ -156,17 +182,34 @@ export class HTTPClient {
]
};
}
const response = await axiosAgent({
url: requestInfo.url,
data: requestInfo.data,
params: requestInfo.params,
method: requestInfo.method || DEFAULT_METHOD,
timeout: requestInfo.timeout || DEFAULT_TIMEOUT,
headers: {
[this.authHeaderName]: this.applicationToken
}
});
if (response.status === 401 || (response.data?.errors?.length > 0 && response.data.errors[0]?.message === 'UNAUTHENTICATED: Invalid token')) {
if (!requestInfo.url) {
return {
errors: [
{
message: 'URL not specified'
}
]
};
}

const response = this.http2Session != null
? await this.makeHTTP2Request(
requestInfo.url.replace(this.origin, ''),
this.applicationToken,
requestInfo.data
)
: await axiosAgent({
url: requestInfo.url,
data: requestInfo.data,
params: requestInfo.params,
method: requestInfo.method || DEFAULT_METHOD,
timeout: requestInfo.timeout || DEFAULT_TIMEOUT,
headers: {
[this.authHeaderName]: this.applicationToken
}
});

if (response.status === 401 || (response.data?.errors?.length > 0 && response.data?.errors?.[0]?.message === 'UNAUTHENTICATED: Invalid token')) {
logger.debug('@stargate-mongoose/rest: reconnecting');
try {
this.applicationToken = await getStargateAccessToken(this.authUrl, this.username, this.password);
Expand All @@ -183,9 +226,9 @@ export class HTTPClient {
}
if (response.status === 200) {
return {
status: response.data.status,
data: deserialize(response.data.data),
errors: response.data.errors
status: response.data?.status,
data: deserialize(response.data?.data),
errors: response.data?.errors
};
} else {
logger.error(requestInfo.url + ': ' + response.status);
Expand Down Expand Up @@ -214,11 +257,60 @@ export class HTTPClient {
}
}

async executeCommand(data: Record<string, any>, optionsToRetain: Set<string> | null) {
makeHTTP2Request(
path: string,
token: string,
body: Record<string, any>
): Promise<{ status: number, data: Record<string, any> }> {
return new Promise((resolve, reject) => {
// Should never happen, but good to have a readable error just in case
if (this.http2Session == null) {
throw new Error('Cannot make http2 request without session');
}
if (this.closed) {
throw new Error('Cannot make http2 request when client is closed');
}

const req: http2.ClientHttp2Stream = this.http2Session.request({
':path': path,
':method': 'POST',
token
});
req.write(serializeCommand(body), 'utf8');
req.end();

let status = 0;
req.on('response', (data: http2.IncomingHttpStatusHeader) => {
status = data[':status'] ?? 0;
});

req.on('error', (error: Error) => {
reject(error);
});

req.setEncoding('utf8');
let responseBody = '';
req.on('data', (chunk: string) => {
responseBody += chunk;
});
req.on('end', () => {
let data = {};
try {
data = JSON.parse(responseBody);
resolve({ status, data });
} catch (error) {
reject(new Error('Unable to parse response as JSON, got: "' + data + '"'));
return;
}
});
});
}

async executeCommandWithUrl(url: string, data: Record<string, any>, optionsToRetain: Set<string> | null) {
const commandName = Object.keys(data)[0];
cleanupOptions(commandName, data[commandName], optionsToRetain, this.logSkippedOptions);
const response = await this._request({
url: this.baseUrl,
url: this.baseUrl + url,
method: HTTP_METHODS.post,
data
});
Expand Down
24 changes: 20 additions & 4 deletions src/collections/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ export interface ClientOptions {
authUrl?: string;
isAstra?: boolean;
logSkippedOptions?: boolean;
useHTTP2?: boolean;
}

export class Client {
httpClient: HTTPClient;
keyspaceName?: string;
createNamespaceOnConnect?: boolean;
dbs: Map<string, Db>;

constructor(baseUrl: string, keyspaceName: string, options: ClientOptions) {
this.keyspaceName = keyspaceName;
Expand All @@ -56,8 +58,10 @@ export class Client {
password: options.password,
authUrl: options.authUrl,
isAstra: options.isAstra,
logSkippedOptions: options.logSkippedOptions
logSkippedOptions: options.logSkippedOptions,
useHTTP2: options.useHTTP2
});
this.dbs = new Map<string, Db>();
}

/**
Expand All @@ -77,7 +81,8 @@ export class Client {
password: options?.password,
authUrl: options?.authUrl,
isAstra: options?.isAstra,
logSkippedOptions: options?.logSkippedOptions
logSkippedOptions: options?.logSkippedOptions,
useHTTP2: options?.useHTTP2
});
await client.connect();
return client;
Expand All @@ -104,10 +109,20 @@ export class Client {
*/
db(dbName?: string) {
if (dbName) {
return new Db(this.httpClient, dbName);
if (this.dbs.has(dbName)) {
return this.dbs.get(dbName);
}
const db = new Db(this.httpClient, dbName);
this.dbs.set(dbName, db);
return db;
}
if (this.keyspaceName) {
return new Db(this.httpClient, this.keyspaceName);
if (this.dbs.has(this.keyspaceName)) {
return this.dbs.get(this.keyspaceName);
}
const db = new Db(this.httpClient, this.keyspaceName);
this.dbs.set(this.keyspaceName, db);
return db;
}
throw new Error('Database name must be provided');
}
Expand All @@ -126,6 +141,7 @@ export class Client {
* @returns Client
*/
close() {
this.httpClient.close();
return this;
}

Expand Down
Loading

0 comments on commit 4d79459

Please sign in to comment.