feat(rest): Add events (#4245)

* feat(rest): Add events

* Apply suggestions from code review

Co-authored-by: Awesome Stickz <awesome@stickz.dev>

* Remove timeTook, consolidate event types

* Update packages/rest/src/manager.ts

Co-authored-by: Awesome Stickz <awesome@stickz.dev>

* Fix type error

* Apply suggestions from code review

Co-authored-by: Link <lts20050703@gmail.com>

---------

Co-authored-by: Awesome Stickz <awesome@stickz.dev>
Co-authored-by: Link <lts20050703@gmail.com>
This commit is contained in:
Fleny
2025-08-04 19:49:44 +02:00
committed by GitHub
parent 27ab08a61d
commit 4792ad2721
2 changed files with 73 additions and 17 deletions

View File

@@ -119,6 +119,12 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
token: options.token,
version: options.version ?? DISCORD_API_VERSION,
logger: options.logger ?? logger,
events: {
request: () => {},
response: () => {},
requestError: () => {},
...options.events,
},
routes: createRoutes(),
@@ -440,10 +446,16 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
loggingHeaders.authorization = `${authorizationScheme} tokenhere`
}
const request = new Request(url, payload)
rest.events.request(request, {
body: options.requestBodyOptions?.body,
})
rest.logger.debug(`sending request to ${url}`, 'with payload:', { ...payload, headers: loggingHeaders })
const response = await fetch(url, payload).catch(async (error) => {
const response = await fetch(request).catch(async (error) => {
rest.logger.error(error)
// Mark request and completed
rest.events.requestError(request, error, { body: options.requestBodyOptions?.body })
// Mark request as completed
rest.invalidBucket.handleCompletedRequest(999, false)
options.reject({
ok: false,
@@ -454,7 +466,15 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
})
rest.logger.debug(`request fetched from ${url} with status ${response.status} & ${response.statusText}`)
// Mark request and completed
// Sometimes the Content-Type may be "application/json; charset=utf-8", for this reason, we need to check the start of the header
const body = await (response.headers.get('Content-Type')?.startsWith('application/json') ? response.json() : response.text()).catch(() => null)
rest.events.response(request, response, {
requestBody: options.requestBodyOptions?.body,
responseBody: body,
})
// Mark request as completed
rest.invalidBucket.handleCompletedRequest(response.status, response.headers.get(RATE_LIMIT_SCOPE_HEADER) === 'shared')
// Set the bucket id if it was available on the headers
@@ -466,15 +486,10 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
rest.logger.debug(`Request to ${url} failed.`)
if (response.status !== HttpResponseCode.TooManyRequests) {
const body = response.headers.get('Content-Type') === 'application/json' ? ((await response.json()) as object) : await response.text()
options.reject({ ok: false, status: response.status, statusText: response.statusText, body })
return
}
// Consume the response body to avoid leaking memory
await response.arrayBuffer()
rest.logger.debug(`Request to ${url} was ratelimited.`)
// Too many attempts, get rid of request from queue.
if (options.retryCount >= rest.maxRetryCount) {
@@ -498,8 +513,8 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
return await options.retryRequest?.(options)
}
// Discord sometimes sends no response with no content.
options.resolve({ ok: true, status: response.status, body: response.status === HttpResponseCode.NoContent ? undefined : await response.text() })
// Discord sometimes sends a response with no content
options.resolve({ ok: true, status: response.status, body: response.status === HttpResponseCode.NoContent ? undefined : body })
},
simplifyUrl(url, method) {
@@ -569,12 +584,22 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
options.headers[rest.authorizationHeader] = rest.authorization
}
const result = await fetch(`${rest.baseUrl}/v${rest.version}${route}`, rest.createRequestBody(method, options))
const request = new Request(`${rest.baseUrl}/v${rest.version}${route}`, rest.createRequestBody(method, options))
rest.events.request(request, {
body: options?.body,
})
const result = await fetch(request)
// Sometimes the Content-Type may be "application/json; charset=utf-8", for this reason, we need to check the start of the header
const body = await (result.headers.get('Content-Type')?.startsWith('application/json') ? result.json() : result.text()).catch(() => null)
rest.events.response(request, result, {
requestBody: options?.body,
responseBody: body,
})
if (!result.ok) {
// Sometime the Content-Type may be "application/json; charset=utf-8", for this reason we need to check the start of the header
const body = await (result.headers.get('Content-Type')?.startsWith('application/json') ? result.json() : result.text()).catch(() => null)
error.cause = Object.assign(Object.create(baseErrorPrototype), {
ok: false,
status: result.status,
@@ -584,7 +609,7 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
throw error
}
return result.status !== 204 ? await result.json() : undefined
return result.status !== 204 ? (typeof body === 'string' ? JSON.parse(body) : body) : undefined
}
return await new Promise(async (resolve, reject) => {
@@ -597,7 +622,7 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
await rest.processRequest(payload)
},
resolve: (data) => {
resolve(data.status !== 204 ? JSON.parse(data.body ?? '{}') : undefined)
resolve(data.status !== 204 ? (typeof data.body === 'string' ? JSON.parse(data.body) : data.body) : undefined)
},
reject: (reason) => {
let errorText: string

View File

@@ -196,6 +196,8 @@ export interface CreateRestManagerOptions {
* @default logger // The logger exported by `@discordeno/utils`
*/
logger?: Pick<typeof logger, 'debug' | 'info' | 'warn' | 'error' | 'fatal'>
/** Events for the rest manager */
events?: Partial<RestManagerEvents>
}
export interface RestManager {
@@ -241,6 +243,8 @@ export interface RestManager {
routes: RestRoutes
/** The logger to use for the rest manager */
logger: Pick<typeof logger, 'debug' | 'info' | 'warn' | 'error' | 'fatal'>
/** Events for the rest manager */
events: RestManagerEvents
/** Allows the user to inject custom headers that will be sent with every request. */
createBaseHeaders: () => Record<string, string>
/** Whether or not the rest manager should keep objects in raw snake case from discord. */
@@ -3251,7 +3255,8 @@ export interface RestRateLimitedPath {
export interface RestRequestResponse {
ok: boolean
status: number
body?: string
/** The returned body parsed if it was JSON, otherwise it will be the raw body as a string */
body?: string | object
}
export interface RestRequestRejection {
@@ -3263,3 +3268,29 @@ export interface RestRequestRejection {
body?: string | object
error?: string
}
export interface RestManagerEvents {
/**
* Emitted when a request is made to the API.
*
* @remarks
* The body that will be sent to the API is available in the `extra` parameter. Do not consume the body in the `Request` object and use the one in the `extra` parameter instead.
*/
request: (request: Request, extra: { body: any }) => void
/**
* Emitted when a response is received from the API.
*
* @remarks
* This is fired for both successful and failed requests, you should check the Response object to determine if the request was successful or not.
*
* Both the request and the response body are available in the `extra` parameter. Do not consume the body in the `Request` or `Response` object and use the one in the `extra` parameter instead.
*/
response: (request: Request, response: Response, extra: { requestBody: any; responseBody: string | object }) => void
/**
* Emitted when a request errors due to fetch error.
*
* @remarks
* The body that was sent to the API is available in the `extra` parameter.
*/
requestError: (request: Request, error: any, extra: { body: any }) => void
}