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
7 changes: 2 additions & 5 deletions src/errors/BaseServerError.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export interface StatusableError extends Error {
status: number
retryAfter?: number
}

/**
Expand All @@ -11,18 +10,16 @@ export class BaseServerError extends Error {
constructor(
message: string,
public readonly statusCode: number,
retryAfter?: number,
) {
super(message)
this.name = 'BaseServerError'
this.retryAfter = retryAfter
}
}

export const baseServerErrorFactory = (name: string, message: string, statusCode: number) => {
return class extends BaseServerError {
constructor(messageOverride?: string, retryAfter?: number) {
super(messageOverride || message, statusCode, retryAfter)
constructor(messageOverride?: string) {
super(messageOverride || message, statusCode)
this.name = name
}
}
Expand Down
27 changes: 10 additions & 17 deletions src/lib/dropbox/DropboxClient.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Dropbox, type DropboxAuth, type files } from 'dropbox'
import { Dropbox, type DropboxAuth, DropboxResponseError, type files } from 'dropbox'
import httpStatus from 'http-status'
import { camelKeys } from 'js-convert-case'
import fetch from 'node-fetch'
import env from '@/config/server.env'
import { MAX_FETCH_DBX_RESOURCES } from '@/constants/limits'
import { DropboxClientType, type DropboxClientTypeValue } from '@/db/constants'
import type { StatusableError } from '@/errors/BaseServerError'
import { DropboxAuthClient } from '@/lib/dropbox/DropboxAuthClient'
import { type DropboxFileMetadata, DropboxFileMetadataSchema } from '@/lib/dropbox/type'

Expand Down Expand Up @@ -56,29 +55,19 @@ export class DropboxClient {
return this.clientInstance
}

async _manualFetch(
private async manualFetch(
url: string,
headers?: Record<string, string>,
body?: NodeJS.ReadableStream | null,
otherOptions?: Record<string, string>,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'POST',
) {
const response = await fetch(url, {
return await fetch(url, {
method,
headers,
body,
...otherOptions,
})

if (!response.ok) {
const retryAfter = response.headers.get('Retry-After')
const error = new Error(`Request failed with status ${response.status}`) as StatusableError
error.status = response.status
error.retryAfter = retryAfter ? parseInt(retryAfter, 10) : undefined
throw error
}

return response
Comment on lines -73 to -81
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arent we going to throw DropboxResponseError from manual fetch from the DropboxAPI?

Copy link
Collaborator Author

@SandipBajracharya SandipBajracharya Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I removed this is because we can customize the error message from the function level that uses this manualFetch and we don't have to use try catch again.

}

async _getAllFilesFolders(
Expand Down Expand Up @@ -126,7 +115,9 @@ export class DropboxClient {
}
const response = await this.manualFetch(`${env.DROPBOX_API_URL}${urlPath}`, headers)
if (response.status !== httpStatus.OK)
throw new Error('DropboxClient#downloadFile. Failed to download file')
throw new DropboxResponseError(response.status, response.headers, {
error_summary: 'DropboxClient#downloadFile. Failed to download file', // following the dropbox response error convention with snake case
})
return response.body
}

Expand Down Expand Up @@ -156,8 +147,11 @@ export class DropboxClient {
'Content-Type': 'application/octet-stream',
}
const response = await this.manualFetch(`${env.DROPBOX_API_URL}${urlPath}`, headers, body)

if (response.status !== httpStatus.OK)
throw new Error('DropboxClient#uploadFile. Failed to upload file')
throw new DropboxResponseError(response.status, response.headers, {
error_summary: 'DropboxClient#uploadFile. Failed to upload file', // following the dropbox response error convention with snake case
})
return DropboxFileMetadataSchema.parse(camelKeys(await response.json()))
}

Expand All @@ -172,7 +166,6 @@ export class DropboxClient {
})
}

manualFetch = this.wrapWithRetry(this._manualFetch)
getAllFilesFolders = this.wrapWithRetry(this._getAllFilesFolders)
downloadFile = this.wrapWithRetry(this._downloadFile)
uploadFile = this.wrapWithRetry(this._uploadFile)
Expand Down
61 changes: 35 additions & 26 deletions src/lib/withRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import Sentry from '@sentry/nextjs'
import { DropboxResponseError } from 'dropbox'
import httpStatus from 'http-status'
import pRetry from 'p-retry'
import type { StatusableError } from '@/errors/BaseServerError'
import { sleep } from '@/utils/sleep'

export const withRetry = async <Args extends unknown[], R>(
fn: (...args: Args) => Promise<R>,
Expand All @@ -20,22 +22,17 @@ export const withRetry = async <Args extends unknown[], R>(
try {
return await fn(...args)
} catch (error: unknown) {
let retryAfter: number | undefined, statusCode: number
// dropbox specific error and retry handling
if (error instanceof DropboxResponseError) {
const retryTime = error.headers.get('retry-after') || error.headers['retry-after']
retryAfter = retryTime ? parseInt(retryTime, 10) : undefined
statusCode = error.status
} else {
const err = error as StatusableError
retryAfter = err.retryAfter
statusCode = err.status
}
const retryTime = error.headers.get('retry-after')
const retryAfter = retryTime ? parseInt(retryTime, 10) : undefined

if (statusCode === 429 && retryAfter) {
// If rate limit happens with retryAfter value from dropbox api. Wait
const waitMs = retryAfter * 1000
console.warn(`Rate limited. Waiting for ${retryAfter} seconds before retry...`)
await new Promise((resolve) => setTimeout(resolve, waitMs))
if (error.status === httpStatus.TOO_MANY_REQUESTS && retryAfter) {
// If rate limit happens with retryAfter value from dropbox api. Wait
const waitMs = retryAfter * 1000
console.warn(`Rate limited. Waiting for ${retryAfter} seconds before retry...`)
await sleep(waitMs)
}
}

// Hopefully now sentry doesn't report retry errors as well. We have enough triage issues as it is
Expand Down Expand Up @@ -65,15 +62,18 @@ export const withRetry = async <Args extends unknown[], R>(
factor: 2, // Exponential factor for timeout delay. Tweak this if issues still persist

onFailedAttempt: (error: { error: unknown; attemptNumber: number; retriesLeft: number }) => {
console.warn(
'Error from onFailedAttempt. Error: ',
JSON.stringify(error),
(error.error as StatusableError).status,
)
if (error.error instanceof DropboxResponseError) {
const errorStatus = error.error.status
if (
errorStatus !== httpStatus.TOO_MANY_REQUESTS &&
errorStatus !== httpStatus.INTERNAL_SERVER_ERROR
)
return
}

if (
(error.error as StatusableError).status !== 429 &&
(error.error as StatusableError).status !== 500
(error.error as StatusableError).status !== httpStatus.TOO_MANY_REQUESTS &&
(error.error as StatusableError).status !== httpStatus.INTERNAL_SERVER_ERROR
) {
return
}
Expand All @@ -82,14 +82,23 @@ export const withRetry = async <Args extends unknown[], R>(
error,
)
},
shouldRetry: (error: unknown) => {
// Typecasting because Copilot doesn't export an error class
const err = error as StatusableError
shouldRetry: (error: { error: unknown; attemptNumber: number; retriesLeft: number }) => {
if (error.error instanceof DropboxResponseError) {
const errorStatus = error.error.status
return (
errorStatus === httpStatus.TOO_MANY_REQUESTS ||
errorStatus === httpStatus.INTERNAL_SERVER_ERROR
)
}

console.warn('Error from shouldRetry. Error: ', JSON.stringify(err), 'status: ', err.status)
// Typecasting because Copilot doesn't export an error class
const err = error.error as StatusableError

// Retry only if statusCode indicates a ratelimit or Internal Server Error
return err.status === 429 || err.status === 500
return (
err.status === httpStatus.TOO_MANY_REQUESTS ||
err.status === httpStatus.INTERNAL_SERVER_ERROR
)
},
},
)
Expand Down
1 change: 1 addition & 0 deletions src/utils/sleep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
6 changes: 6 additions & 0 deletions src/utils/withErrorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DropboxResponseError } from 'dropbox'
import httpStatus from 'http-status'
import { type NextRequest, NextResponse } from 'next/server'
import z, { ZodError } from 'zod'
Expand Down Expand Up @@ -50,6 +51,11 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => {
} else if (error instanceof Error && error.message) {
message = error.message
logger.error('Error:', error)
} else if (error instanceof DropboxResponseError) {
message = error.error.error_summary || `DropboxResponseError: ${message}`
status = error.status

logger.error('DropboxResponseError:', error.error)
} else {
message = 'Something went wrong'
logger.error(error)
Expand Down