Skip to content

Commit 5b837a9

Browse files
authored
feat(NODE-4890)!: make all thrown errors into BSONErrors (#545)
1 parent 2a503d1 commit 5b837a9

15 files changed

+216
-110
lines changed

README.md

+23
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,29 @@ Deserialize stream data as BSON documents.
285285

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

288+
## Error Handling
289+
290+
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.
291+
292+
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.
293+
This means `BSONError.isBSONError()` will always be able to accurately capture the errors that our BSON library throws.
294+
295+
Hypothetical example: A collection in our Db has an issue with UTF-8 data:
296+
297+
```ts
298+
let documentCount = 0;
299+
const cursor = collection.find({}, { utf8Validation: true });
300+
try {
301+
for await (const doc of cursor) documentCount += 1;
302+
} catch (error) {
303+
if (BSONError.isBSONError(error)) {
304+
console.log(`Found the troublemaker UTF-8!: ${documentCount} ${error.message}`);
305+
return documentCount;
306+
}
307+
throw error;
308+
}
309+
```
310+
288311
## FAQ
289312

290313
#### Why does `undefined` get converted to `null`?

docs/upgrade-to-v5.md

+24
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,27 @@ You can now find compiled bundles of the BSON library in 3 common formats in the
264264
- ES Module - `lib/bson.mjs`
265265
- Immediate Invoked Function Expression (IIFE) - `lib/bson.bundle.js`
266266
- 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.
267+
268+
### `BSONTypeError` removed and `BSONError` offers filtering functionality with `static isBSONError()`
269+
270+
`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).
271+
272+
273+
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.
274+
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)).
275+
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.
276+
277+
Hypothetical example: A collection in our Db has an issue with UTF-8 data:
278+
```ts
279+
let documentCount = 0;
280+
const cursor = collection.find({}, { utf8Validation: true });
281+
try {
282+
for await (const doc of cursor) documentCount += 1;
283+
} catch (error) {
284+
if (BSONError.isBSONError(error)) {
285+
console.log(`Found the troublemaker UTF-8!: ${documentCount} ${error.message}`);
286+
return documentCount;
287+
}
288+
throw error;
289+
}
290+
```

src/binary.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { bufferToUuidHexString, uuidHexStringToBuffer, uuidValidateString } from './uuid_utils';
22
import { isUint8Array } from './parser/utils';
33
import type { EJSONOptions } from './extended_json';
4-
import { BSONError, BSONTypeError } from './error';
4+
import { BSONError } from './error';
55
import { BSON_BINARY_SUBTYPE_UUID_NEW } from './constants';
66
import { ByteUtils } from './utils/byte_utils';
77

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

124124
// Decode the byte value once
125125
let decodedByte: number;
@@ -132,7 +132,7 @@ export class Binary {
132132
}
133133

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

138138
if (this.buffer.byteLength > this.position) {
@@ -279,7 +279,7 @@ export class Binary {
279279
data = uuidHexStringToBuffer(doc.$uuid);
280280
}
281281
if (!data) {
282-
throw new BSONTypeError(`Unexpected Binary Extended JSON format ${JSON.stringify(doc)}`);
282+
throw new BSONError(`Unexpected Binary Extended JSON format ${JSON.stringify(doc)}`);
283283
}
284284
return type === BSON_BINARY_SUBTYPE_UUID_NEW ? new UUID(data) : new Binary(data, type);
285285
}
@@ -328,7 +328,7 @@ export class UUID extends Binary {
328328
} else if (typeof input === 'string') {
329329
bytes = uuidHexStringToBuffer(input);
330330
} else {
331-
throw new BSONTypeError(
331+
throw new BSONError(
332332
'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).'
333333
);
334334
}

src/bson.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export {
4949
BSONRegExp,
5050
Decimal128
5151
};
52-
export { BSONError, BSONTypeError } from './error';
52+
export { BSONError } from './error';
5353
export { BSONType } from './constants';
5454
export { EJSON } from './extended_json';
5555

src/decimal128.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BSONTypeError } from './error';
1+
import { BSONError } from './error';
22
import { Long } from './long';
33
import { isUint8Array } from './parser/utils';
44
import { ByteUtils } from './utils/byte_utils';
@@ -113,7 +113,7 @@ function lessThan(left: Long, right: Long): boolean {
113113
}
114114

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

119119
/** @public */
@@ -142,11 +142,11 @@ export class Decimal128 {
142142
this.bytes = Decimal128.fromString(bytes).bytes;
143143
} else if (isUint8Array(bytes)) {
144144
if (bytes.byteLength !== 16) {
145-
throw new BSONTypeError('Decimal128 must take a Buffer of 16 bytes');
145+
throw new BSONError('Decimal128 must take a Buffer of 16 bytes');
146146
}
147147
this.bytes = bytes;
148148
} else {
149-
throw new BSONTypeError('Decimal128 must take a Buffer or string');
149+
throw new BSONError('Decimal128 must take a Buffer or string');
150150
}
151151
}
152152

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

207207
// Results
@@ -211,7 +211,7 @@ export class Decimal128 {
211211

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

217217
if (stringMatch) {
@@ -283,7 +283,7 @@ export class Decimal128 {
283283
}
284284

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

288288
// Read exponent if exists
289289
if (representation[index] === 'e' || representation[index] === 'E') {

src/error.ts

+33-9
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
1-
/** @public */
1+
/**
2+
* @public
3+
* `BSONError` objects are thrown when runtime errors occur.
4+
*/
25
export class BSONError extends Error {
3-
constructor(message: string) {
4-
super(message);
6+
/**
7+
* @internal
8+
* The underlying algorithm for isBSONError may change to improve how strict it is
9+
* about determining if an input is a BSONError. But it must remain backwards compatible
10+
* with previous minors & patches of the current major version.
11+
*/
12+
protected get bsonError(): true {
13+
return true;
514
}
615

7-
get name(): string {
16+
override get name(): string {
817
return 'BSONError';
918
}
10-
}
1119

12-
/** @public */
13-
export class BSONTypeError extends TypeError {
1420
constructor(message: string) {
1521
super(message);
1622
}
1723

18-
get name(): string {
19-
return 'BSONTypeError';
24+
/**
25+
* @public
26+
*
27+
* All errors thrown from the BSON library inherit from `BSONError`.
28+
* This method can assist with determining if an error originates from the BSON library
29+
* even if it does not pass an `instanceof` check against this class' constructor.
30+
*
31+
* @param value - any javascript value that needs type checking
32+
*/
33+
public static isBSONError(value: unknown): value is BSONError {
34+
return (
35+
value != null &&
36+
typeof value === 'object' &&
37+
'bsonError' in value &&
38+
value.bsonError === true &&
39+
// Do not access the following properties, just check existence
40+
'name' in value &&
41+
'message' in value &&
42+
'stack' in value
43+
);
2044
}
2145
}

src/extended_json.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BSON_INT32_MAX, BSON_INT32_MIN, BSON_INT64_MAX, BSON_INT64_MIN } from '
55
import { DBRef, isDBRefLike } from './db_ref';
66
import { Decimal128 } from './decimal128';
77
import { Double } from './double';
8-
import { BSONError, BSONTypeError } from './error';
8+
import { BSONError } from './error';
99
import { Int32 } from './int_32';
1010
import { Long } from './long';
1111
import { MaxKey } from './max_key';
@@ -192,7 +192,7 @@ function serializeValue(value: any, options: EJSONSerializeOptions): any {
192192
circularPart.length + (alreadySeen.length + current.length) / 2 - 1
193193
);
194194

195-
throw new BSONTypeError(
195+
throw new BSONError(
196196
'Converting circular structure to EJSON:\n' +
197197
` ${leadingPart}${alreadySeen}${circularPart}${current}\n` +
198198
` ${leadingSpace}\\${dashes}/`
@@ -321,7 +321,7 @@ function serializeDocument(doc: any, options: EJSONSerializeOptions) {
321321
// Copy the object into this library's version of that type.
322322
const mapper = BSON_TYPE_MAPPINGS[doc._bsontype];
323323
if (!mapper) {
324-
throw new BSONTypeError('Unrecognized or invalid _bsontype: ' + doc._bsontype);
324+
throw new BSONError('Unrecognized or invalid _bsontype: ' + doc._bsontype);
325325
}
326326
outDoc = mapper(outDoc);
327327
}

src/long.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { BSONError } from './error';
12
import type { EJSONOptions } from './extended_json';
23
import type { Timestamp } from './timestamp';
34

@@ -245,7 +246,7 @@ export class Long {
245246
* @returns The corresponding Long value
246247
*/
247248
static fromString(str: string, unsigned?: boolean, radix?: number): Long {
248-
if (str.length === 0) throw Error('empty string');
249+
if (str.length === 0) throw new BSONError('empty string');
249250
if (str === 'NaN' || str === 'Infinity' || str === '+Infinity' || str === '-Infinity')
250251
return Long.ZERO;
251252
if (typeof unsigned === 'number') {
@@ -255,10 +256,10 @@ export class Long {
255256
unsigned = !!unsigned;
256257
}
257258
radix = radix || 10;
258-
if (radix < 2 || 36 < radix) throw RangeError('radix');
259+
if (radix < 2 || 36 < radix) throw new BSONError('radix');
259260

260261
let p;
261-
if ((p = str.indexOf('-')) > 0) throw Error('interior hyphen');
262+
if ((p = str.indexOf('-')) > 0) throw new BSONError('interior hyphen');
262263
else if (p === 0) {
263264
return Long.fromString(str.substring(1), unsigned, radix).neg();
264265
}
@@ -426,7 +427,7 @@ export class Long {
426427
*/
427428
divide(divisor: string | number | Long | Timestamp): Long {
428429
if (!Long.isLong(divisor)) divisor = Long.fromValue(divisor);
429-
if (divisor.isZero()) throw Error('division by zero');
430+
if (divisor.isZero()) throw new BSONError('division by zero');
430431

431432
// use wasm support if present
432433
if (wasm) {
@@ -954,7 +955,7 @@ export class Long {
954955
*/
955956
toString(radix?: number): string {
956957
radix = radix || 10;
957-
if (radix < 2 || 36 < radix) throw RangeError('radix');
958+
if (radix < 2 || 36 < radix) throw new BSONError('radix');
958959
if (this.isZero()) return '0';
959960
if (this.isNegative()) {
960961
// Unsigned Longs are never negative

src/objectid.ts

+6-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BSONTypeError } from './error';
1+
import { BSONError } from './error';
22
import { isUint8Array } from './parser/utils';
33
import { BSONDataView, ByteUtils } from './utils/byte_utils';
44

@@ -52,9 +52,7 @@ export class ObjectId {
5252
let workingId;
5353
if (typeof inputId === 'object' && inputId && 'id' in inputId) {
5454
if (typeof inputId.id !== 'string' && !ArrayBuffer.isView(inputId.id)) {
55-
throw new BSONTypeError(
56-
'Argument passed in must have an id that is of type string or Buffer'
57-
);
55+
throw new BSONError('Argument passed in must have an id that is of type string or Buffer');
5856
}
5957
if ('toHexString' in inputId && typeof inputId.toHexString === 'function') {
6058
workingId = ByteUtils.fromHex(inputId.toHexString());
@@ -80,17 +78,17 @@ export class ObjectId {
8078
if (bytes.byteLength === 12) {
8179
this[kId] = bytes;
8280
} else {
83-
throw new BSONTypeError('Argument passed in must be a string of 12 bytes');
81+
throw new BSONError('Argument passed in must be a string of 12 bytes');
8482
}
8583
} else if (workingId.length === 24 && checkForHexRegExp.test(workingId)) {
8684
this[kId] = ByteUtils.fromHex(workingId);
8785
} else {
88-
throw new BSONTypeError(
86+
throw new BSONError(
8987
'Argument passed in must be a string of 12 bytes or a string of 24 hex characters or an integer'
9088
);
9189
}
9290
} else {
93-
throw new BSONTypeError('Argument passed in does not match the accepted types');
91+
throw new BSONError('Argument passed in does not match the accepted types');
9492
}
9593
// If we are caching the hex string
9694
if (ObjectId.cacheHexString) {
@@ -266,7 +264,7 @@ export class ObjectId {
266264
static createFromHexString(hexString: string): ObjectId {
267265
// Throw an error if it's not a valid setup
268266
if (typeof hexString === 'undefined' || (hexString != null && hexString.length !== 24)) {
269-
throw new BSONTypeError(
267+
throw new BSONError(
270268
'Argument passed in must be a single String of 12 bytes or a string of 24 hex characters'
271269
);
272270
}

0 commit comments

Comments
 (0)