Skip to content

Commit

Permalink
feat(NODE-4890)!: make all thrown errors into BSONErrors (#545)
Browse files Browse the repository at this point in the history
  • Loading branch information
nbbeeken committed Dec 22, 2022
1 parent 2a503d1 commit 5b837a9
Show file tree
Hide file tree
Showing 15 changed files with 216 additions and 110 deletions.
23 changes: 23 additions & 0 deletions README.md
Expand Up @@ -285,6 +285,29 @@ Deserialize stream data as BSON documents.

**Returns**: <code>Number</code> - returns the next index in the buffer after deserialization **x** numbers of documents.

## Error Handling

It is our recommendation to use `BSONError.isBSONError()` checks on errors and to avoid relying on parsing `error.message` and `error.name` strings in your code. We guarantee `BSONError.isBSONError()` checks will pass according to semver guidelines, but errors may be sub-classed or their messages may change at any time, even patch releases, as we see fit to increase the helpfulness of the errors.

Any new errors we add to the driver will directly extend an existing error class and no existing error will be moved to a different parent class outside of a major release.
This means `BSONError.isBSONError()` will always be able to accurately capture the errors that our BSON library throws.

Hypothetical example: A collection in our Db has an issue with UTF-8 data:

```ts
let documentCount = 0;
const cursor = collection.find({}, { utf8Validation: true });
try {
for await (const doc of cursor) documentCount += 1;
} catch (error) {
if (BSONError.isBSONError(error)) {
console.log(`Found the troublemaker UTF-8!: ${documentCount} ${error.message}`);
return documentCount;
}
throw error;
}
```

## FAQ

#### Why does `undefined` get converted to `null`?
Expand Down
24 changes: 24 additions & 0 deletions docs/upgrade-to-v5.md
Expand Up @@ -264,3 +264,27 @@ You can now find compiled bundles of the BSON library in 3 common formats in the
- ES Module - `lib/bson.mjs`
- Immediate Invoked Function Expression (IIFE) - `lib/bson.bundle.js`
- Typically used when trying to import JS on the web CDN style, but the ES Module (`.mjs`) bundle is fully browser compatible and should be preferred if it works in your use case.

### `BSONTypeError` removed and `BSONError` offers filtering functionality with `static isBSONError()`

`BSONTypeError` has been removed because it was not a subclass of BSONError so would not return true for an `instanceof` check against `BSONError`. To learn more about our expectations of error handling see [this section of the mongodb driver's readme](https://github.com/mongodb/node-mongodb-native/tree/main#error-handling).


A `BSONError` can be thrown from deep within a library that relies on BSON, having one error super class for the library helps with programmatic filtering of an error's origin.
Since BSON can be used in environments where instances may originate from across realms, `BSONError` has a static `isBSONError()` method that helps with determining if an object is a `BSONError` instance (much like [Array.isArray](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray)).
It is our recommendation to use `isBSONError()` checks on errors and to avoid relying on parsing `error.message` and `error.name` strings in your code. We guarantee `isBSONError()` checks will pass according to semver guidelines, but errors may be sub-classed or their messages may change at any time, even patch releases, as we see fit to increase the helpfulness of the errors.

Hypothetical example: A collection in our Db has an issue with UTF-8 data:
```ts
let documentCount = 0;
const cursor = collection.find({}, { utf8Validation: true });
try {
for await (const doc of cursor) documentCount += 1;
} catch (error) {
if (BSONError.isBSONError(error)) {
console.log(`Found the troublemaker UTF-8!: ${documentCount} ${error.message}`);
return documentCount;
}
throw error;
}
```
14 changes: 7 additions & 7 deletions src/binary.ts
@@ -1,7 +1,7 @@
import { bufferToUuidHexString, uuidHexStringToBuffer, uuidValidateString } from './uuid_utils';
import { isUint8Array } from './parser/utils';
import type { EJSONOptions } from './extended_json';
import { BSONError, BSONTypeError } from './error';
import { BSONError } from './error';
import { BSON_BINARY_SUBTYPE_UUID_NEW } from './constants';
import { ByteUtils } from './utils/byte_utils';

Expand Down Expand Up @@ -82,7 +82,7 @@ export class Binary {
!(buffer instanceof ArrayBuffer) &&
!Array.isArray(buffer)
) {
throw new BSONTypeError(
throw new BSONError(
'Binary can only be constructed from string, Buffer, TypedArray, or Array<number>'
);
}
Expand Down Expand Up @@ -117,9 +117,9 @@ export class Binary {
put(byteValue: string | number | Uint8Array | number[]): void {
// If it's a string and a has more than one character throw an error
if (typeof byteValue === 'string' && byteValue.length !== 1) {
throw new BSONTypeError('only accepts single character String');
throw new BSONError('only accepts single character String');
} else if (typeof byteValue !== 'number' && byteValue.length !== 1)
throw new BSONTypeError('only accepts single character Uint8Array or Array');
throw new BSONError('only accepts single character Uint8Array or Array');

// Decode the byte value once
let decodedByte: number;
Expand All @@ -132,7 +132,7 @@ export class Binary {
}

if (decodedByte < 0 || decodedByte > 255) {
throw new BSONTypeError('only accepts number in a valid unsigned byte range 0-255');
throw new BSONError('only accepts number in a valid unsigned byte range 0-255');
}

if (this.buffer.byteLength > this.position) {
Expand Down Expand Up @@ -279,7 +279,7 @@ export class Binary {
data = uuidHexStringToBuffer(doc.$uuid);
}
if (!data) {
throw new BSONTypeError(`Unexpected Binary Extended JSON format ${JSON.stringify(doc)}`);
throw new BSONError(`Unexpected Binary Extended JSON format ${JSON.stringify(doc)}`);
}
return type === BSON_BINARY_SUBTYPE_UUID_NEW ? new UUID(data) : new Binary(data, type);
}
Expand Down Expand Up @@ -328,7 +328,7 @@ export class UUID extends Binary {
} else if (typeof input === 'string') {
bytes = uuidHexStringToBuffer(input);
} else {
throw new BSONTypeError(
throw new BSONError(
'Argument passed in UUID constructor must be a UUID, a 16 byte Buffer or a 32/36 character hex string (dashes excluded/included, format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).'
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/bson.ts
Expand Up @@ -49,7 +49,7 @@ export {
BSONRegExp,
Decimal128
};
export { BSONError, BSONTypeError } from './error';
export { BSONError } from './error';
export { BSONType } from './constants';
export { EJSON } from './extended_json';

Expand Down
14 changes: 7 additions & 7 deletions src/decimal128.ts
@@ -1,4 +1,4 @@
import { BSONTypeError } from './error';
import { BSONError } from './error';
import { Long } from './long';
import { isUint8Array } from './parser/utils';
import { ByteUtils } from './utils/byte_utils';
Expand Down Expand Up @@ -113,7 +113,7 @@ function lessThan(left: Long, right: Long): boolean {
}

function invalidErr(string: string, message: string) {
throw new BSONTypeError(`"${string}" is not a valid Decimal128 string - ${message}`);
throw new BSONError(`"${string}" is not a valid Decimal128 string - ${message}`);
}

/** @public */
Expand Down Expand Up @@ -142,11 +142,11 @@ export class Decimal128 {
this.bytes = Decimal128.fromString(bytes).bytes;
} else if (isUint8Array(bytes)) {
if (bytes.byteLength !== 16) {
throw new BSONTypeError('Decimal128 must take a Buffer of 16 bytes');
throw new BSONError('Decimal128 must take a Buffer of 16 bytes');
}
this.bytes = bytes;
} else {
throw new BSONTypeError('Decimal128 must take a Buffer or string');
throw new BSONError('Decimal128 must take a Buffer or string');
}
}

Expand Down Expand Up @@ -201,7 +201,7 @@ export class Decimal128 {
// TODO: implementing a custom parsing for this, or refactoring the regex would yield
// further gains.
if (representation.length >= 7000) {
throw new BSONTypeError('' + representation + ' not a valid Decimal128 string');
throw new BSONError('' + representation + ' not a valid Decimal128 string');
}

// Results
Expand All @@ -211,7 +211,7 @@ export class Decimal128 {

// Validate the string
if ((!stringMatch && !infMatch && !nanMatch) || representation.length === 0) {
throw new BSONTypeError('' + representation + ' not a valid Decimal128 string');
throw new BSONError('' + representation + ' not a valid Decimal128 string');
}

if (stringMatch) {
Expand Down Expand Up @@ -283,7 +283,7 @@ export class Decimal128 {
}

if (sawRadix && !nDigitsRead)
throw new BSONTypeError('' + representation + ' not a valid Decimal128 string');
throw new BSONError('' + representation + ' not a valid Decimal128 string');

// Read exponent if exists
if (representation[index] === 'e' || representation[index] === 'E') {
Expand Down
42 changes: 33 additions & 9 deletions src/error.ts
@@ -1,21 +1,45 @@
/** @public */
/**
* @public
* `BSONError` objects are thrown when runtime errors occur.
*/
export class BSONError extends Error {
constructor(message: string) {
super(message);
/**
* @internal
* The underlying algorithm for isBSONError may change to improve how strict it is
* about determining if an input is a BSONError. But it must remain backwards compatible
* with previous minors & patches of the current major version.
*/
protected get bsonError(): true {
return true;
}

get name(): string {
override get name(): string {
return 'BSONError';
}
}

/** @public */
export class BSONTypeError extends TypeError {
constructor(message: string) {
super(message);
}

get name(): string {
return 'BSONTypeError';
/**
* @public
*
* All errors thrown from the BSON library inherit from `BSONError`.
* This method can assist with determining if an error originates from the BSON library
* even if it does not pass an `instanceof` check against this class' constructor.
*
* @param value - any javascript value that needs type checking
*/
public static isBSONError(value: unknown): value is BSONError {
return (
value != null &&
typeof value === 'object' &&
'bsonError' in value &&
value.bsonError === true &&
// Do not access the following properties, just check existence
'name' in value &&
'message' in value &&
'stack' in value
);
}
}
6 changes: 3 additions & 3 deletions src/extended_json.ts
Expand Up @@ -5,7 +5,7 @@ import { BSON_INT32_MAX, BSON_INT32_MIN, BSON_INT64_MAX, BSON_INT64_MIN } from '
import { DBRef, isDBRefLike } from './db_ref';
import { Decimal128 } from './decimal128';
import { Double } from './double';
import { BSONError, BSONTypeError } from './error';
import { BSONError } from './error';
import { Int32 } from './int_32';
import { Long } from './long';
import { MaxKey } from './max_key';
Expand Down Expand Up @@ -192,7 +192,7 @@ function serializeValue(value: any, options: EJSONSerializeOptions): any {
circularPart.length + (alreadySeen.length + current.length) / 2 - 1
);

throw new BSONTypeError(
throw new BSONError(
'Converting circular structure to EJSON:\n' +
` ${leadingPart}${alreadySeen}${circularPart}${current}\n` +
` ${leadingSpace}\\${dashes}/`
Expand Down Expand Up @@ -321,7 +321,7 @@ function serializeDocument(doc: any, options: EJSONSerializeOptions) {
// Copy the object into this library's version of that type.
const mapper = BSON_TYPE_MAPPINGS[doc._bsontype];
if (!mapper) {
throw new BSONTypeError('Unrecognized or invalid _bsontype: ' + doc._bsontype);
throw new BSONError('Unrecognized or invalid _bsontype: ' + doc._bsontype);
}
outDoc = mapper(outDoc);
}
Expand Down
11 changes: 6 additions & 5 deletions src/long.ts
@@ -1,3 +1,4 @@
import { BSONError } from './error';
import type { EJSONOptions } from './extended_json';
import type { Timestamp } from './timestamp';

Expand Down Expand Up @@ -245,7 +246,7 @@ export class Long {
* @returns The corresponding Long value
*/
static fromString(str: string, unsigned?: boolean, radix?: number): Long {
if (str.length === 0) throw Error('empty string');
if (str.length === 0) throw new BSONError('empty string');
if (str === 'NaN' || str === 'Infinity' || str === '+Infinity' || str === '-Infinity')
return Long.ZERO;
if (typeof unsigned === 'number') {
Expand All @@ -255,10 +256,10 @@ export class Long {
unsigned = !!unsigned;
}
radix = radix || 10;
if (radix < 2 || 36 < radix) throw RangeError('radix');
if (radix < 2 || 36 < radix) throw new BSONError('radix');

let p;
if ((p = str.indexOf('-')) > 0) throw Error('interior hyphen');
if ((p = str.indexOf('-')) > 0) throw new BSONError('interior hyphen');
else if (p === 0) {
return Long.fromString(str.substring(1), unsigned, radix).neg();
}
Expand Down Expand Up @@ -426,7 +427,7 @@ export class Long {
*/
divide(divisor: string | number | Long | Timestamp): Long {
if (!Long.isLong(divisor)) divisor = Long.fromValue(divisor);
if (divisor.isZero()) throw Error('division by zero');
if (divisor.isZero()) throw new BSONError('division by zero');

// use wasm support if present
if (wasm) {
Expand Down Expand Up @@ -954,7 +955,7 @@ export class Long {
*/
toString(radix?: number): string {
radix = radix || 10;
if (radix < 2 || 36 < radix) throw RangeError('radix');
if (radix < 2 || 36 < radix) throw new BSONError('radix');
if (this.isZero()) return '0';
if (this.isNegative()) {
// Unsigned Longs are never negative
Expand Down
14 changes: 6 additions & 8 deletions src/objectid.ts
@@ -1,4 +1,4 @@
import { BSONTypeError } from './error';
import { BSONError } from './error';
import { isUint8Array } from './parser/utils';
import { BSONDataView, ByteUtils } from './utils/byte_utils';

Expand Down Expand Up @@ -52,9 +52,7 @@ export class ObjectId {
let workingId;
if (typeof inputId === 'object' && inputId && 'id' in inputId) {
if (typeof inputId.id !== 'string' && !ArrayBuffer.isView(inputId.id)) {
throw new BSONTypeError(
'Argument passed in must have an id that is of type string or Buffer'
);
throw new BSONError('Argument passed in must have an id that is of type string or Buffer');
}
if ('toHexString' in inputId && typeof inputId.toHexString === 'function') {
workingId = ByteUtils.fromHex(inputId.toHexString());
Expand All @@ -80,17 +78,17 @@ export class ObjectId {
if (bytes.byteLength === 12) {
this[kId] = bytes;
} else {
throw new BSONTypeError('Argument passed in must be a string of 12 bytes');
throw new BSONError('Argument passed in must be a string of 12 bytes');
}
} else if (workingId.length === 24 && checkForHexRegExp.test(workingId)) {
this[kId] = ByteUtils.fromHex(workingId);
} else {
throw new BSONTypeError(
throw new BSONError(
'Argument passed in must be a string of 12 bytes or a string of 24 hex characters or an integer'
);
}
} else {
throw new BSONTypeError('Argument passed in does not match the accepted types');
throw new BSONError('Argument passed in does not match the accepted types');
}
// If we are caching the hex string
if (ObjectId.cacheHexString) {
Expand Down Expand Up @@ -266,7 +264,7 @@ export class ObjectId {
static createFromHexString(hexString: string): ObjectId {
// Throw an error if it's not a valid setup
if (typeof hexString === 'undefined' || (hexString != null && hexString.length !== 24)) {
throw new BSONTypeError(
throw new BSONError(
'Argument passed in must be a single String of 12 bytes or a string of 24 hex characters'
);
}
Expand Down

0 comments on commit 5b837a9

Please sign in to comment.