Skip to content

Commit

Permalink
feat(elation): rate limiting with custom rate limiter (#582)
Browse files Browse the repository at this point in the history
* feat(elation): add rate limiter to elation webhooks

* feat(elation): improved rate limiting settings validation and logging
  • Loading branch information
bejoinka authored Feb 6, 2025
1 parent 569c9f0 commit 1802dab
Show file tree
Hide file tree
Showing 9 changed files with 177 additions and 29 deletions.
26 changes: 18 additions & 8 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
64 changes: 58 additions & 6 deletions extensions/elation/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Setting } from '@awell-health/extensions-core'
import { RateLimitConfig, type Setting } from '@awell-health/extensions-core'
import { isFinite, isNil } from 'lodash'
import { z, type ZodTypeAny } from 'zod'

export const settings = {
Expand Down Expand Up @@ -58,13 +59,28 @@ export const settings = {

export const rateLimitDurationSchema = z
.string()
.regex(
/^\d+\s+[smhd]$/,
'Duration must be in format {number} {unit} where unit is s,m,h,d',
.refine(
(val) => {
try {
const [number, unit] = val.split(' ')
const parsedUnit = parseDurationUnit(unit)
return isFinite(Number(number)) && !isNil(parsedUnit)
} catch (error) {
return false
}
},
{
message:
'Duration must be in format {number} {unit} where unit is seconds, minutes, hours or days',
},
)
.transform((val): Duration => {
.transform((val): RateLimitConfig['duration'] => {
const [number, unit] = val.split(' ')
return `${number}${unit}` as Duration
const parsedUnit = parseDurationUnit(unit)
return {
value: Number(number),
unit: parsedUnit,
}
})
.optional()

Expand All @@ -84,3 +100,39 @@ export const SettingsValidationSchema = z.object({
} satisfies Record<keyof typeof settings, ZodTypeAny>)

export type SettingsType = z.infer<typeof SettingsValidationSchema>

const parseDurationUnit = (
unit: string | undefined,
): 'seconds' | 'minutes' | 'hours' | 'days' => {
if (!unit) throw new Error('Duration unit is required')

const normalized = unit.toLowerCase().trim()

switch (normalized) {
case 's':
case 'second':
case 'seconds':
return 'seconds'

case 'm':
case 'min':
case 'minute':
case 'minutes':
return 'minutes'

case 'h':
case 'hour':
case 'hours':
return 'hours'

case 'd':
case 'day':
case 'days':
return 'days'

default:
throw new Error(
`Invalid duration unit: ${unit}. Valid units are: s, m, h, d`,
)
}
}
39 changes: 38 additions & 1 deletion extensions/elation/webhooks/appointmentCreatedOrUpdated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
} from '@awell-health/extensions-core'
import { ELATION_SYSTEM } from '../constants'
import { type SubscriptionEvent } from '../types/subscription'
import { rateLimitDurationSchema } from '../settings'
import { isNil } from 'lodash'

const dataPoints = {
appointmentId: {
Expand All @@ -22,7 +24,12 @@ export const appointmentCreatedOrUpdated: Webhook<
> = {
key: 'appointmentCreatedOrUpdated',
dataPoints,
onEvent: async ({ payload: { payload, settings }, onSuccess, onError }) => {
onEvent: async ({
payload: { payload, settings },
onSuccess,
onError,
helpers: { rateLimiter },
}) => {
const { action, resource, data } = payload
const { id: appointmentId, patient: patientId } = data

Expand All @@ -31,6 +38,36 @@ export const appointmentCreatedOrUpdated: Webhook<
return
}

// rate limiting
const { success, data: duration } = rateLimitDurationSchema.safeParse(
settings.rateLimitDuration,
)
if (success === true && !isNil(duration)) {
const limiter = rateLimiter('elation-appointment', {
requests: 1,
duration,
})
const { success } = await limiter.limit(appointmentId.toString())
if (!success) {
console.warn({
data,
resource,
action,
message:
'Rate limit exceeded. 200 OK response sent to Elation to prevent further requests.',
})
await onError({
response: {
statusCode: 200,
message:
'Rate limit exceeded. 200 OK response sent to Elation to prevent further requests.',
},
})
return
}
console.log(`Rate limit success for appointment_id=${appointmentId}`)
}

if (resource !== 'appointments') {
await onError({
response: {
Expand Down
40 changes: 38 additions & 2 deletions extensions/elation/webhooks/patientCreatedOrUpdated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type Webhook,
} from '@awell-health/extensions-core'
import { type SubscriptionEvent } from '../types/subscription'
import { rateLimitDurationSchema } from '../settings'
import { isNil } from 'lodash'

const dataPoints = {
patientId: {
Expand All @@ -21,15 +23,49 @@ export const patientCreatedOrUpdated: Webhook<
> = {
key: 'patientCreatedOrUpdated',
dataPoints,
onEvent: async ({ payload: { payload, settings }, onSuccess, onError }) => {
onEvent: async ({
payload: { payload, settings },
onSuccess,
onError,
helpers: { rateLimiter },
}) => {
const { data, resource, action } = payload
const { id: patientId } = data

// skip non 'saved' actions for that webhook
if (action !== 'saved') {
return
}

// rate limiting
const { success, data: duration } = rateLimitDurationSchema.safeParse(
settings.rateLimitDuration,
)
if (success === true && !isNil(duration)) {
const limiter = rateLimiter('elation-patient', {
requests: 1,
duration,
})
const { success } = await limiter.limit(patientId.toString())
if (!success) {
console.warn({
data,
resource,
action,
message:
'Rate limit exceeded. 200 OK response sent to Elation to prevent further requests.',
})
await onError({
response: {
statusCode: 200,
message:
'Rate limit exceeded. 200 OK response sent to Elation to prevent further requests.',
},
})
return
}
console.log(`Rate limit success for patient_id=${patientId}`)
}

if (resource !== 'patients') {
await onError({
response: {
Expand Down
17 changes: 11 additions & 6 deletions extensions/elation/webhooks/rateLimitValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import { rateLimitDurationSchema } from '../settings'

describe('rateLimitDurationSchema', () => {
it.each([
['1 s', '1s'],
['5 m', '5m'],
['12 h', '12h'],
['86400 m', '86400m'],
['30 d', '30d'],
['1 s', { value: 1, unit: 'seconds' }],
['5 m', { value: 5, unit: 'minutes' }],
['12 h', { value: 12, unit: 'hours' }],
['86400 m', { value: 86400, unit: 'minutes' }],
['30 d', { value: 30, unit: 'days' }],
['10 seconds', { value: 10, unit: 'seconds' }],
['10 minutes', { value: 10, unit: 'minutes' }],
['10 hours', { value: 10, unit: 'hours' }],
['10 days', { value: 10, unit: 'days' }],
[undefined, undefined],
])(
'should validate rate limit correctly %s',
async (rateLimitDuration, expected) => {
Expand All @@ -16,7 +21,7 @@ describe('rateLimitDurationSchema', () => {
const validatedRateLimit = rateLimitDurationSchema.parse(
settings.rateLimitDuration,
)
expect(validatedRateLimit).toBe(expected)
expect(validatedRateLimit).toStrictEqual(expected)
},
)
it.each(['1s', '6m', '12h', '86400m', '30d', 'invalid', ''])(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
},
"dependencies": {
"@awell-health/awell-sdk": "^0.1.21",
"@awell-health/extensions-core": "1.0.18",
"@awell-health/extensions-core": "1.0.21",
"@awell-health/healthie-sdk": "^0.1.1",
"@dropbox/sign": "^1.8.0",
"@hubspot/api-client": "^11.2.0",
Expand Down
18 changes: 13 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ __metadata:
resolution: "@awell-health/awell-extensions@workspace:."
dependencies:
"@awell-health/awell-sdk": "npm:^0.1.21"
"@awell-health/extensions-core": "npm:1.0.18"
"@awell-health/extensions-core": "npm:1.0.21"
"@awell-health/healthie-sdk": "npm:^0.1.1"
"@dropbox/sign": "npm:^1.8.0"
"@faker-js/faker": "npm:^8.0.2"
Expand Down Expand Up @@ -194,20 +194,21 @@ __metadata:
languageName: node
linkType: hard

"@awell-health/extensions-core@npm:1.0.18":
version: 1.0.18
resolution: "@awell-health/extensions-core@npm:1.0.18"
"@awell-health/extensions-core@npm:1.0.21":
version: 1.0.21
resolution: "@awell-health/extensions-core@npm:1.0.21"
dependencies:
"@types/json-schema": "npm:^7.0.15"
axios: "npm:^1.7.4"
date-fns: "npm:^3.6.0"
libphonenumber-js: "npm:^1.10.61"
lodash: "npm:^4.17.21"
rate-limiter-flexible: "npm:^5.0.5"
zod: "npm:^3.23.4"
zod-validation-error: "npm:^3.2.0"
peerDependencies:
"@awell-health/awell-sdk": "*"
checksum: 10/f65aaf1c891b828d92cdf89d9fb2d61c00862d5ed3ef57c836d41039a845834e535bf39462c7e3fea870beb5d9f93c38709c16b639aad2971ad3d8287b6d22e0
checksum: 10/ff336c231c6fb42d74d6e55dd06f5f695da990d50f29e3df7040666b961816450ee66554d7a4af13ed3e0900acc6d230e7d861140ce9b3438574faa48c86185e
languageName: node
linkType: hard

Expand Down Expand Up @@ -10595,6 +10596,13 @@ __metadata:
languageName: node
linkType: hard

"rate-limiter-flexible@npm:^5.0.5":
version: 5.0.5
resolution: "rate-limiter-flexible@npm:5.0.5"
checksum: 10/51277add0367968e83227446e7f68a15a15ee30ba9a7ff6c0e3998981a46d36d7c750e511f30c0bf391513aa84dab626ee5b8e56ae28d1bade70c08a0c8f163d
languageName: node
linkType: hard

"raw-body@npm:2.5.2":
version: 2.5.2
resolution: "raw-body@npm:2.5.2"
Expand Down

0 comments on commit 1802dab

Please sign in to comment.