feat: standalone rest server (#290)

* feat: standalone rest server

* desc

* fmt

* <3 vlad

* Update src/rest/README.md

Co-authored-by: Ayyan <ayyantee@gmail.com>

* Update src/rest/README.md

Co-authored-by: Ayyan <ayyantee@gmail.com>

* Update README.md

* Update src/rest/deps.ts

Co-authored-by: Ayyan <ayyantee@gmail.com>

* Update src/rest/queue.ts

Co-authored-by: Ayyan <ayyantee@gmail.com>

* chore: ignore no-explicit-any rule

* fix(rest): replace with correct import paths

* deno fmt

* fixes

* fmt

* use user agent cons

* fix typings

* Update src/rest/cache.ts

Co-authored-by: Ayyan <ayyantee@gmail.com>

* Update src/rest/cache.ts

Co-authored-by: Ayyan <ayyantee@gmail.com>

Co-authored-by: Ayyan <ayyantee@gmail.com>
This commit is contained in:
Skillz4Killz
2021-01-09 12:53:14 -05:00
committed by GitHub
parent 27fb44128f
commit cd0347a5e1
22 changed files with 622 additions and 61 deletions

View File

@@ -38,8 +38,8 @@ module.exports = {
nav,
sidebar,
yuu: {
defaultDarkTheme: true
}
defaultDarkTheme: true,
},
},
plugins: [
"@vuepress/plugin-back-to-top",

View File

@@ -1,15 +1,15 @@
module.exports = [
{
text: "Home",
link: "/",
},
{
text: "Docs",
link: "https://doc.deno.land/https/deno.land/x/discordeno/mod.ts",
},
{
text: "Discord",
link: "https://discord.gg/5vBgXk3UcZ",
target: "_blank",
},
]
{
text: "Home",
link: "/",
},
{
text: "Docs",
link: "https://doc.deno.land/https/deno.land/x/discordeno/mod.ts",
},
{
text: "Discord",
link: "https://discord.gg/5vBgXk3UcZ",
target: "_blank",
},
];

View File

@@ -1,39 +1,39 @@
module.exports = {
"/": [
{
title: "Home",
children: [
"/",
"faq",
"gettingstarted",
"djs",
],
},
{
title: "Step By Step Guide",
children: [
"/stepbystep/",
"/stepbystep/createbot",
"/stepbystep/createcommand",
"/stepbystep/createevent",
"/stepbystep/createlanguage",
"/stepbystep/createmonitor",
"/stepbystep/createinhibitor",
"/stepbystep/createtask",
"/stepbystep/hostingbot",
],
},
{
title: "Advanced Guide",
children: [
"/advanced/",
"/advanced/arguments",
"/advanced/customizations",
"/advanced/dockerhosting",
"/advanced/dynamiccommands",
"/advanced/permlevels",
"/advanced/subcommands",
],
},
],
}
"/": [
{
title: "Home",
children: [
"/",
"faq",
"gettingstarted",
"djs",
],
},
{
title: "Step By Step Guide",
children: [
"/stepbystep/",
"/stepbystep/createbot",
"/stepbystep/createcommand",
"/stepbystep/createevent",
"/stepbystep/createlanguage",
"/stepbystep/createmonitor",
"/stepbystep/createinhibitor",
"/stepbystep/createtask",
"/stepbystep/hostingbot",
],
},
{
title: "Advanced Guide",
children: [
"/advanced/",
"/advanced/arguments",
"/advanced/customizations",
"/advanced/dockerhosting",
"/advanced/dynamiccommands",
"/advanced/permlevels",
"/advanced/subcommands",
],
},
],
};

View File

