pic-js-mops
Version:
An Internet Computer Protocol canister testing library for TypeScript and JavaScript.
228 lines (185 loc) • 6.48 kB
text/typescript
import { ServerRequestTimeoutError } from './error.js';
import { isNil, poll } from './util/index.js';
export interface RequestOptions {
method: Method;
path: string;
headers?: RequestHeaders;
body?: Uint8Array;
}
export type RequestHeaders = RequestInit['headers'];
export interface JsonGetRequest {
path: string;
headers?: RequestHeaders;
}
export interface JsonPostRequest<B> {
path: string;
headers?: RequestHeaders;
body?: B;
}
export type ResponseHeaders = ResponseInit['headers'];
export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
export const JSON_HEADER: RequestHeaders = {
'Content-Type': 'application/json',
};
export class Http2Client {
constructor(
private readonly baseUrl: string,
private readonly processingTimeoutMs: number,
) {}
public request(init: RequestOptions): Promise<Response> {
const timeoutAbortController = new AbortController();
const requestAbortController = new AbortController();
const cancelAfterTimeout = async (): Promise<never> => {
return await new Promise((_, reject) => {
const timeoutId = setTimeout(() => {
requestAbortController.abort();
reject(new ServerRequestTimeoutError());
}, this.processingTimeoutMs);
timeoutAbortController.signal.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new ServerRequestTimeoutError());
});
});
};
const makeRequest = async (): Promise<Response> => {
const url = `${this.baseUrl}${init.path}`;
const res = await fetch(url, {
method: init.method,
headers: init.headers,
body: init.body,
signal: requestAbortController.signal,
});
timeoutAbortController.abort();
return res;
};
return Promise.race([makeRequest(), cancelAfterTimeout()]);
}
public async jsonGet<R extends {}>(init: JsonGetRequest): Promise<R> {
// poll the request until it is successful or times out
return await poll(
async () => {
const res = await this.request({
method: 'GET',
path: init.path,
headers: { ...init.headers, ...JSON_HEADER },
});
const resBody = await getResBody<R>(res);
if (isNil(resBody)) {
return resBody;
}
// server encountered an error, throw and try again
if ('message' in resBody) {
console.error(
'PocketIC server encountered an error',
resBody.message,
);
throw new Error(resBody.message);
}
// the server has started processing or is busy
if ('state_label' in resBody) {
// the server is too busy to process the request, throw and try again
if (res.status === 409) {
throw new Error('Server busy');
}
// the server has started processing the request
// this shouldn't happen for GET requests, throw and try again
if (res.status === 202) {
throw new Error('Server started processing');
}
// something weird happened, throw and try again
throw new Error('Unknown state');
}
// the request was successful, exit the loop
return resBody;
},
{ intervalMs: POLLING_INTERVAL_MS, timeoutMs: this.processingTimeoutMs },
);
}
public async jsonPost<B, R extends {}>(init: JsonPostRequest<B>): Promise<R> {
const reqBody = init.body
? new TextEncoder().encode(JSON.stringify(init.body))
: undefined;
// poll the request until it is successful or times out
return await poll(
async () => {
const res = await this.request({
method: 'POST',
path: init.path,
headers: { ...init.headers, ...JSON_HEADER },
body: reqBody,
});
const resBody = await getResBody<R>(res);
if (isNil(resBody)) {
return resBody;
}
// server encountered an error, throw and try again
if ('message' in resBody) {
console.error(
'PocketIC server encountered an error',
resBody.message,
);
throw new Error(resBody.message);
}
// the server has started processing or is busy
if ('state_label' in resBody) {
// the server is too busy to process the request, throw and try again
if (res.status === 409) {
throw new Error('Server busy');
}
// the server has started processing the request, poll until it is done
if (res.status === 202) {
return await poll(
async () => {
const stateRes = await this.request({
method: 'GET',
path: `/read_graph/${resBody.state_label}/${resBody.op_id}`,
});
const stateBody = (await stateRes.json()) as ApiResponse<R>;
// the server encountered an error, throw and try again
if (
isNil(stateBody) ||
'message' in stateBody ||
'state_label' in stateBody
) {
throw new Error('Polling has not succeeded yet');
}
// the request was successful, exit the loop
return stateBody;
},
{
intervalMs: POLLING_INTERVAL_MS,
timeoutMs: this.processingTimeoutMs,
},
);
}
// something weird happened, throw and try again
throw new Error('Unknown state');
}
// the request was successful, exit the loop
return resBody;
},
{ intervalMs: POLLING_INTERVAL_MS, timeoutMs: this.processingTimeoutMs },
);
}
}
async function getResBody<R extends {}>(
res: Response,
): Promise<ApiResponse<R>> {
try {
return (await res.clone().json()) as ApiResponse<R>;
} catch (error) {
const message = await res.text();
console.error('Error parsing PocketIC server response body:', error);
console.error('Original body:', message);
throw new Error(message);
}
}
const POLLING_INTERVAL_MS = 10;
interface StartedOrBusyApiResponse {
state_label: string;
op_id: string;
}
interface ErrorResponse {
message: string;
}
type ApiResponse<R extends {}> = StartedOrBusyApiResponse | ErrorResponse | R;