transloadit
Version:
Node.js SDK for Transloadit
922 lines (806 loc) • 28.8 kB
text/typescript
import * as assert from 'node:assert'
import { createHmac, randomUUID } from 'node:crypto'
import { constants, createReadStream } from 'node:fs'
import { access } from 'node:fs/promises'
import type { Readable } from 'node:stream'
import debug from 'debug'
import FormData from 'form-data'
import got, {
type Delays,
type Headers,
HTTPError,
type OptionsOfJSONResponseBody,
RequestError,
type RetryOptions,
} from 'got'
import intoStream, { type Input as IntoStreamInput } from 'into-stream'
import { isReadableStream, isStream } from 'is-stream'
import pMap from 'p-map'
import packageJson from '../package.json' with { type: 'json' }
import { ApiError, type TransloaditErrorResponseBody } from './ApiError.ts'
import {
type AssemblyIndex,
type AssemblyIndexItem,
type AssemblyStatus,
assemblyIndexSchema,
assemblyStatusSchema,
} from './alphalib/types/assemblyStatus.ts'
import { zodParseWithContext } from './alphalib/zodParseWithContext.ts'
import type {
BaseResponse,
BillResponse,
CreateAssemblyParams,
CreateTemplateCredentialParams,
CreateTemplateParams,
EditTemplateParams,
ListAssembliesParams,
ListedTemplate,
ListTemplateCredentialsParams,
ListTemplatesParams,
OptionalAuthParams,
PaginationListWithCount,
ReplayAssemblyNotificationParams,
ReplayAssemblyNotificationResponse,
ReplayAssemblyParams,
ReplayAssemblyResponse,
TemplateCredentialResponse,
TemplateCredentialsResponse,
TemplateResponse,
} from './apiTypes.ts'
import InconsistentResponseError from './InconsistentResponseError.ts'
import PaginationStream from './PaginationStream.ts'
import PollingTimeoutError from './PollingTimeoutError.ts'
import { type Stream, sendTusRequest } from './tus.ts'
// See https://github.com/sindresorhus/got/tree/v11.8.6?tab=readme-ov-file#errors
// Expose relevant errors
export {
HTTPError,
MaxRedirectsError,
ParseError,
ReadError,
RequestError,
TimeoutError,
UploadError,
} from 'got'
export type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts'
export * from './apiTypes.ts'
export { InconsistentResponseError, ApiError }
const log = debug('transloadit')
const logWarn = debug('transloadit:warn')
export interface UploadProgress {
uploadedBytes?: number | undefined
totalBytes?: number | undefined
}
const { version } = packageJson
export type AssemblyProgress = (assembly: AssemblyStatus) => void
export interface CreateAssemblyOptions {
params?: CreateAssemblyParams
files?: {
[name: string]: string
}
uploads?: {
[name: string]: Readable | IntoStreamInput
}
waitForCompletion?: boolean
chunkSize?: number
uploadConcurrency?: number
timeout?: number
onUploadProgress?: (uploadProgress: UploadProgress) => void
onAssemblyProgress?: AssemblyProgress
assemblyId?: string
}
export interface AwaitAssemblyCompletionOptions {
onAssemblyProgress?: AssemblyProgress
timeout?: number
interval?: number
startTimeMs?: number
}
export interface SmartCDNUrlOptions {
/**
* Workspace slug
*/
workspace: string
/**
* Template slug or template ID
*/
template: string
/**
* Input value that is provided as `${fields.input}` in the template
*/
input: string
/**
* Additional parameters for the URL query string
*/
urlParams?: Record<string, boolean | number | string | (boolean | number | string)[]>
/**
* Expiration timestamp of the signature in milliseconds since UNIX epoch.
* Defaults to 1 hour from now.
*/
expiresAt?: number
}
export type Fields = Record<string, string | number>
// A special promise that lets the user immediately get the assembly ID (synchronously before the request is sent)
interface CreateAssemblyPromise extends Promise<AssemblyStatus> {
assemblyId: string
}
// 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: AssemblyStatus) {
if (result.assembly_url == null || result.assembly_ssl_url == null) {
throw new InconsistentResponseError('Server returned an incomplete assembly response (no URL)')
}
}
function getHrTimeMs(): number {
return Number(process.hrtime.bigint() / 1000000n)
}
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, 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 new ApiError({ body: result }) // in this case there is no `cause` because we don't have an HTTPError
}
}
export interface Options {
authKey: string
authSecret: string
endpoint?: string
maxRetries?: number
timeout?: number
gotRetry?: Partial<RetryOptions>
validateResponses?: boolean
}
export class Transloadit {
private _authKey: string
private _authSecret: string
private _endpoint: string
private _maxRetries: number
private _defaultTimeout: number
private _gotRetry: Partial<RetryOptions>
private _lastUsedAssemblyUrl = ''
private _validateResponses = false
constructor(opts: Options) {
if (opts?.authKey == null) {
throw new Error('Please provide an authKey')
}
if (opts.authSecret == null) {
throw new Error('Please provide an authSecret')
}
if (opts.endpoint?.endsWith('/')) {
throw new Error('Trailing slash in endpoint is not allowed')
}
this._authKey = opts.authKey
this._authSecret = opts.authSecret
this._endpoint = opts.endpoint || 'https://api2.transloadit.com'
this._maxRetries = opts.maxRetries != null ? opts.maxRetries : 5
this._defaultTimeout = opts.timeout != null ? opts.timeout : 60000
// Passed on to got https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md
this._gotRetry = opts.gotRetry != null ? opts.gotRetry : { limit: 0 }
if (opts.validateResponses != null) this._validateResponses = opts.validateResponses
}
getLastUsedAssemblyUrl(): string {
return this._lastUsedAssemblyUrl
}
setDefaultTimeout(timeout: number): void {
this._defaultTimeout = timeout
}
/**
* Create an Assembly
*
* @param opts assembly options
*/
createAssembly(opts: CreateAssemblyOptions = {}): CreateAssemblyPromise {
const {
params = {},
waitForCompletion = false,
chunkSize: requestedChunkSize = Number.POSITIVE_INFINITY,
uploadConcurrency = 10,
timeout = 24 * 60 * 60 * 1000, // 1 day
onUploadProgress = () => {},
onAssemblyProgress = () => {},
files = {},
uploads = {},
assemblyId,
} = opts
// Keep track of how long the request took
const startTimeMs = getHrTimeMs()
// Undocumented feature to allow specifying a custom assembly id from the client
// Not recommended for general use due to security. E.g if the user doesn't provide a cryptographically
// secure ID, then anyone could access the assembly.
let effectiveAssemblyId: string
if (assemblyId != null) {
effectiveAssemblyId = assemblyId
} else {
effectiveAssemblyId = randomUUID().replace(/-/g, '')
}
const urlSuffix = `/assemblies/${effectiveAssemblyId}`
// We want to be able to return the promise immediately with custom data
const promise = (async () => {
this._lastUsedAssemblyUrl = `${this._endpoint}${urlSuffix}`
await pMap(
Object.entries(files),
async ([, path]) => access(path, constants.F_OK | constants.R_OK),
{ concurrency: 5 },
)
// Convert uploads to streams
const streamsMap = Object.fromEntries(
Object.entries(uploads).map(([label, value]) => {
const isReadable = isReadableStream(value)
if (!isReadable && isStream(value)) {
// https://github.com/transloadit/node-sdk/issues/92
throw new Error(`Upload named "${label}" is not a Readable stream`)
}
return [label, isReadableStream(value) ? value : intoStream(value)]
}),
)
// Wrap in object structure (so we can store whether it's a pathless stream or not)
const allStreamsMap = Object.fromEntries<Stream>(
Object.entries(streamsMap).map(([label, stream]) => [label, { stream }]),
)
// Create streams from files too
for (const [label, path] of Object.entries(files)) {
const stream = createReadStream(path)
allStreamsMap[label] = { stream, path } // File streams have path
}
const allStreams = Object.values(allStreamsMap)
// Pause all streams
for (const { stream } of allStreams) {
stream.pause()
}
// If any stream emits error, we want to handle this and exit with error
const streamErrorPromise = new Promise<AssemblyStatus>((_resolve, reject) => {
for (const { stream } of allStreams) {
stream.on('error', reject)
}
})
const createAssemblyAndUpload = async () => {
const result: AssemblyStatus = await this._remoteJson({
urlSuffix,
method: 'post',
timeout: { request: timeout },
params,
fields: {
tus_num_expected_upload_files: allStreams.length,
},
})
checkResult(result)
if (Object.keys(allStreamsMap).length > 0) {
await sendTusRequest({
streamsMap: allStreamsMap,
assembly: result,
onProgress: onUploadProgress,
requestedChunkSize,
uploadConcurrency,
})
}
if (!waitForCompletion) return result
if (result.assembly_id == null) {
throw new InconsistentResponseError(
'Server returned an assembly response without an assembly_id after creation',
)
}
const awaitResult = await this.awaitAssemblyCompletion(result.assembly_id, {
timeout,
onAssemblyProgress,
startTimeMs,
})
checkResult(awaitResult)
return awaitResult
}
return Promise.race([createAssemblyAndUpload(), streamErrorPromise])
})()
// This allows the user to use or log the assemblyId even before it has been created for easier debugging
return Object.assign(promise, { assemblyId: effectiveAssemblyId })
}
async awaitAssemblyCompletion(
assemblyId: string,
{
onAssemblyProgress = () => {},
timeout,
startTimeMs = getHrTimeMs(),
interval = 1000,
}: AwaitAssemblyCompletionOptions = {},
): Promise<AssemblyStatus> {
assert.ok(assemblyId)
while (true) {
const result = await this.getAssembly(assemblyId)
// If 'ok' is not in result, it implies a terminal state (e.g., error, completed, canceled).
// If 'ok' is present, then we check if it's one of the non-terminal polling states.
if (
!('ok' in result) ||
(result.ok !== 'ASSEMBLY_UPLOADING' &&
result.ok !== 'ASSEMBLY_EXECUTING' &&
// ASSEMBLY_REPLAYING is not a valid 'ok' status for polling, it means it's done replaying.
// The API does not seem to have an ASSEMBLY_REPLAYING status in the typical polling loop.
// It's usually a final status from the replay endpoint.
// For polling, we only care about UPLOADING and EXECUTING.
// If a replay operation puts it into a pollable state, that state would be EXECUTING.
result.ok !== 'ASSEMBLY_REPLAYING') // This line might need review based on actual API behavior for replayed assembly polling
) {
return result // Done!
}
try {
onAssemblyProgress(result)
} catch (err) {
log('Caught onAssemblyProgress error', err)
}
const nowMs = getHrTimeMs()
if (timeout != null && nowMs - startTimeMs >= timeout) {
throw new PollingTimeoutError('Polling timed out')
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
}
maybeThrowInconsistentResponseError(message: string) {
const err = new InconsistentResponseError(message)
// @TODO, once our schemas have matured, we should remove the option and always throw the error here.
// But as it stands, schemas are new, and we can't easily update all customer's node-sdks,
// so there will be a long tail of throws if we enable this now.
if (this._validateResponses) {
throw err
}
console.error(
`---\nPlease report this error to Transloadit (support@transloadit.com). We are working on better schemas for our API and this looks like something we do not cover yet: \n\n${err}\nThank you in advance!\n---\n`,
)
}
/**
* Cancel the assembly
*
* @param assemblyId assembly ID
* @returns after the assembly is deleted
*/
async cancelAssembly(assemblyId: string): Promise<AssemblyStatus> {
const { assembly_ssl_url: url } = await this.getAssembly(assemblyId)
const rawResult = await this._remoteJson<Record<string, unknown>, OptionalAuthParams>({
url,
method: 'delete',
})
const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult)
if (!parsedResult.success) {
this.maybeThrowInconsistentResponseError(
`The API responded with data that does not match the expected schema while cancelling Assembly: ${assemblyId}.\n${parsedResult.humanReadable}`,
)
}
checkAssemblyUrls(rawResult as AssemblyStatus)
return rawResult as AssemblyStatus
}
/**
* Replay an Assembly
*
* @param assemblyId of the assembly to replay
* @param optional params
* @returns after the replay is started
*/
async replayAssembly(
assemblyId: string,
params: ReplayAssemblyParams = {},
): Promise<ReplayAssemblyResponse> {
const result: ReplayAssemblyResponse = await this._remoteJson({
urlSuffix: `/assemblies/${assemblyId}/replay`,
method: 'post',
...(Object.keys(params).length > 0 && { params }),
})
checkResult(result)
return result
}
/**
* Replay an Assembly notification
*
* @param assemblyId of the assembly whose notification to replay
* @param optional params
* @returns after the replay is started
*/
async replayAssemblyNotification(
assemblyId: string,
params: ReplayAssemblyNotificationParams = {},
): Promise<ReplayAssemblyNotificationResponse> {
return this._remoteJson({
urlSuffix: `/assembly_notifications/${assemblyId}/replay`,
method: 'post',
...(Object.keys(params).length > 0 && { params }),
})
}
/**
* List all assemblies
*
* @param params optional request options
* @returns list of Assemblies
*/
async listAssemblies(
params?: ListAssembliesParams,
): Promise<PaginationListWithCount<AssemblyIndexItem>> {
const rawResponse = await this._remoteJson<
PaginationListWithCount<Record<string, unknown>>,
ListAssembliesParams
>({
urlSuffix: '/assemblies',
method: 'get',
params: params || {},
})
if (
rawResponse == null ||
typeof rawResponse !== 'object' ||
!Array.isArray(rawResponse.items)
) {
throw new InconsistentResponseError(
'API response for listAssemblies is malformed or missing items array',
)
}
const parsedResult = zodParseWithContext(assemblyIndexSchema, rawResponse.items)
if (!parsedResult.success) {
this.maybeThrowInconsistentResponseError(
`API response for listAssemblies contained items that do not match the expected schema.\n${parsedResult.humanReadable}`,
)
}
return {
items: rawResponse.items as AssemblyIndex,
count: rawResponse.count,
}
}
streamAssemblies(params: ListAssembliesParams): Readable {
return new PaginationStream(async (page) => this.listAssemblies({ ...params, page }))
}
/**
* Get an Assembly
*
* @param assemblyId the Assembly Id
* @returns the retrieved Assembly
*/
async getAssembly(assemblyId: string): Promise<AssemblyStatus> {
const rawResult = await this._remoteJson<Record<string, unknown>, OptionalAuthParams>({
urlSuffix: `/assemblies/${assemblyId}`,
})
const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult)
if (!parsedResult.success) {
this.maybeThrowInconsistentResponseError(
`The API responded with data that does not match the expected schema while getting Assembly: ${assemblyId}.\n${parsedResult.humanReadable}`,
)
}
checkAssemblyUrls(rawResult as AssemblyStatus)
return rawResult as AssemblyStatus
}
/**
* Create a Credential
*
* @param params optional request options
* @returns when the Credential is created
*/
async createTemplateCredential(
params: CreateTemplateCredentialParams,
): Promise<TemplateCredentialResponse> {
return this._remoteJson({
urlSuffix: '/template_credentials',
method: 'post',
params: params || {},
})
}
/**
* Edit a Credential
*
* @param credentialId the Credential ID
* @param params optional request options
* @returns when the Credential is edited
*/
async editTemplateCredential(
credentialId: string,
params: CreateTemplateCredentialParams,
): Promise<TemplateCredentialResponse> {
return this._remoteJson({
urlSuffix: `/template_credentials/${credentialId}`,
method: 'put',
params: params || {},
})
}
/**
* Delete a Credential
*
* @param credentialId the Credential ID
* @returns when the Credential is deleted
*/
async deleteTemplateCredential(credentialId: string): Promise<BaseResponse> {
return this._remoteJson({
urlSuffix: `/template_credentials/${credentialId}`,
method: 'delete',
})
}
/**
* Get a Credential
*
* @param credentialId the Credential ID
* @returns when the Credential is retrieved
*/
async getTemplateCredential(credentialId: string): Promise<TemplateCredentialResponse> {
return this._remoteJson({
urlSuffix: `/template_credentials/${credentialId}`,
method: 'get',
})
}
/**
* List all TemplateCredentials
*
* @param params optional request options
* @returns the list of templates
*/
async listTemplateCredentials(
params?: ListTemplateCredentialsParams,
): Promise<TemplateCredentialsResponse> {
return this._remoteJson({
urlSuffix: '/template_credentials',
method: 'get',
params: params || {},
})
}
streamTemplateCredentials(params: ListTemplateCredentialsParams) {
return new PaginationStream(async (page) => ({
items: (await this.listTemplateCredentials({ ...params, page })).credentials,
}))
}
/**
* Create an Assembly Template
*
* @param params optional request options
* @returns when the template is created
*/
async createTemplate(params: CreateTemplateParams): Promise<TemplateResponse> {
return this._remoteJson({
urlSuffix: '/templates',
method: 'post',
params: params || {},
})
}
/**
* Edit an Assembly Template
*
* @param templateId the template ID
* @param params optional request options
* @returns when the template is edited
*/
async editTemplate(templateId: string, params: EditTemplateParams): Promise<TemplateResponse> {
return this._remoteJson({
urlSuffix: `/templates/${templateId}`,
method: 'put',
params: params || {},
})
}
/**
* Delete an Assembly Template
*
* @param templateId the template ID
* @returns when the template is deleted
*/
async deleteTemplate(templateId: string): Promise<BaseResponse> {
return this._remoteJson({
urlSuffix: `/templates/${templateId}`,
method: 'delete',
})
}
/**
* Get an Assembly Template
*
* @param templateId the template ID
* @returns when the template is retrieved
*/
async getTemplate(templateId: string): Promise<TemplateResponse> {
return this._remoteJson({
urlSuffix: `/templates/${templateId}`,
method: 'get',
})
}
/**
* List all Assembly Templates
*
* @param params optional request options
* @returns the list of templates
*/
async listTemplates(
params?: ListTemplatesParams,
): Promise<PaginationListWithCount<ListedTemplate>> {
return this._remoteJson({
urlSuffix: '/templates',
method: 'get',
params: params || {},
})
}
streamTemplates(params?: ListTemplatesParams): PaginationStream<ListedTemplate> {
return new PaginationStream(async (page) => this.listTemplates({ ...params, page }))
}
/**
* Get account Billing details for a specific month
*
* @param month the date for the required billing in the format yyyy-mm
* @returns with billing data
* @see https://transloadit.com/docs/api/bill-date-get/
*/
async getBill(month: string): Promise<BillResponse> {
assert.ok(month, 'month is required')
return this._remoteJson({
urlSuffix: `/bill/${month}`,
method: 'get',
})
}
calcSignature(
params: OptionalAuthParams,
algorithm?: string,
): { signature: string; params: string } {
const jsonParams = this._prepareParams(params)
const signature = this._calcSignature(jsonParams, algorithm)
return { signature, params: jsonParams }
}
/**
* Construct a signed Smart CDN URL. See https://transloadit.com/docs/topics/signature-authentication/#smart-cdn.
*/
getSignedSmartCDNUrl(opts: SmartCDNUrlOptions): string {
if (opts.workspace == null || opts.workspace === '')
throw new TypeError('workspace is required')
if (opts.template == null || opts.template === '') throw new TypeError('template is required')
if (opts.input == null) throw new TypeError('input is required') // `input` can be an empty string.
const workspaceSlug = encodeURIComponent(opts.workspace)
const templateSlug = encodeURIComponent(opts.template)
const inputField = encodeURIComponent(opts.input)
const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000 // 1 hour
const queryParams = new URLSearchParams()
for (const [key, value] of Object.entries(opts.urlParams || {})) {
if (Array.isArray(value)) {
for (const val of value) {
queryParams.append(key, `${val}`)
}
} else {
queryParams.append(key, `${value}`)
}
}
queryParams.set('auth_key', this._authKey)
queryParams.set('exp', `${expiresAt}`)
// The signature changes depending on the order of the query parameters. We therefore sort them on the client-
// and server-side to ensure that we do not get mismatching signatures if a proxy changes the order of query
// parameters or implementations handle query parameters ordering differently.
queryParams.sort()
const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`
const algorithm = 'sha256'
const signature = createHmac(algorithm, this._authSecret).update(stringToSign).digest('hex')
queryParams.set('sig', `sha256:${signature}`)
const signedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}`
return signedUrl
}
private _calcSignature(toSign: string, algorithm = 'sha384'): string {
return `${algorithm}:${createHmac(algorithm, this._authSecret)
.update(Buffer.from(toSign, 'utf-8'))
.digest('hex')}`
}
// Sets the multipart/form-data for POST, PUT and DELETE requests, including
// the streams, the signed params, and any additional fields.
private _appendForm(form: FormData, params: OptionalAuthParams, fields?: Fields): void {
const sigData = this.calcSignature(params)
const jsonParams = sigData.params
const { signature } = sigData
form.append('params', jsonParams)
if (fields != null) {
for (const [key, val] of Object.entries(fields)) {
form.append(key, val)
}
}
form.append('signature', signature)
}
// Implements HTTP GET query params, handling the case where the url already
// has params.
private _appendParamsToUrl(url: string, params: OptionalAuthParams): string {
const { signature, params: jsonParams } = this.calcSignature(params)
const prefix = url.indexOf('?') === -1 ? '?' : '&'
return `${url}${prefix}signature=${signature}¶ms=${encodeURIComponent(jsonParams)}`
}
// Responsible for including auth parameters in all requests
private _prepareParams(paramsIn?: OptionalAuthParams): string {
let params = paramsIn
if (params == null) {
params = {}
}
if (params['auth'] == null) {
params['auth'] = {}
}
if (params['auth'].key == null) {
params['auth'].key = this._authKey
}
if (params['auth'].expires == null) {
params['auth'].expires = this._getExpiresDate()
}
return JSON.stringify(params)
}
// We want to mock this method
private _getExpiresDate(): string {
const expiresDate = new Date()
expiresDate.setDate(expiresDate.getDate() + 1)
return expiresDate.toISOString()
}
// Responsible for making API calls. Automatically sends streams with any POST,
// PUT or DELETE requests. Automatically adds signature parameters to all
// requests. Also automatically parses the JSON response.
private async _remoteJson<TRet, TParams extends OptionalAuthParams>(opts: {
urlSuffix?: string
url?: string
timeout?: Delays
method?: 'delete' | 'get' | 'post' | 'put'
params?: TParams
fields?: Fields
headers?: Headers
}): Promise<TRet> {
const {
urlSuffix,
url: urlInput,
timeout = { request: this._defaultTimeout },
method = 'get',
params = {},
fields,
headers,
} = opts
// Allow providing either a `urlSuffix` or a full `url`
if (!urlSuffix && !urlInput) throw new Error('No URL provided')
let url = urlInput || `${this._endpoint}${urlSuffix}`
if (method === 'get') {
url = this._appendParamsToUrl(url, params)
}
log('Sending request', method, url)
// todo use got.retry instead because we are no longer using FormData (which is a stream and can only be used once)
// https://github.com/sindresorhus/got/issues/1282
for (let retryCount = 0; ; retryCount++) {
let form: FormData | undefined
if (method === 'post' || method === 'put' || method === 'delete') {
form = new FormData()
this._appendForm(form, params, fields)
}
const requestOpts: OptionsOfJSONResponseBody = {
retry: this._gotRetry,
body: form,
timeout,
headers: {
'Transloadit-Client': `node-sdk:${version}`,
'User-Agent': undefined, // Remove got's user-agent
...headers,
},
responseType: 'json',
}
try {
const request = got[method]<TRet>(url, requestOpts)
const { body } = await request
// console.log(body)
return body
} catch (err) {
if (!(err instanceof RequestError)) throw err
if (err instanceof HTTPError) {
const { statusCode, body } = err.response
logWarn('HTTP error', statusCode, body)
// check whether we should retry
// https://transloadit.com/blog/2012/04/introducing-rate-limiting/
if (
typeof body === 'object' &&
body != null &&
'error' in body &&
'info' in body &&
typeof body.info === 'object' &&
body.info != null &&
'retryIn' in body.info &&
typeof body.info.retryIn === 'number' &&
Boolean(body.info.retryIn) &&
retryCount < this._maxRetries && // 413 taken from https://transloadit.com/blog/2012/04/introducing-rate-limiting/
// todo can 413 be removed?
((statusCode === 413 && body.error === 'RATE_LIMIT_REACHED') || statusCode === 429)
) {
const { retryIn: retryInSec } = body.info
logWarn(`Rate limit reached, retrying request in approximately ${retryInSec} seconds.`)
const retryInMs = 1000 * (retryInSec * (1 + 0.1 * Math.random()))
await new Promise((resolve) => setTimeout(resolve, retryInMs))
// Retry
} else {
throw new ApiError({
cause: err,
body: err.response?.body as TransloaditErrorResponseBody | undefined,
}) // todo don't assert type
}
} else {
throw err
}
}
}
}
}