Skip to content

Commit

Permalink
PLU-108: [ACTIONABLE-ERRORS] Add new error format (#299)
Browse files Browse the repository at this point in the history
  • Loading branch information
m0nggh authored Nov 8, 2023
1 parent 6606533 commit 9c89d29
Show file tree
Hide file tree
Showing 13 changed files with 278 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { IGlobalVariable } from '@plumber/types'

import { AxiosError } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import HttpError from '@/errors/http'

import app from '../..'
import makeRequestAction from '../../actions/http-request'

Expand Down Expand Up @@ -47,7 +50,7 @@ describe('make http request', () => {
$.step.parameters.url = 'http://test.local/endpoint?1234'
mocks.httpRequest.mockReturnValue('mock response')

await makeRequestAction.run($)
await makeRequestAction.run($).catch(() => null)
expect(mocks.isUrlAllowed).toHaveBeenCalledOnce()
expect(mocks.httpRequest).toHaveBeenCalledWith({
url: $.step.parameters.url,
Expand All @@ -57,6 +60,25 @@ describe('make http request', () => {
})
})

it('should throw an error for error with http request', async () => {
$.step.parameters.method = 'POST'
$.step.parameters.data = 'meep meep'
$.step.parameters.url = 'http://test.local/endpoint?1234'
const error403 = {
response: {
status: 403,
statusText: 'forbidden',
},
} as AxiosError
const httpError = new HttpError(error403)
mocks.httpRequest.mockRejectedValueOnce(httpError)

// throw partial step error message
await expect(makeRequestAction.run($)).rejects.toThrowError(
'Status code: 403',
)
})

it('should throw an error if url is not malformed', async () => {
$.step.parameters.url = 'malformed-urll'
await expect(makeRequestAction.run($)).rejects.toThrowError('Invalid URL')
Expand Down
29 changes: 21 additions & 8 deletions packages/backend/src/apps/custom-api/actions/http-request/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { IRawAction } from '@plumber/types'

import { URL } from 'url'

import { generateHttpStepError } from '@/helpers/generate-step-error'

import { isUrlAllowed } from '../../common/ip-resolver'

type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
Expand Down Expand Up @@ -59,14 +61,25 @@ const action: IRawAction = {
throw new Error('The URL you are trying to call is not allowed.')
}

const response = await $.http.request({
url,
method,
data,
// Redirects open up way too many vulns (e.g. someone changes the
// redirect target to a malicious endpoint), so disable it.
maxRedirects: 0,
})
const response = await $.http
.request({
url,
method,
data,
// Redirects open up way too many vulns (e.g. someone changes the
// redirect target to a malicious endpoint), so disable it.
maxRedirects: 0,
})
.catch((err): never => {
const stepErrorSolution =
'Check your custom app based on the status code and retry again.'
throw generateHttpStepError(
err,
stepErrorSolution,
$.step.position,
$.app.name,
)
})

let responseData = response.data

Expand Down
12 changes: 10 additions & 2 deletions packages/backend/src/apps/delay/actions/delay-until/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { IRawAction } from '@plumber/types'

import { generateStepError } from '@/helpers/generate-step-error'

import generateTimestamp from '../../helpers/generate-timestamp'

const action: IRawAction = {
Expand Down Expand Up @@ -35,8 +37,14 @@ const action: IRawAction = {
)

if (isNaN(delayTimestamp)) {
throw new Error(
'Invalid timestamp entered, please check that you keyed in the date and time in the correct format',
const stepErrorName = 'Invalid timestamp entered'
const stepErrorSolution =
'Click on set up action and check for the validity of the format of the date or time entered.'
throw generateStepError(
stepErrorName,
stepErrorSolution,
$.step.position,
$.app.name,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { IRawAction } from '@plumber/types'
import { SafeParseError } from 'zod'
import { fromZodError } from 'zod-validation-error'

import { generateStepError } from '@/helpers/generate-step-error'

import { sendTransactionalEmails } from '../../common/email-helper'
import {
transactionalEmailFields,
Expand Down Expand Up @@ -33,8 +35,21 @@ const action: IRawAction = {
})

if (!result.success) {
throw fromZodError((result as SafeParseError<unknown>).error)
const validationError = fromZodError(
(result as SafeParseError<unknown>).error,
)

const stepErrorName = validationError.details[0].message
const stepErrorSolution =
'Click on set up action and reconfigure the invalid field.'
throw generateStepError(
stepErrorName,
stepErrorSolution,
$.step.position,
$.app.name,
)
}

const { recipients, newProgress } = await getRatelimitedRecipientList(
result.data.destinationEmail,
+progress,
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/errors/step.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IStepError } from '@plumber/types'

export default class StepError extends Error {
constructor(error: IStepError, options?: ErrorOptions) {
const computedMessage = JSON.stringify(error)
super(computedMessage, options)
this.name = this.constructor.name
}
}
37 changes: 37 additions & 0 deletions packages/backend/src/helpers/generate-step-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { IJSONObject } from '@plumber/types'

import HttpError from '@/errors/http'
import StepError from '@/errors/step'

export function generateHttpStepError(
error: HttpError,
solution: string,
position: number,
appName: string,
) {
const stepErrorName = `Status code: ${error.response.status} (${error.response.statusText})`
return new StepError(
{
name: stepErrorName,
solution,
position: position.toString(),
appName,
details: error.details as IJSONObject,
},
{ cause: error },
)
}

export function generateStepError(
name: string,
solution: string,
position: number,
appName: string,
) {
return new StepError({
name,
solution,
position: position.toString(),
appName,
})
}
5 changes: 5 additions & 0 deletions packages/backend/src/services/action.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type IActionRunResult, NextStepMetadata } from '@plumber/types'

import HttpError from '@/errors/http'
import StepError from '@/errors/step'
import computeParameters from '@/helpers/compute-parameters'
import globalVariable from '@/helpers/global-variable'
import logger from '@/helpers/logger'
Expand Down Expand Up @@ -71,6 +72,10 @@ export const processAction = async (options: ProcessActionOptions) => {
}
} catch (error) {
logger.error(error)
// log raw http error from StepError
if (error instanceof StepError && error.cause) {
logger.error(error.cause)
}
if (error instanceof HttpError) {
$.actionOutput.error = {
details: error.details,
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"baseUrl": "./src",
"declaration": true,
"esModuleInterop": true,
"lib": ["es2021"],
"lib": ["es2021", "ES2022.Error"],
"module": "commonjs",
"moduleResolution": "node",
"noImplicitAny": true,
Expand Down
62 changes: 62 additions & 0 deletions packages/frontend/src/components/TestSubstep/ErrorResult.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { IStepError } from '@plumber/types'

import { useCallback, useState } from 'react'
import { Box, Collapse, Text } from '@chakra-ui/react'
import { Badge, Button, Infobox } from '@opengovsg/design-system-react'
import JSONViewer from 'components/JSONViewer'

interface ErrorResultProps {
errorDetails: IStepError
}

const contactPlumberMessage =
'If this error still persists, contact us at [email protected].'

export default function ErrorResult(props: ErrorResultProps) {
const { errorDetails } = props
const { name, solution, position, appName, details } = errorDetails
const [isOpen, setIsOpen] = useState(false)
const toggleDropdown = useCallback(() => {
setIsOpen((value) => !value)
}, [])

return (
<Infobox variant="error">
<Box>
<Badge
mb={2}
bg="interaction.critical-subtle.default"
color="interaction.critical.default"
>
<Text>{`Step ${position}: ${appName} error`}</Text>
</Badge>

<Text mb={0.5} textStyle="subhead-1">
{name}
</Text>

<Text textStyle="body-1">
{solution} {contactPlumberMessage}{' '}
{details && (
<>
<Button
onClick={toggleDropdown}
variant="link"
size="sm"
sx={{ textDecoration: 'underline' }}
>
View http error details below.
</Button>

<Box>
<Collapse in={isOpen}>
<JSONViewer data={details}></JSONViewer>
</Collapse>
</Box>
</>
)}
</Text>
</Box>
</Infobox>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { IJSONObject } from '@plumber/types'

import { useCallback, useState } from 'react'
import { Box, Collapse, Text } from '@chakra-ui/react'
import { Button, Infobox } from '@opengovsg/design-system-react'
import JSONViewer from 'components/JSONViewer'

interface GenericErrorResultProps {
errorDetails: IJSONObject
}

export default function GenericErrorResult(props: GenericErrorResultProps) {
const { errorDetails } = props
const [isOpen, setIsOpen] = useState(false)
const toggleDropdown = useCallback(() => {
setIsOpen((value) => !value)
}, [])

return (
<Infobox variant="error">
<Box>
<Text mb={2} textStyle="subhead-1">
We could not test this step
</Text>

<Text textStyle="body-1">
Check if you have configured the steps above correctly and retest. If
this error still persists, contact us at [email protected].{' '}
<Button
onClick={toggleDropdown}
variant="link"
size="sm"
sx={{ textDecoration: 'underline' }}
>
View error details below.
</Button>
</Text>

<Box>
<Collapse in={isOpen}>
<JSONViewer data={errorDetails}></JSONViewer>
</Collapse>
</Box>
</Box>
</Infobox>
)
}
Loading

0 comments on commit 9c89d29

Please sign in to comment.