Skip to content

Commit

Permalink
chore(open-payments): parse error objects, and return error codes (#484)
Browse files Browse the repository at this point in the history
* chore(open-payments): parse error objects, and return error codes

* chore(open-payments): add missing assertions, update tests

* chore(open-payments): update README

* chore(open-payments): add changeset

* chore(open-payments): simplify assertion
  • Loading branch information
mkurapov committed Jul 3, 2024
1 parent 5851525 commit aa21d26
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/warm-geese-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@interledger/open-payments': minor
---

Adding functionality to parse error objects from Open Payments API responses, and expose new `code` field in `OpenPaymentsClientError`.
1 change: 1 addition & 0 deletions packages/open-payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ try {
console.log(error.message)
console.log(error.description) // additional description of the error
console.log(error.status) // the HTTP status of the request, if a request failure
console.log(error.code) // the error code from the Open Payments API
console.log(error.validationErrors) // an array of validation errors. Populated if the response of the request failed OpenAPI specfication validation, or other validation checks.
} else {
console.log(error)
Expand Down
3 changes: 3 additions & 0 deletions packages/open-payments/src/client/error.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
interface ErrorDetails {
description: string
status?: number
code?: string
validationErrors?: string[]
}

export class OpenPaymentsClientError extends Error {
public description: string
public validationErrors?: string[]
public status?: number
public code?: string

constructor(message: string, args: ErrorDetails) {
super(message)
this.name = 'OpenPaymentsClientError'
this.description = args.description
this.status = args.status
this.code = args.code
this.validationErrors = args.validationErrors
}
}
3 changes: 3 additions & 0 deletions packages/open-payments/src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe('Client', (): void => {
})

test('throws error if could not load private key as Buffer', async (): Promise<void> => {
expect.assertions(2)
try {
await createAuthenticatedClient({
logger: silentLogger,
Expand All @@ -92,6 +93,7 @@ describe('Client', (): void => {
})

test('throws error if could not load private key', async (): Promise<void> => {
expect.assertions(2)
try {
await createAuthenticatedClient({
logger: silentLogger,
Expand All @@ -116,6 +118,7 @@ describe('Client', (): void => {
`(
'throws an error if both authenticatedRequestInterceptor and privateKey or keyId are provided',
async ({ keyId, privateKey }) => {
expect.assertions(2)
try {
// @ts-expect-error Invalid args
await createAuthenticatedClient({
Expand Down
6 changes: 4 additions & 2 deletions packages/open-payments/src/client/outgoing-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ describe('outgoing-payment', (): void => {
.query({ 'wallet-address': walletAddress })
.reply(200, outgoingPaymentPaginationResult)

expect.assertions(3)
try {
await listOutgoingPayments(
deps,
Expand Down Expand Up @@ -278,7 +279,7 @@ describe('outgoing-payment', (): void => {
},
openApiValidators.failedValidator
)
).rejects.toThrowError()
).rejects.toThrow()
scope.done()
})
})
Expand Down Expand Up @@ -355,6 +356,7 @@ describe('outgoing-payment', (): void => {
.post('/outgoing-payments')
.reply(200, outgoingPayment)

expect.assertions(3)
try {
await createOutgoingPayment(
deps,
Expand Down Expand Up @@ -402,7 +404,7 @@ describe('outgoing-payment', (): void => {
walletAddress
}
)
).rejects.toThrowError(OpenPaymentsClientError)
).rejects.toThrow(OpenPaymentsClientError)
scope.done()
})
})
Expand Down
150 changes: 149 additions & 1 deletion packages/open-payments/src/client/requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createHttpClient,
deleteRequest,
get,
handleError,
HttpClient,
post,
requestShouldBeAuthorized,
Expand All @@ -12,7 +13,7 @@ import nock from 'nock'
import { createTestDeps, mockOpenApiResponseValidators } from '../test/helpers'
import { OpenPaymentsClientError } from './error'
import assert from 'assert'
import ky from 'ky'
import ky, { HTTPError } from 'ky'
import { BaseDeps } from '.'

const HTTP_SIGNATURE_REGEX = /sig1=:([a-zA-Z0-9+/]){86}==:/
Expand Down Expand Up @@ -817,4 +818,151 @@ describe('requests', (): void => {
)
})
})

describe('handleError', (): void => {
test('handles HTTP error with expected JSON response', async (): Promise<void> => {
const url = 'https://localhost:1000/'
const request = new Request(url)
const response = new Response(
JSON.stringify({
error: {
code: 'invalid_client',
description: 'Could not determine client'
}
}),
{ status: 400 }
)

expect.assertions(5)
try {
await handleError(deps, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
error: new HTTPError(response, request, undefined!),
requestType: 'POST',
url
})
} catch (error) {
assert.ok(error instanceof OpenPaymentsClientError)
expect(error.message).toBe('Error making Open Payments POST request')
expect(error.description).toBe('Could not determine client')
expect(error.code).toBe('invalid_client')
expect(error.status).toBe(400)
expect(error.validationErrors).toBeUndefined()
}
})

test('handles HTTP error with unexpected JSON response', async (): Promise<void> => {
const url = 'https://localhost:1000/'
const request = new Request(url)
const responseBody = {
unexpected: 'response'
}
const response = new Response(JSON.stringify(responseBody), {
status: 400
})

expect.assertions(5)
try {
await handleError(deps, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
error: new HTTPError(response, request, undefined!),
requestType: 'POST',
url
})
} catch (error) {
assert.ok(error instanceof OpenPaymentsClientError)
expect(error.message).toBe('Error making Open Payments POST request')
expect(error.description).toBe(JSON.stringify(responseBody))
expect(error.code).toBeUndefined()
expect(error.status).toBe(400)
expect(error.validationErrors).toBeUndefined()
}
})

test('handles HTTP error with text response', async (): Promise<void> => {
const url = 'https://localhost:1000/'
const request = new Request(url)
const response = new Response('Bad Request', { status: 400 })

expect.assertions(5)
try {
await handleError(deps, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
error: new HTTPError(response, request, undefined!),
requestType: 'POST',
url
})
} catch (error) {
assert.ok(error instanceof OpenPaymentsClientError)
expect(error.message).toBe('Error making Open Payments POST request')
expect(error.description).toBe('Bad Request')
expect(error.code).toBeUndefined()
expect(error.status).toBe(400)
expect(error.validationErrors).toBeUndefined()
}
})

test('handles validation error', async (): Promise<void> => {
const url = 'https://localhost:1000/'

expect.assertions(5)
try {
await handleError(deps, {
error: {
status: 400,
errors: [{ message: 'invalid request' }]
},
requestType: 'POST',
url
})
} catch (error) {
assert.ok(error instanceof OpenPaymentsClientError)
expect(error.message).toBe('Error making Open Payments POST request')
expect(error.description).toBe('Could not validate OpenAPI response')
expect(error.code).toBeUndefined()
expect(error.status).toBe(400)
expect(error.validationErrors).toEqual(['invalid request'])
}
})

test('handles ordinary error', async (): Promise<void> => {
const url = 'https://localhost:1000/'

expect.assertions(5)
try {
await handleError(deps, {
error: new Error('Something went wrong'),
requestType: 'POST',
url
})
} catch (error) {
assert.ok(error instanceof OpenPaymentsClientError)
expect(error.message).toBe('Error making Open Payments POST request')
expect(error.description).toBe('Something went wrong')
expect(error.code).toBeUndefined()
expect(error.status).toBeUndefined()
expect(error.validationErrors).toBeUndefined()
}
})

test('handles unexpected (non-object) error', async (): Promise<void> => {
const url = 'https://localhost:1000/'

expect.assertions(5)
try {
await handleError(deps, {
error: 'unexpected error',
requestType: 'POST',
url
})
} catch (error) {
assert.ok(error instanceof OpenPaymentsClientError)
expect(error.message).toBe('Error making Open Payments POST request')
expect(error.description).toBe('Received unexpected error')
expect(error.code).toBeUndefined()
expect(error.status).toBeUndefined()
expect(error.validationErrors).toBeUndefined()
}
})
})
})
34 changes: 25 additions & 9 deletions packages/open-payments/src/client/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ interface HandleErrorArgs {
requestType: 'POST' | 'DELETE' | 'GET'
}

const handleError = async (
export const handleError = async (
deps: BaseDeps,
args: HandleErrorArgs
): Promise<never> => {
Expand All @@ -158,23 +158,32 @@ const handleError = async (
let errorDescription
let errorStatus
let validationErrors
let errorCode

const { HTTPError } = await import('ky')

if (error instanceof HTTPError) {
let responseBody
let responseBody:
| {
error: { description: string; code: string }
}
| string
| undefined

try {
responseBody = (await error.response.json()) as { message: string }
responseBody = await error.response.text()
responseBody = JSON.parse(responseBody)
} catch {
// Ignore if we can't parse the response body (or no body exists)
}

errorStatus = error.response.status
errorDescription =
responseBody && responseBody.message
? responseBody.message
: error.message
errorStatus = error.response?.status
typeof responseBody === 'object'
? responseBody.error?.description || JSON.stringify(responseBody)
: responseBody || error.message
errorCode =
typeof responseBody === 'object' ? responseBody.error?.code : undefined
} else if (isValidationError(error)) {
errorDescription = 'Could not validate OpenAPI response'
validationErrors = error.errors.map((e) => e.message)
Expand All @@ -188,14 +197,21 @@ const handleError = async (

const errorMessage = `Error making Open Payments ${requestType} request`
deps.logger.error(
{ status: errorStatus, errorDescription, url, requestType },
{
method: requestType,
url,
status: errorStatus,
description: errorDescription,
code: errorCode
},
errorMessage
)

throw new OpenPaymentsClientError(errorMessage, {
description: errorDescription,
validationErrors,
status: errorStatus
status: errorStatus,
code: errorCode
})
}

Expand Down

0 comments on commit aa21d26

Please sign in to comment.