@@ -1,4 +1,4 @@
import { RequestManager } from "../../rest/mod.ts";
import { RequestManager } from "../../rest/request_manager.ts";
import {
ChannelEditOptions,
ChannelTypes,

View File

@@ -1,5 +1,5 @@
import { identifyPayload } from "../../bot.ts";
import { RequestManager } from "../../rest/mod.ts";
import { RequestManager } from "../../rest/request_manager.ts";
import {
AuditLogs,
BannedUser,

View File

@@ -1,5 +1,5 @@
import { botID } from "../../bot.ts";
import { RequestManager } from "../../rest/mod.ts";
import { RequestManager } from "../../rest/request_manager.ts";
import {
ChannelCreatePayload,
DMChannelCreatePayload,

View File

@@ -1,5 +1,5 @@
import { botID } from "../../bot.ts";
import { RequestManager } from "../../rest/mod.ts";
import { RequestManager } from "../../rest/request_manager.ts";
import {
Errors,
MessageContent,

View File

@@ -1,5 +1,4 @@
import { botID } from "../../bot.ts";
import { RequestManager } from "../../rest/mod.ts";
import {
CreateSlashCommandOptions,
EditSlashCommandOptions,
@@ -18,6 +17,7 @@ import { endpoints } from "../../util/constants.ts";
import { botHasChannelPermissions } from "../../util/permissions.ts";
import { urlToBase64 } from "../../util/utils.ts";
import { structures } from "../structures/mod.ts";
import { RequestManager } from "../../rest/request_manager.ts";
/** Create a new webhook. Requires the MANAGE_WEBHOOKS permission. Returns a webhook object on success. Webhook names follow our naming restrictions that can be found in our Usernames and Nicknames documentation, with the following additional stipulations:
*

View File

@@ -1,4 +1,4 @@
import { RequestManager } from "./rest/mod.ts";
import { RequestManager } from "./rest/request_manager.ts";
import {
BotConfig,
DiscordBotGatewayData,

13
src/rest/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Discordeno Rest
A standalone and server-less REST module with functionality of REST, independently.
- Easily host on any serverless infrastructure.
- Easy to use and setup with Cloudflare Workers (FREE for 100K requests per day!)
- Freedom from global rate limit errors
- As your bot grows, you want to handle global rate limits better. Shards don't communicate fast enough to truly handle it properly so this allows 1 rest handler across the entire bot.
- In fact, you can host multiple instances of your bot and all connect to the same rest server.
- REST does not rest!
- Separate rest means if your bot for whatever reason crashes, your requests that are queued will still keep going and will not be lost.
- Seamless updates! When you want to update and reboot the bot, you could potentially lose tons of messages or responses that are in queue. Using this you could restart your bot without ever worrying about losing any responses.
- Scalability! Scalability! Scalability!

21
src/rest/cache.ts Normal file
View File

@@ -0,0 +1,21 @@
import { RestCache } from "./types/mod.ts";
export const restCache: RestCache = {
pathQueues: new Map(),
processingQueue: false,
globallyRateLimited: false,
ratelimitedPaths: new Map(),
eventHandlers: {
// BY DEFAULT WE WILL LOG ALL ERRORS TO CONSOLE. USER CAN CHOOSE TO OVERRIDE
error: function (_type, error) {
console.error(error);
},
// PLACEHOLDERS TO ALLOW USERS TO CUSTOMIZE
fetching() {},
fetched() {},
fetchSuccess() {},
fetchFailed() {},
globallyRateLimited() {},
retriesMaxed() {},
},
};

1
src/rest/deps.ts Normal file
View File

@@ -0,0 +1 @@
export * from "https://deno.land/std@0.83.0/http/server.ts";

View File

@@ -1 +1,4 @@
export * from "./request_manager.ts";
export * from "./cache.ts";
export * from "./queue.ts";
export * from "./request.ts";
export * from "./server.ts";

187
src/rest/queue.ts Normal file
View File

@@ -0,0 +1,187 @@
import { restCache } from "./cache.ts";
import { createRequestBody, processRequestHeaders } from "./request.ts";
import { HttpResponseCode } from "./types/mod.ts";
/** If the queue is not already processing, this will start processing the queue. */
export function startQueue() {
// IF ALREADY PROCESSING CANCEL
if (restCache.processingQueue) return;
// MARK AS PROCESSING
restCache.processingQueue = true;
processQueue();
}
/** Processes the queue by looping over each path separately until the queues are empty. */
export function processQueue() {
while (restCache.processingQueue) {
// FOR EVERY PATH WE WILL START ITS OWN LOOP.
restCache.pathQueues.forEach(async (queue) => {
// EACH PATH IS UNIQUE LIMITER
while (queue.length) {
// IF THE BOT IS GLOBALLY RATELIMITED TRY AGAIN
if (!restCache.globallyRateLimited) continue;
// SELECT THE FIRST ITEM FROM THIS QUEUE
const [queuedRequest] = queue;
// IF THIS DOESNT HAVE ANY ITEMS JUST CANCEL, THE CLEANER WILL REMOVE IT.
if (!queuedRequest) return;
// IF THIS URL IS STILL RATE LIMITED, TRY AGAIN
const urlResetIn = checkRateLimits(queuedRequest.payload.url);
if (urlResetIn) continue;
// IF A BUCKET EXISTS, CHECK THE BUCKET'S RATE LIMITS
const bucketResetIn = queuedRequest.payload.bucketID
? checkRateLimits(queuedRequest.payload.bucketID)
: false;
// THIS BUCKET IS STILL RATELIMITED, RE-ADD TO QUEUE
if (bucketResetIn) continue;
// EXECUTE THE REQUEST
// IF THIS IS A GET REQUEST, CHANGE THE BODY TO QUERY PARAMETERS
const query =
queuedRequest.payload.method === "get" && queuedRequest.payload.body
? Object.entries(queuedRequest.payload.body).map(([key, value]) =>
`${encodeURIComponent(key)}=${
encodeURIComponent(value as string)
}`
)
.join("&")
: "";
const urlToUse = queuedRequest.payload.method === "get" && query
? `${queuedRequest.payload.url}?${query}`
: queuedRequest.payload.url;
// CUSTOM HANDLER FOR USER TO LOG OR WHATEVER WHENEVER A FETCH IS MADE
restCache.eventHandlers.fetching(queuedRequest.payload);
try {
const response = await fetch(
urlToUse,
createRequestBody(queuedRequest),
);
restCache.eventHandlers.fetched(queuedRequest.payload);
const bucketIDFromHeaders = processRequestHeaders(
queuedRequest.payload.url,
response.headers,
);
if (response.status < 200 && response.status >= 400) {
restCache.eventHandlers.error(
"httpError",
queuedRequest.payload,
response,
);
const error = response.status === HttpResponseCode.BadRequest
? "The request was improperly formatted, or the server couldn't understand it."
: response.status === HttpResponseCode.Unauthorized
? "The Authorization header was missing or invalid."
: response.status === HttpResponseCode.Forbidden
? "The Authorization token you passed did not have permission to the resource."
: response.status === HttpResponseCode.NotFound
? "The resource at the location specified doesn't exist."
: response.status === HttpResponseCode.MethodNotAllowed
? "The HTTP method used is not valid for the location specified."
: response.status === HttpResponseCode.GatewayUnavailable
? "There was not a gateway available to process your request. Wait a bit and retry."
: "REQUEST_UNKNOWN_ERROR";
queuedRequest.request.respond(
{ status: response.status, body: JSON.stringify({ error }) },
);
queue.shift();
continue;
}
// SOMETIMES DISCORD RETURNS AN EMPTY 204 RESPONSE THAT CAN'T BE MADE TO JSON
if (response.status === 204) {
restCache.eventHandlers.fetchSuccess(queuedRequest.payload);
return queuedRequest.request.respond({ status: 204 });
}
// CONVERT THE RESPONSE TO JSON
const json = await response.json();
// IF THE RESPONSE WAS RATE LIMITED, HANDLE ACCORDINGLY
if (
json.retry_after ||
json.message === "You are being rate limited."
) {
// IF IT HAS MAXED RETRIES SOMETHING SERIOUSLY WRONG. CANCEL OUT.
if (
queuedRequest.payload.retryCount >=
queuedRequest.options.maxRetryCount
) {
restCache.eventHandlers.retriesMaxed(queuedRequest.payload);
queuedRequest.request.respond(
{
status: 200,
body: JSON.stringify(
{
error:
"The request was rate limited and it maxed out the retries limit.",
},
),
},
);
// REMOVE ITEM FROM QUEUE TO PREVENT RETRY
queue.shift();
continue;
}
// SET THE BUCKET ID IF IT WAS PRESENT
if (bucketIDFromHeaders) {
queuedRequest.payload.bucketID = bucketIDFromHeaders;
}
// SINCE IT WAS RATELIMITE, RETRY AGAIN
continue;
}
restCache.eventHandlers.fetchSuccess(queuedRequest.payload);
// REMOVE FROM QUEUE
queue.shift();
queuedRequest.request.respond(
{ status: 200, body: JSON.stringify(json) },
);
} catch (error) {
// SOMETHING WENT WRONG, LOG AND RESPOND WITH ERROR
restCache.eventHandlers.fetchFailed(queuedRequest.payload, error);
queuedRequest.request.respond(
{ status: 404, body: JSON.stringify({ error }) },
);
// REMOVE FROM QUEUE
queue.shift();
}
}
// ONCE QUEUE IS DONE, WE CAN TRY CLEANING UP
cleanupQueues();
});
}
}
/** Cleans up the queues by checking if there is nothing left and removing it. */
export function cleanupQueues() {
restCache.pathQueues.forEach((queue, key) => {
if (queue.length) return;
// REMOVE IT FROM CACHE
restCache.pathQueues.delete(key);
});
}
/** Check the rate limits for a url or a bucket. */
export function checkRateLimits(url: string) {
const ratelimited = restCache.ratelimitedPaths.get(url);
const global = restCache.ratelimitedPaths.get("global");
const now = Date.now();
if (ratelimited && now < ratelimited.resetTimestamp) {
return ratelimited.resetTimestamp - now;
}
if (global && now < global.resetTimestamp) {
return global.resetTimestamp - now;
}
return false;
}

162
src/rest/request.ts Normal file
View File

@@ -0,0 +1,162 @@
import { USER_AGENT } from "../util/constants.ts";
import { restCache } from "./cache.ts";
import { ServerRequest } from "./deps.ts";
import { startQueue } from "./queue.ts";
import {
QueuedRequest,
RestServerOptions,
RunMethodOptions,
} from "./types/mod.ts";
/** Processes a request and assigns it to a queue or creates a queue if none exists for it. */
export function processRequest(
request: ServerRequest,
payload: RunMethodOptions,
options: RestServerOptions,
) {
const route = payload.url.substring(payload.url.indexOf("api/"));
const parts = route.split("/");
// REMOVE THE API
parts.shift();
// REMOVES THE VERSION NUMBER
if (parts[0]?.startsWith("v")) parts.shift();
// REMOVE THE MAJOR PARAM
parts.shift();
const [id] = parts;
const queue = restCache.pathQueues.get(id);
// IF THE QUEUE EXISTS JUST ADD THIS TO THE QUEUE
if (queue) {
queue.push({ request, payload, options });
} else {
// CREATES A NEW QUEUE
restCache.pathQueues.set(id, [{ request, payload, options }]);
}
startQueue();
}
/** Creates the request body and headers that are necessary to send a request. Will handle different types of methods and everything necessary for discord. */
export function createRequestBody(queuedRequest: QueuedRequest) {
const headers: { [key: string]: string } = {
Authorization: queuedRequest.options.token,
"User-Agent": USER_AGENT,
};
// GET METHODS SHOULD NOT HAVE A BODY
if (queuedRequest.payload.method === "get") {
queuedRequest.payload.body = undefined;
}
// IF A REASON IS PROVIDED ENCODE IT IN HEADERS
if (queuedRequest.payload.body?.reason) {
headers["X-Audit-Log-Reason"] = encodeURIComponent(
queuedRequest.payload.body.reason,
);
}
// IF A FILE/ATTACHMENT IS PRESENT WE NEED SPECIAL HANDLING
if (queuedRequest.payload.body?.file) {
const form = new FormData();
form.append(
"file",
queuedRequest.payload.body.file.blob,
queuedRequest.payload.body.file.name,
);
form.append(
"payload_json",
JSON.stringify({ ...queuedRequest.payload.body, file: undefined }),
);
queuedRequest.payload.body.file = form;
} else if (
queuedRequest.payload.body &&
!["get", "delete"].includes(queuedRequest.payload.method)
) {
headers["Content-Type"] = "application/json";
}
return {
headers,
body: queuedRequest.payload.body?.file ||
JSON.stringify(queuedRequest.payload.body),
method: queuedRequest.payload.method.toUpperCase(),
};
}
/** Processes the rate limit headers and determines if it needs to be ratelimited and returns the bucket id if available */
export function processRequestHeaders(url: string, headers: Headers) {
let ratelimited = false;
// GET ALL NECESSARY HEADERS
const remaining = headers.get("x-ratelimit-remaining");
const resetTimestamp = headers.get("x-ratelimit-reset");
const retryAfter = headers.get("retry-after");
const global = headers.get("x-ratelimit-global");
const bucketID = headers.get("x-ratelimit-bucket");
// IF THERE IS NO REMAINING RATE LIMIT, MARK IT AS RATE LIMITED
if (remaining && remaining === "0") {
ratelimited = true;
// SAVE THE URL AS LIMITED, IMPORTANT FOR NEW REQUESTS BY USER WITHOUT BUCKET
restCache.ratelimitedPaths.set(url, {
url,
resetTimestamp: Number(resetTimestamp) * 1000,
bucketID,
});
// SAVE THE BUCKET AS LIMITED SINCE DIFFERENT URLS MAY SHARE A BUCKET
if (bucketID) {
restCache.ratelimitedPaths.set(bucketID, {
url,
resetTimestamp: Number(resetTimestamp) * 1000,
bucketID,
});
}
}
// IF THERE IS NO REMAINING GLOBAL LIMIT, MARK IT RATE LIMITED GLOBALLY
if (global) {
const reset = Date.now() + (Number(retryAfter) * 1000);
restCache.eventHandlers.globallyRateLimited(url, reset);
restCache.globallyRateLimited = true;
ratelimited = true;
restCache.ratelimitedPaths.set("global", {
url: "global",
resetTimestamp: reset,
bucketID,
});
if (bucketID) {
restCache.ratelimitedPaths.set(bucketID, {
url: "global",
resetTimestamp: reset,
bucketID,
});
}
}
return ratelimited ? bucketID : undefined;
}
/** This wll create a infinite loop running in 1 seconds using tail recursion to keep rate limits clean. When a rate limit resets, this will remove it so the queue can proceed. */
function processRateLimitedPaths() {
const now = Date.now();
restCache.ratelimitedPaths.forEach((value, key) => {
// IF THE TIME HAS NOT REACHED CANCEL
if (value.resetTimestamp > now) return;
// RATE LIMIT IS OVER, DELETE THE RATE LIMITER
restCache.ratelimitedPaths.delete(key);
// IF IT WAS GLOBAL ALSO MARK THE GLOBAL VALUE AS FALSE
if (key === "global") restCache.globallyRateLimited = false;
});
// RECHECK IN 1 SECOND
setTimeout(() => processRateLimitedPaths(), 1000);
}
/** Starts the loop */
processRateLimitedPaths();

59
src/rest/server.ts Normal file
View File

@@ -0,0 +1,59 @@
// SERVERLESS REST CLIENT THAT CAN WORK ACROSS SHARDS/WORKERS TO COMMUNICATE GLOBAL RATE LIMITS EASILY
import { restCache } from "./cache.ts";
import { serve, ServerRequest } from "./deps.ts";
import { processRequest } from "./request.ts";
import { RestServerOptions } from "./types/mod.ts";
/** Begins an http server that will handle incoming requests. */
export async function startRESTServer(options: RestServerOptions) {
const server = serve({ port: options.port });
for await (const request of server) {
handlePayload(request, options).catch((error) => {
restCache.eventHandlers.error("processRequest", error);
});
}
}
/** Handler function for every request. Converts to json, verified authorization & requirements and begins processing the request */
async function handlePayload(
request: ServerRequest,
options: RestServerOptions,
) {
// INSTANTLY IGNORE ANY REQUESTS THAT DON'T HAVE THE SECRET AUTHORIZATION KEY
const authorization = request.headers.get("authorization");
if (authorization !== options.authorization) return;
// READ BUFFER AFTER AUTH CHECK
const buffer = await Deno.readAll(request.body);
try {
// CONVERT THE BODY TO JSON
const data = JSON.parse(new TextDecoder().decode(buffer));
if (!data.url) {
return request.respond(
{
status: 400,
body: JSON.stringify({ error: "No URL was provided." }),
},
);
}
if (!data.method) {
return request.respond(
{
status: 400,
body: JSON.stringify({ error: "No METHOD was provided." }),
},
);
}
// PROCESS THE REQUEST
await processRequest(
request,
{ method: data.method, url: data.url, body: data.body, retryCount: 0 },
options,
);
} catch (error) {
restCache.eventHandlers.error("serverRequest", error);
}
}

15
src/rest/types/cache.ts Normal file
View File

@@ -0,0 +1,15 @@
import { QueuedRequest, RateLimitedPath } from "./queue.ts";
import { RestEventHandlers } from "./server.ts";
export interface RestCache {
/** The queues that are currently needing to be executed. Key is the url path and the value is all the requests in this same path. Paths are mapped by MAJOR params. */
pathQueues: Map<string, QueuedRequest[]>;
/** Whether or not the queues are currently processing. */
processingQueue: boolean;
/** Whether or not this token has been globally rate limited. */
globallyRateLimited: boolean;
/** The paths that have been rate limited */
ratelimitedPaths: Map<string, RateLimitedPath>;
/** The event handlers are functions that run when something is happening internally. Users can customize this for analytics, debugging, logging or anything their heart desires. */
eventHandlers: RestEventHandlers;
}

14
src/rest/types/http.ts Normal file
View File

@@ -0,0 +1,14 @@
export enum HttpResponseCode {
Ok = 200,
Created = 201,
NoContent = 204,
NotModified = 304,
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
TooManyRequests = 429,
GatewayUnavailable = 502,
// ServerError left untyped because it's 5xx.
}

5
src/rest/types/mod.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./cache.ts";
export * from "./http.ts";
export * from "./queue.ts";
export * from "./requests.ts";
export * from "./server.ts";

20
src/rest/types/queue.ts Normal file
View File

@@ -0,0 +1,20 @@
import { ServerRequest } from "../deps.ts";
import { RestServerOptions, RunMethodOptions } from "./mod.ts";
export interface RateLimitedPath {
/** The url for this request */
url: string;
/** The timestamp when this request can be made. */
resetTimestamp: number;
/** The bucket id that is assigned to this request path. */
bucketID: string | null;
}
export interface QueuedRequest {
/** The request itself, the server received. This will be used to send a response later. */
request: ServerRequest;
/** The payload like url, method and such for the request. */
payload: RunMethodOptions;
/** The intial start configurations like token which is necessary for headers. */
options: RestServerOptions;
}

View File

@@ -0,0 +1,16 @@
export type RequestMethods =
| "get"
| "post"
| "put"
| "patch"
| "head"
| "delete";
export interface RunMethodOptions {
method: RequestMethods;
url: string;
retryCount: number;
// deno-lint-ignore no-explicit-any
body?: any;
bucketID?: string | null;
}

45
src/rest/types/server.ts Normal file
View File

@@ -0,0 +1,45 @@
import { RunMethodOptions } from "./requests.ts";
export interface RestServerOptions {
/** The port number where the server will be hosted. */
port: number;
/** The authorization secret key that all requests must provide in its headers. This prevents anyone from making your server do something. */
authorization: string;
/** The bot token that will be used for authorization. */
token: string;
/** When a request is rate limited, how many times should it keep retrying the request. Recommended: 10 */
maxRetryCount: number;
}
export interface RestEventHandlers {
/** Runs whenever an error occurs. Use this to log to sentry or log to discord with a webhook. */
error: (
type: RestErrorEventTypes,
// deno-lint-ignore no-explicit-any
...data: any[]
) => unknown | Promise<unknown>;
/** Runs before every request is about to be fetched. Can be useful for things like analytics and debugging. */
fetching: (data: RunMethodOptions) => unknown | Promise<unknown>;
/** Runs right after a request is complete whether it worked or not. Useful for analytics and debugging. */
fetched: (data: RunMethodOptions) => unknown | Promise<unknown>;
/** The fetch request was successfully executed. Useful for analytics. */
fetchSuccess: (data: RunMethodOptions) => unknown | Promise<unknown>;
/** The fetch request errored somewhere. Useful for analytics and debugging */
fetchFailed: (
data: RunMethodOptions,
// deno-lint-ignore no-explicit-any
error: any,
) => unknown | Promise<unknown>;
/** Runs whenever the token is globally rate limited. Useful for analytics, debugging */
globallyRateLimited: (
url: string,
resetAt: number,
) => unknown | Promise<unknown>;
/** Runs when the maximum amount of retries has been reached. Useful for logging and debugging */
retriesMaxed: (data: RunMethodOptions) => unknown | Promise<unknown>;
}
export type RestErrorEventTypes =
| "serverRequest"
| "processRequest"
| "httpError";