import assert from 'node:assert'
import S from '@pbvision/schema'
import RESPONSES from './response.js'
/**
* @namespace Exceptions
* @public
*/
/**
* Thrown to shortcut request handling.
*
* {@link API} will catch this exception and send the HTTP response code
* and (optional) data. This is useful to immediately stop request processing,
* especially from deeply nested locations in the call stack where it would
* be cumbersome to pass return information (especially errors) all the way
* back up the call stack.
*
* Typically, users should throw {@link RequestError} for error responses or
* @see RequestOkay for non-error responses (not @this).
*
* @arg {Number} httpCode The HTTP status code to respond with.
* @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
* string to send in the HTTP response body.
* @package
* @memberof Exceptions
*/
class __RequestDone extends Error {
static STATUS = undefined
static SCHEMA = RESPONSES.NO_OUTPUT
static get schemaValidator () {
const cacheKey = '_CACHED_SCHEMA_VALIDATOR'
if (!Object.prototype.hasOwnProperty.call(this, cacheKey)) {
this[cacheKey] = this.schema.compile(`data input to ${this.name}`)
}
return this[cacheKey]
}
/**
* Gets a Schema object.
*/
static get schema () {
let schema = this.SCHEMA
if (!schema.isSchema) {
schema = S.obj(schema)
}
return schema
}
/**
* Create an exception.
* @param {String} message An error message
* @param {Object} data A JSON object containing additional data
* @param {Integer} code An optional code to override exception's default
* STATUS
*/
constructor (message, data = undefined, code = undefined) {
super(message)
this.httpCode = code ?? this.constructor.STATUS
assert(this.httpCode !== undefined, 'Status must be defined')
if (this.constructor.SCHEMA !== undefined) {
this.constructor.schemaValidator?.(data)
}
this.data = data
}
}
/**
* Concrete class to indicate request is completed ok.
* @public
* @memberof Exceptions
*/
class RequestDone extends __RequestDone {
static STATUS = 200
static SCHEMA = S.obj() // optional
static get schemaValidator () {
const orig = super.schemaValidator
// istanbul ignore else
if (this.SCHEMA === RequestDone.SCHEMA) {
return data => {
if (data !== undefined) {
orig(data)
}
}
}
// istanbul ignore next
return orig
}
/**
* Return data to return to caller.
*/
get respData () {
return this.data
}
/**
* Create a success response. Status must be smaller than 400.
* @param {Object} data Data to return to caller
* @param {Integer} code A status to override default status.
*/
constructor (data = undefined, code = undefined) {
super(undefined, data, code)
assert(this.httpCode < 300, 'Status code must be less than 300')
}
}
/**
* Thrown to shortcut request handling and return an HTTP success code (200).
*
* @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
* string to send in the HTTP response body.
* @public
* @memberof Exceptions
* @see RequestDone
*/
class RequestOkay extends RequestDone {
static STATUS = 200
// request okay response will be verified like a regular response
static SCHEMA = undefined
constructor (data = undefined) {
super(data, 200)
}
}
/**
* Thrown to shortcut request handling and return an HTTP error.
*
* @arg {String} message The human-readable error message to send back in the
* JSON response data (in the "error" key).
* @arg {Object} [data={}] Optional additional JSON data to send back
* in the error response body.
* @arg {Number} [code=400] The HTTP status code to respond with; defaults
* to STATUS
* @public
* @memberof Exceptions
* @see RequestDone
*/
class RequestError extends __RequestDone {
static SCHEMA = S.obj() // optional
static get schemaValidator () {
const orig = super.schemaValidator
// istanbul ignore else
if (this.SCHEMA === RequestError.SCHEMA) {
return data => {
if (data !== undefined) {
orig(data)
}
}
}
return orig
}
constructor (message, data = undefined, code = undefined) {
super(message, data, code)
assert(this.httpCode >= 300, 'Status code must be at least 300')
}
/**
* Schema to use on returned data
*/
static get respSchema () {
return S.obj({
code: S.str,
message: S.str.optional(),
data: S.obj().optional() // Data is validated in constructor, don't validate again.
})
}
/**
* Data to return to the caller
*/
get respData () {
return {
code: this.constructor.name,
message: this.message,
data: this.data
}
}
/**
* Mark this error for rate-limited Sentry reporting. Useful when a known
* dependency outage causes the same error to fire repeatedly -- Cloud Tasks
* retries, etc. The HTTP response and logs are unaffected; only the Sentry
* report is throttled so the error budget doesn't get exhausted during the
* outage. Returns `this`, so it can be chained at the throw site:
*
* throw new RequestError('failed to send email', { err }, 550)
* .rateLimitSentry()
*
* Rate-limit keying follows the Sentry fingerprint we'd have used anyway
* (so Sentry grouping and our suppression agree on what "the same error" is).
*
* @param {Number} [windowMs=300000] rate-limit window in ms; at most one
* Sentry report is sent per window per key (default 5 minutes).
* @returns {RequestError} this
*/
rateLimitSentry (windowMs = 5 * 60 * 1000) {
this._sentryRateLimitMs = windowMs
return this
}
/**
* Force this client error (status < 500) to be reported to Sentry. By
* default a RequestError below 500 is not reported: it is an expected
* outcome deliberately thrown by the app (access denied, bad input, etc.)
* whose reports would only consume the Sentry quota. (Crashes and errors
* not thrown through these exception classes are always reported.) The
* HTTP response and logs always include the error either way. Returns
* `this`, so it can be chained at the throw site:
*
* throw new BadRequestException('suspicious input').forceSentry()
*
* @returns {RequestError} this
*/
forceSentry () {
this._sentryForceReport = true
return this
}
}
/**
* Thrown when a redirect is required.
* @public
* @memberof Exceptions
*/
class RedirectException extends RequestError {
static STATUS = 302
static SCHEMA = S.str.max(0)
constructor (url, code) {
super('', '', code)
assert(this.httpCode < 400, 'Status code must be less than 400')
assert(typeof url === 'string' && url.length)
this.url = url
}
}
/**
* Thrown when a error is induced by client.
* @public
* @memberof Exceptions
*/
class BadRequestException extends RequestError {
static STATUS = 400
}
/**
* Wraps Request Validation failures into a RequestError object for processing.
* @public
* @memberof Exceptions
*/
class InvalidInputException extends BadRequestException {
static STATUS = 400
static __ERROR_PREFIX = {
headers: 'Header Validation Failure',
body: 'Body Validation Failure',
querystring: 'Query Validation Failure',
params: 'Path Validation Failure'
}
constructor (schemaError) {
const prefixMap = InvalidInputException.__ERROR_PREFIX
const prefix = prefixMap[schemaError.validationContext] ??
'Unknown Validation Failure'
super(`${prefix}: ${schemaError.message}`)
this.name = this.constructor.name
// istanbul ignore else
if (process.env.NODE_ENV !== 'prod' && schemaError.validation) {
for (const err of schemaError.validation) {
console.log(JSON.stringify(err))
}
}
}
}
/**
* Thrown when a client is not authorized to access the requested resource. For
* example, user is trying to log in using invalid credentials.
* @public
* @memberof Exceptions
*/
class UnauthorizedException extends RequestError {
static STATUS = 401
constructor (message = 'access denied') {
super(message)
}
}
/**
* Thrown when an authenticated client does not have enough privilege to access
* the requested resource. For example, a user tries to change other user's
* data.
* @public
* @memberof Exceptions
*/
class ForbiddenException extends RequestError {
static STATUS = 403
constructor (message = 'access denied') {
super(message)
}
}
/**
* Thrown when the requested resource is not found, or should be hidden.
* @public
* @memberof Exceptions
*/
class NotFoundException extends RequestError {
static STATUS = 404
constructor () {
super('Not found')
}
}
/**
* Thrown when an error is induced by a server bug.
* @public
* @memberof Exceptions
*/
class InternalFailureException extends RequestError {
static STATUS = 500
}
/**
* Thrown when server is temporarily unable to serve the request, due to
* internal timeouts or service outage.
* @public
* @memberof Exceptions
*/
class ServiceUnavailableException extends RequestError {
static STATUS = 503
}
export {
// Base exceptions
__RequestDone,
RequestError,
RequestDone,
// Success
RequestOkay,
// Redirect
RedirectException,
// Error
BadRequestException,
InvalidInputException,
ForbiddenException,
InternalFailureException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException
}