Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/types/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
CommsRequestError,
getCommsErrorCode,
getCommsErrorString,
isConflict,
isMalformedId,
isNotFound,
} from './errors'

function requestError(status: number, body?: unknown): CommsRequestError {
return new CommsRequestError('request failed', status, body)
}

describe('isNotFound', () => {
it('is true for a 404', () => {
expect(isNotFound(requestError(404))).toBe(true)
})

it('is false for another status', () => {
expect(isNotFound(requestError(409))).toBe(false)
})

it('is false for an error with no status', () => {
expect(isNotFound(new CommsRequestError('network down'))).toBe(false)
})

it('is false for anything that is not a CommsRequestError', () => {
expect(isNotFound(new Error('nope'))).toBe(false)
expect(isNotFound({ httpStatusCode: 404 })).toBe(false)
expect(isNotFound(undefined)).toBe(false)
})
})

describe('isConflict', () => {
it('is true for a 409', () => {
expect(isConflict(requestError(409))).toBe(true)
})

it('is false for another status', () => {
expect(isConflict(requestError(404))).toBe(false)
})
})

describe('getCommsErrorCode', () => {
it('reads the numeric error_code out of the response body', () => {
expect(getCommsErrorCode(requestError(409, { error_code: 217 }))).toBe(217)
})

it('is null when the body carried no error_code', () => {
expect(getCommsErrorCode(requestError(409, { error_string: 'nope' }))).toBeNull()
expect(getCommsErrorCode(requestError(409))).toBeNull()
})

it('is null when error_code is not a number', () => {
expect(getCommsErrorCode(requestError(409, { error_code: '217' }))).toBeNull()
})

it('is null for anything that is not a CommsRequestError', () => {
expect(getCommsErrorCode({ responseData: { error_code: 217 } })).toBeNull()
})
})

describe('getCommsErrorString', () => {
it('reads the error_string out of the response body', () => {
const error = requestError(409, { error_string: 'id must decode to 16 bytes' })
expect(getCommsErrorString(error)).toBe('id must decode to 16 bytes')
})

it('is null when the body carried no error_string', () => {
expect(getCommsErrorString(requestError(409, { error_code: 217 }))).toBeNull()
expect(getCommsErrorString(requestError(409))).toBeNull()
})

it('is null when error_string is not a string', () => {
expect(getCommsErrorString(requestError(409, { error_string: 217 }))).toBeNull()
})

it('is null for anything that is not a CommsRequestError', () => {
expect(getCommsErrorString({ responseData: { error_string: 'nope' } })).toBeNull()
})

it('does not fall back to the error message', () => {
// `message` is the SDK's own text; this reads the server's body only.
expect(getCommsErrorString(new CommsRequestError('server text', 409))).toBeNull()
})
})

describe('isMalformedId', () => {
it('is true for a 409 carrying error_code 217', () => {
expect(isMalformedId(requestError(409, { error_code: 217 }))).toBe(true)
})

it('is true whichever message the server pairs with 217', () => {
// The API sends 217 for both an id that does not decode to 16 bytes
// and one whose version nibble is not 7, so the code is what it keys on.
const decode = requestError(409, {
error_code: 217,
error_string: 'id must decode to 16 bytes',
})
const nibble = requestError(409, {
error_code: 217,
error_string: 'id must be UUIDv7 (version nibble mismatch)',
})
expect(isMalformedId(decode)).toBe(true)
expect(isMalformedId(nibble)).toBe(true)
})

it('is false for a 409 that is a genuine conflict', () => {
expect(isMalformedId(requestError(409, { error_code: 110 }))).toBe(false)
expect(isMalformedId(requestError(409))).toBe(false)
})

it('is false for 217 on another status', () => {
expect(isMalformedId(requestError(404, { error_code: 217 }))).toBe(false)
})
})
79 changes: 79 additions & 0 deletions src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,82 @@ export class CommsRequestError extends CustomError {
this.responseData = responseData
}
}

function hasStatus(error: unknown, status: number): error is CommsRequestError {
return error instanceof CommsRequestError && error.httpStatusCode === status
}

function getResponseField(error: unknown, field: string): unknown {
if (!(error instanceof CommsRequestError)) return undefined
const data = error.responseData
if (typeof data !== 'object' || data === null || !(field in data)) return undefined
return (data as Record<string, unknown>)[field]
}

/**
* The numeric `error_code` the API sends in an error body, when there was one.
*
* @param error - The thrown value to inspect.
* @returns The code, or `null` when the error is not a
* {@link CommsRequestError} or carried no numeric `error_code`.
*/
export function getCommsErrorCode(error: unknown): number | null {
const code = getResponseField(error, 'error_code')
return typeof code === 'number' ? code : null
}

/**
* The `error_string` the API sends alongside `error_code`, when there was one.
* It is a server-authored message rather than a stable contract, so branch on
* {@link getCommsErrorCode} and use this for display.
*
* @param error - The thrown value to inspect.
* @returns The message, or `null` when the error is not a
* {@link CommsRequestError} or carried no string `error_string`.
*/
export function getCommsErrorString(error: unknown): string | null {
const message = getResponseField(error, 'error_string')
return typeof message === 'string' ? message : null
}

/**
* True when the request failed with a 404.
*
* @example
* ```typescript
* try {
* await api.threads.getThread(id)
* } catch (error) {
* if (isNotFound(error)) return null
* throw error
* }
* ```
*/
export function isNotFound(error: unknown): boolean {
return hasStatus(error, 404)
}

/**
* True when the request failed with a 409. The API uses this both for genuine
* conflicts and for a malformed id, so narrow with {@link isMalformedId}
* before reporting one as the other.
*/
export function isConflict(error: unknown): boolean {
return hasStatus(error, 409)
}

/**
* True when the API rejected an id as malformed: a 409 carrying `error_code`
* 217, which it sends both for a value that does not base58-decode to 16 bytes
* and for one whose version nibble is not 7. That is a bad reference rather
* than a conflict, so it usually deserves a different message from
* {@link isConflict}.
*
* @example
* ```typescript
* if (isMalformedId(error)) throw new Error(`Not a valid Comms id: ${ref}`)
* ```
*/
export function isMalformedId(error: unknown): boolean {
return isConflict(error) && getCommsErrorCode(error) === 217
}
Loading