Skip to content

Commit

Permalink
Improve errors
Browse files Browse the repository at this point in the history
Renamed `TransloaditError` to `ApiError`. Differences between `TransloaditError` and `ApiError`:
- Moved `TransloaditError.response.body` to `ApiError.response`
- Removed `TransloaditError.assemblyId` (can now be found in `ApiError.response.assembly_id`
- Removed `TransloaditError.transloaditErrorCode` (can now be found in `ApiError.response.error`
- `ApiError` does not inherit from `got.HTTPError`, but `ApiError.cause` will be the `got.HTTPError` instance that caused this error (except for when Tranloadit API responds with HTTP 200 and `error` prop set in JSON response, in which case cause will be `undefined`).

Note that (just like before) when the Transloadit API responds with an error we will always throw a `ApiError` - In all other cases (like request timeout, connection error, TypeError etc.), we don't wrap the error in `ApiError`.

Also improved error stack traces, added a unit test in `mock-http.test.ts` that verifies the stack trace.
  • Loading branch information
mifi committed Dec 13, 2024
1 parent 0e0e9e1 commit c6e68f9
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 174 deletions.
90 changes: 45 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,45 +56,41 @@ const transloadit = new Transloadit({
authSecret: 'YOUR_TRANSLOADIT_SECRET',
})

;(async () => {
try {
const options = {
files: {
file1: '/PATH/TO/FILE.jpg',
},
params: {
steps: {
// You can have many Steps. In this case we will just resize any inputs (:original)
resize: {
use: ':original',
robot: '/image/resize',
result: true,
width: 75,
height: 75,
},
try {
const options = {
files: {
file1: '/PATH/TO/FILE.jpg',
},
params: {
steps: {
// You can have many Steps. In this case we will just resize any inputs (:original)
resize: {
use: ':original',
robot: '/image/resize',
result: true,
width: 75,
height: 75,
},
// OR if you already created a template, you can use it instead of "steps":
// template_id: 'YOUR_TEMPLATE_ID',
},
waitForCompletion: true, // Wait for the Assembly (job) to finish executing before returning
}

const status = await transloadit.createAssembly(options)

if (status.results.resize) {
console.log('✅ Success - Your resized image:', status.results.resize[0].ssl_url)
} else {
console.log(
"❌ The Assembly didn't produce any output. Make sure you used a valid image file"
)
}
} catch (err) {
console.error('❌ Unable to process Assembly.', err)
if (err.cause?.assembly_id) {
console.error(`💡 More info: https://transloadit.com/assemblies/${err.cause?.assembly_id}`)
}
// OR if you already created a template, you can use it instead of "steps":
// template_id: 'YOUR_TEMPLATE_ID',
},
waitForCompletion: true, // Wait for the Assembly (job) to finish executing before returning
}

const status = await transloadit.createAssembly(options)

if (status.results.resize) {
console.log('✅ Success - Your resized image:', status.results.resize[0].ssl_url)
} else {
console.log("❌ The Assembly didn't produce any output. Make sure you used a valid image file")
}
})()
} catch (err) {
console.error('❌ Unable to process Assembly.', err)
if (err instanceof ApiError && err.response.assembly_id) {
console.error(`💡 More info: https://transloadit.com/assemblies/${err.response.assembly_id}`)
}
}
```

You can find [details about your executed Assemblies here](https://transloadit.com/assemblies).
Expand Down Expand Up @@ -419,31 +415,35 @@ const url = client.getSignedSmartCDNUrl({

### Errors

Errors from Node.js will be passed on and we use [GOT](https://github.com/sindresorhus/got) for HTTP requests and errors from there will also be passed on. When the HTTP response code is not 200, the error will be an `HTTPError`, which is a [got.HTTPError](https://github.com/sindresorhus/got#errors)) with some additional properties:
Any errors originating from Node.js will be passed on and we use [GOT](https://github.com/sindresorhus/got) v11 for HTTP requests. [Errors from `got`](https://github.com/sindresorhus/got/tree/v11.8.6?tab=readme-ov-file#errors) will also be passed on, _except_ the `got.HTTPError` which will be replaced with a `transloadit.ApiError`, which will have its `cause` property set to the instance of the original `got.HTTPError`. `transloadit.ApiError` has these properties:

- **(deprecated: use `cause` instead)** `HTTPError.response?.body` the JSON object returned by the server along with the error response (**note**: `HTTPError.response` will be `undefined` for non-server errors)
- **(deprecated)** `HTTPError.transloaditErrorCode` alias for `HTTPError.cause?.error` ([View all error codes](https://transloadit.com/docs/api/response-codes/#error-codes))
- `HTTPError.assemblyId` (alias for `HTTPError.response.body.assembly_id`, if the request regards an [Assembly](https://transloadit.com/docs/api/assemblies-assembly-id-get/))
- `HTTPError.response` the JSON object returned by the server. It has these properties
- `error` (`string`) - [The Transloadit API error code](https://transloadit.com/docs/api/response-codes/#error-codes).
- `message` (`string`) - A textual representation of the Transloadit API error.
- `assembly_id`: (`string`) - If the request is related to an assembly, this will be the ID of the assembly.
- `assembly_ssl_url` (`string`) - If the request is related to an assembly, this will be the SSL URL to the assembly .

To identify errors you can either check its props or use `instanceof`, e.g.:

```js
catch (err) {
if (err instanceof TimeoutError) {
try {
await transloadit.createAssembly(options)
} catch (err) {
if (err instanceof got.TimeoutError) {
return console.error('The request timed out', err)
}
if (err.code === 'ENOENT') {
return console.error('Cannot open file', err)
}
if (err.cause?.error === 'ASSEMBLY_INVALID_STEPS') {
if (err instanceof transloadit.ApiError && err.response.error === 'ASSEMBLY_INVALID_STEPS') {
return console.error('Invalid Assembly Steps', err)
}
}
```

**Note:** Assemblies that have an error status (`assembly.error`) will only result in an error thrown from `createAssembly` and `replayAssembly`. For other Assembly methods, no errors will be thrown, but any error can be found in the response's `error` property
**Note:** Assemblies that have an error status (`assembly.error`) will only result in an error being thrown from `createAssembly` and `replayAssembly`. For other Assembly methods, no errors will be thrown, but any error can be found in the response's `error` property

- [More information on Transloadit errors (`cause.error`)](https://transloadit.com/docs/api/response-codes/#error-codes)
- [More information on Transloadit errors (`ApiError.response.error`)](https://transloadit.com/docs/api/response-codes/#error-codes)
- [More information on request errors](https://github.com/sindresorhus/got#errors)

### Rate limiting & auto retry
Expand Down
4 changes: 2 additions & 2 deletions examples/retry.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// yarn prepack
//
const pRetry = require('p-retry')
const { Transloadit, TransloaditError } = require('transloadit')
const { Transloadit, ApiError } = require('transloadit')

const transloadit = new Transloadit({
authKey: /** @type {string} */ (process.env.TRANSLOADIT_KEY),
Expand All @@ -22,7 +22,7 @@ async function run() {
const { items } = await transloadit.listTemplates({ sort: 'created', order: 'asc' })
return items
} catch (err) {
if (err instanceof TransloaditError && err.cause?.error === 'INVALID_SIGNATURE') {
if (err instanceof ApiError && err.response.error === 'INVALID_SIGNATURE') {
// This is an unrecoverable error, abort retry
throw new pRetry.AbortError('INVALID_SIGNATURE')
}
Expand Down
50 changes: 50 additions & 0 deletions src/ApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { HTTPError } from 'got'

export interface TransloaditErrorResponseBody {
error?: string
message?: string
http_code?: string
assembly_ssl_url?: string
assembly_id?: string
}

export class ApiError extends Error {
override name = 'ApiError'

response: TransloaditErrorResponseBody

override cause?: HTTPError | undefined

constructor(params: {
cause?: HTTPError
appendStack?: string
body: TransloaditErrorResponseBody | undefined
}) {
const { cause, body, appendStack } = params

const parts = ['API error']
if (cause?.response.statusCode) parts.push(`(HTTP ${cause.response.statusCode})`)
if (body?.error) parts.push(`${body.error}:`)
if (body?.message) parts.push(body.message)
if (body?.assembly_ssl_url) parts.push(body.assembly_ssl_url)

const message = parts.join(' ')

super(message)

// if we have a cause, use the stack trace from it instead
if (cause != null && typeof cause.stack === 'string') {
const indexOfMessageEnd = cause.stack.indexOf(cause.message) + cause.message.length
const gotStacktrace = cause.stack.slice(indexOfMessageEnd)
this.stack = `${message}${gotStacktrace}`
}

// If we have an original stack, append it to the bottom, because `got`s stack traces are not very good
if (this.stack != null && appendStack != null) {
this.stack += `\n${appendStack.replace(/^([^\n]+\n)/, '')}`
}

this.response = body ?? {}
this.cause = cause
}
}
76 changes: 15 additions & 61 deletions src/Transloadit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHmac, randomUUID } from 'crypto'
import got, { RequiredRetryOptions, Headers, OptionsOfJSONResponseBody, HTTPError } from 'got'
import got, { RequiredRetryOptions, Headers, OptionsOfJSONResponseBody } from 'got'
import FormData from 'form-data'
import { constants, createReadStream } from 'fs'
import { access } from 'fs/promises'
Expand All @@ -11,13 +11,13 @@ import pMap from 'p-map'
import { InconsistentResponseError } from './InconsistentResponseError'
import { PaginationStream } from './PaginationStream'
import { PollingTimeoutError } from './PollingTimeoutError'
import { TransloaditResponseBody, TransloaditError } from './TransloaditError'
import { TransloaditErrorResponseBody, ApiError } from './ApiError'
import { version } from '../package.json'
import { sendTusRequest, Stream } from './tus'

import type { Readable } from 'stream'

// See https://github.com/sindresorhus/got#errors
// See https://github.com/sindresorhus/got/tree/v11.8.6?tab=readme-ov-file#errors
// Expose relevant errors
export {
RequestError,
Expand All @@ -28,7 +28,7 @@ export {
MaxRedirectsError,
TimeoutError,
} from 'got'
export { InconsistentResponseError, TransloaditError }
export { InconsistentResponseError, ApiError }

const log = debug('transloadit')
const logWarn = debug('transloadit:warn')
Expand All @@ -47,60 +47,6 @@ interface CreateAssemblyPromise extends Promise<Assembly> {
assemblyId: string
}

function getTransloaditErrorPropsFromBody(err: Error, body: TransloaditResponseBody) {
let newMessage = err.message
let newStack = err.stack

// Provide a more useful message if there is one
if (body?.message && body?.error) newMessage += ` ${body.error}: ${body.message}`
else if (body?.error) newMessage += ` ${body.error}`

if (body?.assembly_ssl_url) newMessage += ` - ${body.assembly_ssl_url}`

if (typeof err.stack === 'string') {
const indexOfMessageEnd = err.stack.indexOf(err.message) + err.message.length
const stacktrace = err.stack.slice(indexOfMessageEnd)
newStack = `${newMessage}${stacktrace}`
}

return {
message: newMessage,
...(newStack != null && { stack: newStack }),
...(body?.assembly_id && { assemblyId: body.assembly_id }),
...(body?.error && { transloaditErrorCode: body.error }),
}
}

function decorateTransloaditError(err: HTTPError, body: TransloaditResponseBody): TransloaditError {
// todo improve this
const transloaditErr = err as HTTPError & TransloaditError
/* eslint-disable no-param-reassign */
if (body) transloaditErr.cause = body
const props = getTransloaditErrorPropsFromBody(err, body)
transloaditErr.message = props.message
if (props.stack != null) transloaditErr.stack = props.stack
if (props.assemblyId) transloaditErr.assemblyId = props.assemblyId
if (props.transloaditErrorCode) transloaditErr.transloaditErrorCode = props.transloaditErrorCode
/* eslint-enable no-param-reassign */

return transloaditErr
}

function makeTransloaditError(err: Error, body: TransloaditResponseBody): TransloaditError {
const transloaditErr = new TransloaditError(err.message, body)
// todo improve this
/* eslint-disable no-param-reassign */
if (body) transloaditErr.cause = body
const props = getTransloaditErrorPropsFromBody(err, body)
transloaditErr.message = props.message
if (props.stack != null) transloaditErr.stack = props.stack
if (props.assemblyId) transloaditErr.assemblyId = props.assemblyId
if (props.transloaditErrorCode) transloaditErr.transloaditErrorCode = props.transloaditErrorCode
/* eslint-enable no-param-reassign */

return transloaditErr
}

// Not sure if this is still a problem with the API, but throw a special error type so the user can retry if needed
function checkAssemblyUrls(result: Assembly) {
if (result.assembly_url == null || result.assembly_ssl_url == null) {
Expand All @@ -114,14 +60,14 @@ function getHrTimeMs(): number {

function checkResult<T>(result: T | { error: string }): asserts result is T {
// In case server returned a successful HTTP status code, but an `error` in the JSON object
// This happens sometimes when createAssembly with an invalid file (IMPORT_FILE_ERROR)
// This happens sometimes, for example when createAssembly with an invalid file (IMPORT_FILE_ERROR)
if (
typeof result === 'object' &&
result !== null &&
'error' in result &&
typeof result.error === 'string'
) {
throw makeTransloaditError(new Error('Error in response'), result)
throw new ApiError({ body: result }) // in this case there is no `cause` because we don't have a HTTPError
}
}

Expand Down Expand Up @@ -786,6 +732,10 @@ export class Transloadit {
responseType: 'json',
}

// `got` stacktraces are very lacking, so we capture our own
// https://github.com/sindresorhus/got/blob/main/documentation/async-stack-traces.md
const stack = new Error().stack

try {
const request = got[method]<T>(url, requestOpts)
const { body } = await request
Expand Down Expand Up @@ -814,7 +764,11 @@ export class Transloadit {
retryCount < this._maxRetries
)
) {
throw decorateTransloaditError(err, body as TransloaditResponseBody) // todo improve
throw new ApiError({
cause: err,
appendStack: stack,
body: body as TransloaditErrorResponseBody,
}) // todo don't assert type
}

const { retryIn: retryInSec } = body.info
Expand Down
35 changes: 0 additions & 35 deletions src/TransloaditError.ts

This file was deleted.

10 changes: 3 additions & 7 deletions test/integration/live-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,10 @@ describe('API integration', { timeout: 60000 }, () => {
const promise = createAssembly(client, opts)
await promise.catch((err) => {
expect(err).toMatchObject({
transloaditErrorCode: 'INVALID_INPUT_ERROR',
cause: expect.objectContaining({
response: expect.objectContaining({
error: 'INVALID_INPUT_ERROR',
assembly_id: expect.any(String),
}),
assemblyId: expect.any(String),
})
})
await expect(promise).rejects.toThrow(Error)
Expand Down Expand Up @@ -731,8 +729,7 @@ describe('API integration', { timeout: 60000 }, () => {
expect(ok).toBe('TEMPLATE_DELETED')
await expect(client.getTemplate(templId!)).rejects.toThrow(
expect.objectContaining({
transloaditErrorCode: 'TEMPLATE_NOT_FOUND',
cause: expect.objectContaining({
response: expect.objectContaining({
error: 'TEMPLATE_NOT_FOUND',
}),
})
Expand Down Expand Up @@ -805,8 +802,7 @@ describe('API integration', { timeout: 60000 }, () => {
expect(ok).toBe('TEMPLATE_CREDENTIALS_DELETED')
await expect(client.getTemplateCredential(credId!)).rejects.toThrow(
expect.objectContaining({
transloaditErrorCode: 'TEMPLATE_CREDENTIALS_NOT_READ',
cause: expect.objectContaining({
response: expect.objectContaining({
error: 'TEMPLATE_CREDENTIALS_NOT_READ',
}),
})
Expand Down
Loading

0 comments on commit c6e68f9

Please sign in to comment.