diff --git a/Dockerfile b/Dockerfile index 4192386319d..2592438208c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ COPY packages/webhooks/package.json ./packages/webhooks/package.json COPY packages/fleet/package.json ./packages/fleet/package.json COPY packages/providers/package.json ./packages/providers/package.json COPY packages/runner-sdk/package.json ./packages/runner-sdk/package.json +COPY packages/billing/package.json ./packages/billing/package.json COPY package*.json ./ # Install every dependencies diff --git a/package-lock.json b/package-lock.json index dd1a3333019..70725cea0fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4826,6 +4826,10 @@ "react": ">=16.8.0" } }, + "node_modules/@nangohq/billing": { + "resolved": "packages/billing", + "link": true + }, "node_modules/@nangohq/connect-ui": { "resolved": "packages/connect-ui", "link": true @@ -20516,6 +20520,11 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/lago-javascript-client": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lago-javascript-client/-/lago-javascript-client-1.22.0.tgz", + "integrity": "sha512-nA+lLFfDCHfH7+Rt6N/s4wpf2UdQhEGv4G/mxjgJUfboMsvq91/e8KZfGioY0MGFu1M+CmlReWOBtETghtW7Bg==" + }, "node_modules/lazystream": { "version": "1.0.1", "license": "MIT", @@ -29064,6 +29073,24 @@ "@types/node": ">=20" } }, + "packages/billing": { + "name": "@nangohq/billing", + "version": "1.0.0", + "dependencies": { + "@nangohq/utils": "file:../utils", + "lago-javascript-client": "1.22.0", + "uuidv7": "1.0.2" + }, + "devDependencies": {} + }, + "packages/billing/node_modules/uuidv7": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.0.2.tgz", + "integrity": "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA==", + "bin": { + "uuidv7": "cli.js" + } + }, "packages/cli": { "name": "nango", "version": "0.58.5", @@ -29743,6 +29770,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY", "dependencies": { + "@nangohq/billing": "file:../billing", "@nangohq/database": "file:../database", "@nangohq/logs": "file:../logs", "@nangohq/records": "file:../records", @@ -29928,6 +29956,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY", "dependencies": { + "@nangohq/billing": "file:../billing", "@nangohq/database": "file:../database", "@nangohq/fleet": "file:../fleet", "@nangohq/keystore": "file:../keystore", diff --git a/packages/billing/lib/billing.ts b/packages/billing/lib/billing.ts new file mode 100644 index 00000000000..9a7697dc99b --- /dev/null +++ b/packages/billing/lib/billing.ts @@ -0,0 +1,76 @@ +import { uuidv7 } from 'uuidv7'; + +import { Err, Ok, flagHasUsage, networkError, report, retryFlexible } from '@nangohq/utils'; + +import type { BillingClient, BillingIngestEvent, BillingMetric } from './types.js'; +import type { Result } from '@nangohq/utils'; + +export class Billing { + constructor(private client: BillingClient) { + this.client = client; + } + + async send(type: BillingMetric['type'], value: number, props: BillingMetric['properties']): Promise> { + return this.sendAll([{ type, value, properties: props }]); + } + + async sendAll(events: BillingMetric[]): Promise> { + const mapped = events.flatMap((event) => { + if (event.value === 0) { + return []; + } + + return [ + { + type: event.type, + accountId: event.properties.accountId, + idempotencyKey: event.properties.idempotencyKey || uuidv7(), + timestamp: event.properties.timestamp || new Date(), + properties: { + count: event.value + } + } + ]; + }); + + return this.ingest(mapped); + } + + // Note: Events are sent immediately + private async ingest(events: BillingIngestEvent[]): Promise> { + if (!flagHasUsage) { + return Ok(undefined); + } + + try { + await retryFlexible( + async () => { + await this.client.ingest(events); + }, + { + max: 3, + onError: ({ err }) => { + if ( + err instanceof TypeError && + err.cause && + typeof err.cause === 'object' && + 'code' in err.cause && + networkError.includes(err.cause.code as string) + ) { + return { retry: true, reason: 'maybe_unreachable' }; + } + if (err instanceof Response && err.status >= 500) { + return { retry: true, reason: 'status_code' }; + } + return { retry: false, reason: 'unknown' }; + } + } + ); + return Ok(undefined); + } catch (err: unknown) { + const e = new Error(`Failed to send billing event`, { cause: err }); + report(e); + return Err(e); + } + } +} diff --git a/packages/billing/lib/clients/lago.ts b/packages/billing/lib/clients/lago.ts new file mode 100644 index 00000000000..11fabb7483b --- /dev/null +++ b/packages/billing/lib/clients/lago.ts @@ -0,0 +1,31 @@ +import { Client as LagoClient } from 'lago-javascript-client'; + +import { envs } from '../envs.js'; + +import type { BillingClient, BillingIngestEvent } from '../types.js'; +import type { EventInput } from 'lago-javascript-client'; + +const lagoClient = LagoClient(envs.LAGO_API_KEY || ''); + +export const lago: BillingClient = { + ingest: async (events: BillingIngestEvent[]): Promise => { + const batchSize = 100; + for (let i = 0; i < events.length; i += batchSize) { + // Any fail will bubble up on purpose + // getLagoError is useless and modifying the error + await lagoClient.events.createBatchEvents({ + events: events.slice(i, i + batchSize).map(toLagoEvent) + }); + } + } +}; + +function toLagoEvent(event: BillingIngestEvent): EventInput['event'] { + return { + code: event.type, + transaction_id: event.idempotencyKey, + external_subscription_id: event.accountId.toString(), + timestamp: event.timestamp.getTime(), + properties: event.properties + }; +} diff --git a/packages/billing/lib/envs.ts b/packages/billing/lib/envs.ts new file mode 100644 index 00000000000..ec1592e84a5 --- /dev/null +++ b/packages/billing/lib/envs.ts @@ -0,0 +1,3 @@ +import { ENVS, parseEnvs } from '@nangohq/utils'; + +export const envs = parseEnvs(ENVS); diff --git a/packages/billing/lib/index.ts b/packages/billing/lib/index.ts new file mode 100644 index 00000000000..32441750b19 --- /dev/null +++ b/packages/billing/lib/index.ts @@ -0,0 +1,6 @@ +import { Billing } from './billing.js'; +import { lago } from './clients/lago.js'; + +export type { BillingIngestEvent, BillingMetric } from './types.js'; + +export const billing = new Billing(lago); diff --git a/packages/billing/lib/types.ts b/packages/billing/lib/types.ts new file mode 100644 index 00000000000..bbe81f293ac --- /dev/null +++ b/packages/billing/lib/types.ts @@ -0,0 +1,17 @@ +export interface BillingClient { + ingest: (events: BillingIngestEvent[]) => Promise; +} + +export interface BillingIngestEvent { + type: 'monthly_active_records' | 'billable_connections' | 'billable_actions'; + idempotencyKey: string; + accountId: number; + timestamp: Date; + properties: Record; +} + +export interface BillingMetric { + type: BillingIngestEvent['type']; + value: number; + properties: { accountId: number; timestamp?: Date | undefined; idempotencyKey?: string | undefined }; +} diff --git a/packages/billing/package.json b/packages/billing/package.json new file mode 100644 index 00000000000..b57544915c5 --- /dev/null +++ b/packages/billing/package.json @@ -0,0 +1,23 @@ +{ + "name": "@nangohq/billing", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.js", + "private": true, + "scripts": {}, + "repository": { + "type": "git", + "url": "git+https://github.com/NangoHQ/nango.git", + "directory": "packages/billing" + }, + "dependencies": { + "@nangohq/utils": "file:../utils", + "lago-javascript-client": "1.22.0", + "uuidv7": "1.0.2" + }, + "devDependencies": {}, + "files": [ + "dist/**/*" + ] +} diff --git a/packages/billing/tsconfig.json b/packages/billing/tsconfig.json new file mode 100644 index 00000000000..72091fb86f1 --- /dev/null +++ b/packages/billing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "lib", + "outDir": "dist" + }, + "references": [ + { + "path": "../utils" + } + ], + "include": ["lib/**/*", "../utils/lib/vitest.d.ts"] +} diff --git a/packages/kvstore/package.json b/packages/kvstore/package.json index c134cdad262..f62cb0ce57b 100644 --- a/packages/kvstore/package.json +++ b/packages/kvstore/package.json @@ -11,7 +11,7 @@ "repository": { "type": "git", "url": "git+https://github.com/NangoHQ/nango.git", - "directory": "packages/logs" + "directory": "packages/kvstore" }, "dependencies": { "@nangohq/utils": "file:../utils", diff --git a/packages/persist/lib/records.ts b/packages/persist/lib/records.ts index a86ce53558c..efa2b95ee1f 100644 --- a/packages/persist/lib/records.ts +++ b/packages/persist/lib/records.ts @@ -1,12 +1,13 @@ import tracer from 'dd-trace'; +import { billing } from '@nangohq/billing'; import { logContextGetter } from '@nangohq/logs'; import { format as recordsFormatter, records as recordsService } from '@nangohq/records'; import { ErrorSourceEnum, LogActionEnum, errorManager, getSyncConfigByJobId, updateSyncJobResult } from '@nangohq/shared'; import { Err, Ok, metrics, stringifyError } from '@nangohq/utils'; import type { FormattedRecord, UnencryptedRecordData, UpsertSummary } from '@nangohq/records'; -import type { MergingStrategy } from '@nangohq/types'; +import type { DBPlan, MergingStrategy } from '@nangohq/types'; import type { Result } from '@nangohq/utils'; import type { Span } from 'dd-trace'; @@ -18,6 +19,7 @@ export async function persistRecords({ accountId, environmentId, connectionId, + plan, providerConfigKey, nangoConnectionId, syncId, @@ -31,6 +33,7 @@ export async function persistRecords({ accountId: number; environmentId: number; connectionId: string; + plan: DBPlan | null; providerConfigKey: string; nangoConnectionId: number; syncId: string; @@ -151,7 +154,13 @@ export async function persistRecords({ return acc; }, 0); - metrics.increment(metrics.Types.BILLED_RECORDS_COUNT, new Set(summary.billedKeys).size, { accountId }); + const mar = new Set(summary.billedKeys).size; + + if (plan && plan.name !== 'free') { + void billing.send('monthly_active_records', mar, { accountId }); + } + + metrics.increment(metrics.Types.BILLED_RECORDS_COUNT, mar, { accountId }); metrics.increment(metrics.Types.PERSIST_RECORDS_COUNT, records.length); metrics.increment(metrics.Types.PERSIST_RECORDS_SIZE_IN_BYTES, recordsSizeInBytes, { accountId }); metrics.increment(metrics.Types.PERSIST_RECORDS_MODIFIED_COUNT, allModifiedKeys.size); diff --git a/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/deleteRecords.ts b/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/deleteRecords.ts index 7ad278e9e56..d004e29558f 100644 --- a/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/deleteRecords.ts +++ b/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/deleteRecords.ts @@ -1,9 +1,11 @@ -import type { ApiError, DeleteRecordsSuccess, Endpoint, MergingStrategy } from '@nangohq/types'; import { validateRequest } from '@nangohq/utils'; -import type { EndpointRequest, EndpointResponse, RouteHandler, Route } from '@nangohq/utils'; -import { persistRecords, recordsPath } from '../../../../../../../../../records.js'; + import { recordsRequestParser } from './validate.js'; +import { persistRecords, recordsPath } from '../../../../../../../../../records.js'; + import type { AuthLocals } from '../../../../../../../../../middleware/auth.middleware.js'; +import type { ApiError, DeleteRecordsSuccess, Endpoint, MergingStrategy } from '@nangohq/types'; +import type { EndpointRequest, EndpointResponse, Route, RouteHandler } from '@nangohq/utils'; type DeleteRecords = Endpoint<{ Method: typeof method; @@ -34,8 +36,9 @@ const validate = validateRequest(recordsRequestParser); const handler = async (req: EndpointRequest, res: EndpointResponse) => { const { environmentId, nangoConnectionId, syncId, syncJobId }: DeleteRecords['Params'] = req.params; const { model, records, providerConfigKey, connectionId, activityLogId, merging }: DeleteRecords['Body'] = req.body; - const { account } = res.locals; + const { account, plan } = res.locals; const result = await persistRecords({ + plan, persistType: 'delete', environmentId, accountId: account.id, diff --git a/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/postRecords.ts b/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/postRecords.ts index 6eb443fbd80..ec63b17833e 100644 --- a/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/postRecords.ts +++ b/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/postRecords.ts @@ -1,9 +1,11 @@ -import type { ApiError, Endpoint, MergingStrategy, PostRecordsSuccess } from '@nangohq/types'; import { validateRequest } from '@nangohq/utils'; -import type { EndpointRequest, EndpointResponse, RouteHandler } from '@nangohq/utils'; -import { persistRecords, recordsPath } from '../../../../../../../../../records.js'; + import { recordsRequestParser } from './validate.js'; +import { persistRecords, recordsPath } from '../../../../../../../../../records.js'; + import type { AuthLocals } from '../../../../../../../../../middleware/auth.middleware.js'; +import type { ApiError, Endpoint, MergingStrategy, PostRecordsSuccess } from '@nangohq/types'; +import type { EndpointRequest, EndpointResponse, RouteHandler } from '@nangohq/utils'; type PostRecords = Endpoint<{ Method: typeof method; @@ -34,8 +36,9 @@ const validate = validateRequest(recordsRequestParser); const handler = async (req: EndpointRequest, res: EndpointResponse) => { const { environmentId, nangoConnectionId, syncId, syncJobId }: PostRecords['Params'] = req.params; const { model, records, providerConfigKey, connectionId, activityLogId, merging }: PostRecords['Body'] = req.body; - const { account } = res.locals; + const { account, plan } = res.locals; const result = await persistRecords({ + plan, persistType: 'save', accountId: account.id, environmentId, diff --git a/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/putRecords.ts b/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/putRecords.ts index 64f797401b3..5ee32ff95e5 100644 --- a/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/putRecords.ts +++ b/packages/persist/lib/routes/environment/environmentId/connection/connectionId/sync/syncId/job/jobId/putRecords.ts @@ -1,9 +1,11 @@ -import type { ApiError, Endpoint, MergingStrategy, PutRecordsSuccess } from '@nangohq/types'; import { validateRequest } from '@nangohq/utils'; -import type { EndpointRequest, EndpointResponse, RouteHandler } from '@nangohq/utils'; -import { persistRecords, recordsPath } from '../../../../../../../../../records.js'; + import { recordsRequestParser } from './validate.js'; +import { persistRecords, recordsPath } from '../../../../../../../../../records.js'; + import type { AuthLocals } from '../../../../../../../../../middleware/auth.middleware.js'; +import type { ApiError, Endpoint, MergingStrategy, PutRecordsSuccess } from '@nangohq/types'; +import type { EndpointRequest, EndpointResponse, RouteHandler } from '@nangohq/utils'; type PutRecords = Endpoint<{ Method: typeof method; @@ -34,8 +36,9 @@ const validate = validateRequest(recordsRequestParser); const handler = async (req: EndpointRequest, res: EndpointResponse) => { const { environmentId, nangoConnectionId, syncId, syncJobId }: PutRecords['Params'] = req.params; const { model, records, providerConfigKey, connectionId, activityLogId, merging }: PutRecords['Body'] = req.body; - const { account } = res.locals; + const { account, plan } = res.locals; const result = await persistRecords({ + plan, persistType: 'update', accountId: account.id, environmentId, diff --git a/packages/persist/package.json b/packages/persist/package.json index ffd6ae59459..baa6ba6d086 100644 --- a/packages/persist/package.json +++ b/packages/persist/package.json @@ -23,6 +23,7 @@ "@nangohq/records": "file:../records", "@nangohq/shared": "file:../shared", "@nangohq/utils": "file:../utils", + "@nangohq/billing": "file:../billing", "dd-trace": "5.21.0", "express": "^4.20.0", "zod": "3.24.2" diff --git a/packages/persist/tsconfig.json b/packages/persist/tsconfig.json index 9643ba715b0..d13d0c4e340 100644 --- a/packages/persist/tsconfig.json +++ b/packages/persist/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../records" + }, + { + "path": "../billing" } ], "include": ["lib/**/*"] diff --git a/packages/server/lib/controllers/sync.controller.ts b/packages/server/lib/controllers/sync.controller.ts index 88dcdbae795..a1b1f1c8603 100644 --- a/packages/server/lib/controllers/sync.controller.ts +++ b/packages/server/lib/controllers/sync.controller.ts @@ -1,5 +1,6 @@ import tracer from 'dd-trace'; +import { billing } from '@nangohq/billing'; import { OtlpSpan, defaultOperationExpiration, logContextGetter } from '@nangohq/logs'; import { records as recordsService } from '@nangohq/records'; import { @@ -152,7 +153,7 @@ class SyncController { }); const { input, action_name } = req.body; - const { account, environment } = res.locals; + const { account, environment, plan } = res.locals; const environmentId = environment.id; const connectionId = req.get('Connection-Id'); const providerConfigKey = req.get('Provider-Config-Key'); @@ -230,6 +231,10 @@ class SyncController { await logCtx.success(); res.status(200).json(actionResponse.value); + if (plan && plan.name !== 'free') { + void billing.send('billable_actions', 1, { accountId: account.id, idempotencyKey: logCtx.id }); + } + return; } else { span.setTag('nango.error', actionResponse.error); diff --git a/packages/server/lib/crons/usage.ts b/packages/server/lib/crons/usage.ts index 1e174a163b6..fc10fdd3000 100644 --- a/packages/server/lib/crons/usage.ts +++ b/packages/server/lib/crons/usage.ts @@ -1,12 +1,15 @@ import tracer from 'dd-trace'; import * as cron from 'node-cron'; +import { billing as usageBilling } from '@nangohq/billing'; import { records } from '@nangohq/records'; import { connectionService } from '@nangohq/shared'; -import { getLogger, metrics, report, flagHasUsage } from '@nangohq/utils'; +import { flagHasUsage, getLogger, metrics, report } from '@nangohq/utils'; import { envs } from '../env.js'; +import type { BillingMetric } from '@nangohq/billing'; + const logger = getLogger('cron.exportUsage'); const cronMinutes = envs.CRON_EXPORT_USAGE_MINUTES; @@ -74,11 +77,17 @@ const billing = { exportBillableConnections: async (): Promise => { await tracer.trace>('nango.cron.exportUsage.billing.connections', async (span) => { try { - const res = await connectionService.billableConnections(new Date()); + const now = new Date(); + const res = await connectionService.billableConnections(now); if (res.isErr()) { throw res.error; } - logger.info(`Exporting ${res.value.length} billable connections`); //TODO: export to billing platform + + const events = res.value.map(({ accountId, count }) => { + return { type: 'billable_connections', value: count, properties: { accountId, timestamp: now } }; + }); + + await usageBilling.sendAll(events); } catch (err) { span.setTag('error', err); report(new Error('cron_failed_to_export_billable_connections', { cause: err })); diff --git a/packages/server/nodemon.json b/packages/server/nodemon.json index 3caee327ab0..bbc4e3eec6e 100644 --- a/packages/server/nodemon.json +++ b/packages/server/nodemon.json @@ -5,6 +5,7 @@ "../logs/dist", "../records/dist", "../utils/dist", + "../billing/dist", "../webhooks/dist", "../keystore/dist", "../../.env", diff --git a/packages/server/package.json b/packages/server/package.json index 3f848d92e51..a82165f1fe3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,6 +34,7 @@ "@nangohq/shared": "file:../shared", "@nangohq/utils": "file:../utils", "@nangohq/webhooks": "file:../webhooks", + "@nangohq/billing": "file:../billing", "@workos-inc/node": "6.2.0", "axios": "^1.8.4", "body-parser": "1.20.3", diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 54eb72b0d2d..5b28a04c8cd 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../fleet" + }, + { + "path": "../billing" } ], "include": ["lib/**/*", "../shared/lib/express.d.ts", "../utils/lib/vitest.d.ts"] diff --git a/packages/shared/lib/services/connection.service.ts b/packages/shared/lib/services/connection.service.ts index 50ccf72db6e..c69fe9f5142 100644 --- a/packages/shared/lib/services/connection.service.ts +++ b/packages/shared/lib/services/connection.service.ts @@ -1551,6 +1551,13 @@ class ConnectionService { return Err(new NangoError('failed_to_get_connections_count')); } + /** + * Note: + * a billable connection is a connection that is not deleted and has not been deleted during the month + * connections are pro-rated based on the number of seconds they were existing in the month + * + * This method only returns returns data for paying customer + */ async billableConnections(referenceDate: Date): Promise< Result< { @@ -1562,10 +1569,6 @@ class ConnectionService { NangoError > > { - // Note: - // a billable connection is a connection that is not deleted and has not been deleted during the month - // connections are pro-rated based on the number of seconds they were existing in the month - const targetDate = new Date(Date.UTC(referenceDate.getUTCFullYear(), referenceDate.getUTCMonth(), referenceDate.getUTCDate(), 0, 0, 0, 0)); const year = referenceDate.getUTCFullYear(); const month = referenceDate.getUTCMonth() + 1; // js months are 0-based @@ -1591,8 +1594,10 @@ class ConnectionService { db.readOnly.raw(`(SELECT month_end FROM month_info) AS month_end`), db.readOnly.raw(`(SELECT total_seconds_in_month FROM month_info) AS total_seconds_in_month`) ) - .from('nango._nango_connections as c') - .join('nango._nango_environments as e', 'c.environment_id', 'e.id') + .from('_nango_connections as c') + .join('_nango_environments as e', 'c.environment_id', 'e.id') + .join('plans', 'plans.account_id', 'e.account_id') + .where('plans.name', '<>', 'free') .where((builder) => { builder.where('c.deleted_at', null).orWhereRaw(`c.deleted_at >= (SELECT month_start FROM month_info)`); }) diff --git a/packages/utils/lib/environment/parse.ts b/packages/utils/lib/environment/parse.ts index 2817c1d0637..e272a5680d2 100644 --- a/packages/utils/lib/environment/parse.ts +++ b/packages/utils/lib/environment/parse.ts @@ -130,6 +130,8 @@ export const ENVS = z.object({ // Billing FLAG_PLAN_ENABLED: bool, + FLAG_USAGE_ENABLED: bool, + LAGO_API_KEY: z.string().optional(), // --- Third parties // AWS diff --git a/packages/utils/lib/retry.ts b/packages/utils/lib/retry.ts index d0195fbef95..989900b165d 100644 --- a/packages/utils/lib/retry.ts +++ b/packages/utils/lib/retry.ts @@ -1,8 +1,10 @@ -import type { MaybePromise } from '@nangohq/types'; +import { setTimeout } from 'node:timers/promises'; + import { AxiosError } from 'axios'; -import type { BackoffOptions } from 'exponential-backoff'; import { backOff } from 'exponential-backoff'; -import { setTimeout } from 'node:timers/promises'; + +import type { MaybePromise } from '@nangohq/types'; +import type { BackoffOptions } from 'exponential-backoff'; export interface RetryConfig { maxAttempts: number; @@ -96,7 +98,8 @@ export async function retryWithBackoff any>(fn: T, options?: Bac return await backOff(fn, { numOfAttempts: 5, ...options }); } -export const networkError = ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED']; +export const networkError = ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'ECONNREFUSED', 'EHOSTUNREACH', 'EAI_AGAIN']; +export const nonRetryableNetworkError = ['ENOTFOUND', 'ERRADDRINUSE']; export function httpRetryStrategy(error: unknown, _attemptNumber: number): boolean { if (!(error instanceof AxiosError)) { // debatable @@ -107,6 +110,10 @@ export function httpRetryStrategy(error: unknown, _attemptNumber: number): boole return true; } + if (error.code && nonRetryableNetworkError.includes(error.code)) { + return false; + } + if (!error.response || !error.status) { return false; } diff --git a/tsconfig.build.json b/tsconfig.build.json index 1e775a0d52f..c0cd8caf668 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -73,6 +73,9 @@ { "path": "packages/providers" }, + { + "path": "packages/billing" + }, { "path": "packages/types" }