mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-12 10:25:51 +00:00
Basic rate limits (#8539)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
fb9254535f
commit
aedad834f1
@ -349,6 +349,8 @@ services:
|
|||||||
- AI_BOT_URL=http://huly.local:4010
|
- AI_BOT_URL=http://huly.local:4010
|
||||||
- MSG2FILE_URL=http://huly.local:9087
|
- MSG2FILE_URL=http://huly.local:9087
|
||||||
- COMMUNICATION_TIME_LOGGING_ENABLED=true
|
- COMMUNICATION_TIME_LOGGING_ENABLED=true
|
||||||
|
- RATE_LIMIT_MAX=250 # 250 requests per 30 seconds
|
||||||
|
- RATE_LIMIT_WINDOW=30000
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
rekoni:
|
rekoni:
|
||||||
image: hardcoreeng/rekoni-service
|
image: hardcoreeng/rekoni-service
|
||||||
@ -472,8 +474,8 @@ services:
|
|||||||
- AVATAR_PATH=./avatar.png
|
- AVATAR_PATH=./avatar.png
|
||||||
- AVATAR_CONTENT_TYPE=.png
|
- AVATAR_CONTENT_TYPE=.png
|
||||||
- STATS_URL=http://huly.local:4900
|
- STATS_URL=http://huly.local:4900
|
||||||
# - LOVE_ENDPOINT=http://huly.local:8096
|
# - LOVE_ENDPOINT=http://huly.local:8096
|
||||||
# - OPENAI_API_KEY=token
|
# - OPENAI_API_KEY=token
|
||||||
msg2file:
|
msg2file:
|
||||||
image: hardcoreeng/msg2file
|
image: hardcoreeng/msg2file
|
||||||
ports:
|
ports:
|
||||||
|
@ -42,8 +42,19 @@ export function createRestClient (endpoint: string, workspaceId: string, token:
|
|||||||
return new RestClientImpl(endpoint, workspaceId, token)
|
return new RestClientImpl(endpoint, workspaceId, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rateLimitError = 'rate-limit'
|
||||||
|
|
||||||
|
function isRLE (err: any): boolean {
|
||||||
|
return err.message === rateLimitError
|
||||||
|
}
|
||||||
|
|
||||||
export class RestClientImpl implements RestClient {
|
export class RestClientImpl implements RestClient {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
|
|
||||||
|
slowDownTimer = 0
|
||||||
|
|
||||||
|
remaining: number = 1000
|
||||||
|
limit: number = 1000
|
||||||
constructor (
|
constructor (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
readonly workspace: string,
|
readonly workspace: string,
|
||||||
@ -85,10 +96,15 @@ export class RestClientImpl implements RestClient {
|
|||||||
const result = await withRetry(async () => {
|
const result = await withRetry(async () => {
|
||||||
const response = await fetch(requestUrl, this.requestInit())
|
const response = await fetch(requestUrl, this.requestInit())
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
await this.checkRateLimits(response)
|
||||||
throw new PlatformError(unknownError(response.statusText))
|
throw new PlatformError(unknownError(response.statusText))
|
||||||
}
|
}
|
||||||
return await extractJson<FindResult<T>>(response)
|
return await extractJson<FindResult<T>>(response)
|
||||||
})
|
}, isRLE)
|
||||||
|
|
||||||
|
if (result.error !== undefined) {
|
||||||
|
throw new PlatformError(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
if (result.lookupMap !== undefined) {
|
if (result.lookupMap !== undefined) {
|
||||||
// We need to extract lookup map to document lookups
|
// We need to extract lookup map to document lookups
|
||||||
@ -124,6 +140,25 @@ export class RestClientImpl implements RestClient {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkRateLimits (response: Response): Promise<void> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
// Extract rate limit information from headers
|
||||||
|
const retryAfter = response.headers.get('Retry-After')
|
||||||
|
const rateLimitReset = response.headers.get('X-RateLimit-Reset')
|
||||||
|
// const rateLimitLimit: string | null = response.headers.get('X-RateLimit-Limit')
|
||||||
|
const waitTime =
|
||||||
|
retryAfter != null
|
||||||
|
? parseInt(retryAfter)
|
||||||
|
: rateLimitReset != null
|
||||||
|
? new Date(parseInt(rateLimitReset)).getTime() - Date.now()
|
||||||
|
: 1000 // Default to 1 seconds if no headers are provided
|
||||||
|
|
||||||
|
console.warn(`Rate limit exceeded. Waiting ${Math.round((10 * waitTime) / 1000) / 10} seconds before retrying...`)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitTime))
|
||||||
|
throw new Error(rateLimitError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAccount (): Promise<Account> {
|
async getAccount (): Promise<Account> {
|
||||||
const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`)
|
const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`)
|
||||||
const response = await fetch(requestUrl, this.requestInit())
|
const response = await fetch(requestUrl, this.requestInit())
|
||||||
@ -160,16 +195,23 @@ export class RestClientImpl implements RestClient {
|
|||||||
|
|
||||||
async tx (tx: Tx): Promise<TxResult> {
|
async tx (tx: Tx): Promise<TxResult> {
|
||||||
const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`)
|
const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`)
|
||||||
const response = await fetch(requestUrl, {
|
const result = await withRetry(async () => {
|
||||||
method: 'POST',
|
const response = await fetch(requestUrl, {
|
||||||
headers: this.jsonHeaders(),
|
method: 'POST',
|
||||||
keepalive: true,
|
headers: this.jsonHeaders(),
|
||||||
body: JSON.stringify(tx)
|
keepalive: true,
|
||||||
})
|
body: JSON.stringify(tx)
|
||||||
if (!response.ok) {
|
})
|
||||||
throw new PlatformError(unknownError(response.statusText))
|
if (!response.ok) {
|
||||||
|
await this.checkRateLimits(response)
|
||||||
|
throw new PlatformError(unknownError(response.statusText))
|
||||||
|
}
|
||||||
|
return await extractJson<TxResult>(response)
|
||||||
|
}, isRLE)
|
||||||
|
if (result.error !== undefined) {
|
||||||
|
throw new PlatformError(result.error)
|
||||||
}
|
}
|
||||||
return await extractJson<TxResult>(response)
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { uncompress } from 'snappyjs'
|
import { uncompress } from 'snappyjs'
|
||||||
|
|
||||||
export async function withRetry<T> (fn: () => Promise<T>): Promise<T> {
|
export async function withRetry<T> (fn: () => Promise<T>, ignoreAttemptCheck?: (err: any) => boolean): Promise<T> {
|
||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
let lastError: any
|
let lastError: any
|
||||||
|
|
||||||
@ -8,9 +8,13 @@ export async function withRetry<T> (fn: () => Promise<T>): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
lastError = err
|
if (ignoreAttemptCheck !== undefined && ignoreAttemptCheck(err)) {
|
||||||
|
// Do not decrement attempt
|
||||||
|
attempt--
|
||||||
|
} else {
|
||||||
|
lastError = err
|
||||||
|
}
|
||||||
if (attempt === maxRetries - 1) {
|
if (attempt === maxRetries - 1) {
|
||||||
console.error('Failed to execute query', err)
|
|
||||||
throw lastError
|
throw lastError
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100))
|
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100))
|
||||||
|
@ -61,7 +61,7 @@ import platform, {
|
|||||||
UNAUTHORIZED
|
UNAUTHORIZED
|
||||||
} from '@hcengineering/platform'
|
} from '@hcengineering/platform'
|
||||||
import { uncompress } from 'snappyjs'
|
import { uncompress } from 'snappyjs'
|
||||||
import { HelloRequest, HelloResponse, ReqId, type Response, RPCHandler } from '@hcengineering/rpc'
|
import { HelloRequest, HelloResponse, ReqId, type Response, RPCHandler, type RateLimitInfo } from '@hcengineering/rpc'
|
||||||
import { EventResult } from '@hcengineering/communication-sdk-types'
|
import { EventResult } from '@hcengineering/communication-sdk-types'
|
||||||
import {
|
import {
|
||||||
FindLabelsParams,
|
FindLabelsParams,
|
||||||
@ -87,9 +87,14 @@ class RequestPromise {
|
|||||||
resolve!: (value?: any) => void
|
resolve!: (value?: any) => void
|
||||||
reject!: (reason?: any) => void
|
reject!: (reason?: any) => void
|
||||||
reconnect?: () => void
|
reconnect?: () => void
|
||||||
|
|
||||||
|
// Required to proeprly handle rate limits
|
||||||
|
sendData: () => void = () => {}
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
readonly method: string,
|
readonly method: string,
|
||||||
readonly params: any[],
|
readonly params: any[],
|
||||||
|
|
||||||
readonly handleResult?: (result: any) => Promise<void>
|
readonly handleResult?: (result: any) => Promise<void>
|
||||||
) {
|
) {
|
||||||
this.promise = new Promise((resolve, reject) => {
|
this.promise = new Promise((resolve, reject) => {
|
||||||
@ -285,11 +290,30 @@ class Connection implements ClientConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentRateLimit: RateLimitInfo | undefined
|
||||||
|
slowDownTimer = 0
|
||||||
|
|
||||||
handleMsg (socketId: number, resp: Response<any>): void {
|
handleMsg (socketId: number, resp: Response<any>): void {
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resp.rateLimit !== undefined) {
|
||||||
|
console.log(
|
||||||
|
'Rate limits:',
|
||||||
|
resp.rateLimit.remaining,
|
||||||
|
resp.rateLimit.limit,
|
||||||
|
resp.rateLimit.reset,
|
||||||
|
resp.rateLimit.retryAfter
|
||||||
|
)
|
||||||
|
this.currentRateLimit = resp.rateLimit
|
||||||
|
if (this.currentRateLimit.remaining < this.currentRateLimit.limit / 3) {
|
||||||
|
this.slowDownTimer++
|
||||||
|
} else if (this.slowDownTimer > 0) {
|
||||||
|
this.slowDownTimer--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resp.error !== undefined) {
|
if (resp.error !== undefined) {
|
||||||
if (resp.error?.code === UNAUTHORIZED.code || resp.terminate === true) {
|
if (resp.error?.code === UNAUTHORIZED.code || resp.terminate === true) {
|
||||||
Analytics.handleError(new PlatformError(resp.error))
|
Analytics.handleError(new PlatformError(resp.error))
|
||||||
@ -308,6 +332,20 @@ class Connection implements ClientConnection {
|
|||||||
|
|
||||||
if (resp.id !== undefined) {
|
if (resp.id !== undefined) {
|
||||||
const promise = this.requests.get(resp.id)
|
const promise = this.requests.get(resp.id)
|
||||||
|
|
||||||
|
// Support rate limits
|
||||||
|
if (resp.rateLimit !== undefined) {
|
||||||
|
const { remaining, retryAfter } = resp.rateLimit
|
||||||
|
if (remaining === 0) {
|
||||||
|
console.log('Rate limit exceed:', resp.rateLimit)
|
||||||
|
void new Promise((resolve) => setTimeout(resolve, retryAfter ?? 1)).then(() => {
|
||||||
|
// Retry after a while, so rate limits allow to call more.
|
||||||
|
promise?.sendData()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (promise !== undefined) {
|
if (promise !== undefined) {
|
||||||
promise.reject(new PlatformError(resp.error))
|
promise.reject(new PlatformError(resp.error))
|
||||||
}
|
}
|
||||||
@ -382,6 +420,7 @@ class Connection implements ClientConnection {
|
|||||||
}
|
}
|
||||||
if (resp.id !== undefined) {
|
if (resp.id !== undefined) {
|
||||||
const promise = this.requests.get(resp.id)
|
const promise = this.requests.get(resp.id)
|
||||||
|
|
||||||
if (promise === undefined) {
|
if (promise === undefined) {
|
||||||
console.error(
|
console.error(
|
||||||
new Error(`unknown response id: ${resp.id as string} ${this.workspace} ${this.user}`),
|
new Error(`unknown response id: ${resp.id as string} ${this.workspace} ${this.user}`),
|
||||||
@ -680,6 +719,11 @@ class Connection implements ClientConnection {
|
|||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.ConnectionClosed, {}))
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.ConnectionClosed, {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.slowDownTimer > 0) {
|
||||||
|
// We need to wait a bit to avoid ban.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.slowDownTimer))
|
||||||
|
}
|
||||||
|
|
||||||
if (data.once === true) {
|
if (data.once === true) {
|
||||||
// Check if has same request already then skip
|
// Check if has same request already then skip
|
||||||
const dparams = JSON.stringify(data.params)
|
const dparams = JSON.stringify(data.params)
|
||||||
@ -702,7 +746,7 @@ class Connection implements ClientConnection {
|
|||||||
if (data.method !== pingConst) {
|
if (data.method !== pingConst) {
|
||||||
this.requests.set(id, promise)
|
this.requests.set(id, promise)
|
||||||
}
|
}
|
||||||
const sendData = (): void => {
|
promise.sendData = (): void => {
|
||||||
if (this.websocket?.readyState === ClientSocketReadyState.OPEN) {
|
if (this.websocket?.readyState === ClientSocketReadyState.OPEN) {
|
||||||
promise.startTime = Date.now()
|
promise.startTime = Date.now()
|
||||||
|
|
||||||
@ -730,13 +774,13 @@ class Connection implements ClientConnection {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// In case we don't have response yet.
|
// In case we don't have response yet.
|
||||||
if (this.requests.has(id) && ((await data.retry?.()) ?? true)) {
|
if (this.requests.has(id) && ((await data.retry?.()) ?? true)) {
|
||||||
sendData()
|
promise.sendData()
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.withSync('send-data', {}, () => {
|
ctx.withSync('send-data', {}, () => {
|
||||||
sendData()
|
promise.sendData()
|
||||||
})
|
})
|
||||||
void ctx
|
void ctx
|
||||||
.with('broadcast-event', {}, () => broadcastEvent(client.event.NetworkRequests, this.requests.size))
|
.with('broadcast-event', {}, () => broadcastEvent(client.event.NetworkRequests, this.requests.size))
|
||||||
|
@ -12,7 +12,7 @@ import core, {
|
|||||||
import type { ClientSessionCtx, ConnectionSocket, Session, SessionManager } from '@hcengineering/server-core'
|
import type { ClientSessionCtx, ConnectionSocket, Session, SessionManager } from '@hcengineering/server-core'
|
||||||
import { decodeToken } from '@hcengineering/server-token'
|
import { decodeToken } from '@hcengineering/server-token'
|
||||||
|
|
||||||
import { rpcJSONReplacer } from '@hcengineering/rpc'
|
import { rpcJSONReplacer, type RateLimitInfo } from '@hcengineering/rpc'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { type Express, type Response as ExpressResponse, type Request } from 'express'
|
import { type Express, type Response as ExpressResponse, type Request } from 'express'
|
||||||
import type { OutgoingHttpHeaders } from 'http2'
|
import type { OutgoingHttpHeaders } from 'http2'
|
||||||
@ -20,6 +20,8 @@ import { compress } from 'snappy'
|
|||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { gzip } from 'zlib'
|
import { gzip } from 'zlib'
|
||||||
import { retrieveJson } from './utils'
|
import { retrieveJson } from './utils'
|
||||||
|
|
||||||
|
import { unknownError } from '@hcengineering/platform'
|
||||||
interface RPCClientInfo {
|
interface RPCClientInfo {
|
||||||
client: ConnectionSocket
|
client: ConnectionSocket
|
||||||
session: Session
|
session: Session
|
||||||
@ -28,16 +30,34 @@ interface RPCClientInfo {
|
|||||||
|
|
||||||
const gzipAsync = promisify(gzip)
|
const gzipAsync = promisify(gzip)
|
||||||
|
|
||||||
|
const keepAliveOptions = {
|
||||||
|
'keep-alive': 'timeout=5, max=1000',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
}
|
||||||
|
|
||||||
const sendError = (res: ExpressResponse, code: number, data: any): void => {
|
const sendError = (res: ExpressResponse, code: number, data: any): void => {
|
||||||
res.writeHead(code, {
|
res.writeHead(code, {
|
||||||
|
...keepAliveOptions,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache'
|
||||||
Connection: 'keep-alive',
|
|
||||||
'keep-alive': 'timeout=5, max=1000'
|
|
||||||
})
|
})
|
||||||
res.end(JSON.stringify(data))
|
res.end(JSON.stringify(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rateLimitToHeaders (rateLimit?: RateLimitInfo): OutgoingHttpHeaders {
|
||||||
|
if (rateLimit === undefined) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const { remaining, limit, reset, retryAfter } = rateLimit
|
||||||
|
return {
|
||||||
|
'Retry-After': `${Math.max(retryAfter ?? 0, 1)}`,
|
||||||
|
'Retry-After-ms': `${retryAfter ?? 0}`,
|
||||||
|
'X-RateLimit-Limit': `${limit}`,
|
||||||
|
'X-RateLimit-Remaining': `${remaining}`,
|
||||||
|
'X-RateLimit-Reset': `${reset}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sendJson (
|
async function sendJson (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: ExpressResponse,
|
res: ExpressResponse,
|
||||||
@ -50,10 +70,9 @@ async function sendJson (
|
|||||||
const etag = createHash('sha256').update(body).digest('hex')
|
const etag = createHash('sha256').update(body).digest('hex')
|
||||||
const headers: OutgoingHttpHeaders = {
|
const headers: OutgoingHttpHeaders = {
|
||||||
...(extraHeaders ?? {}),
|
...(extraHeaders ?? {}),
|
||||||
|
...keepAliveOptions,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
connection: 'keep-alive',
|
|
||||||
'keep-alive': 'timeout=5, max=1000',
|
|
||||||
ETag: etag
|
ETag: etag
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,7 +116,7 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
|
|||||||
async function withSession (
|
async function withSession (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: ExpressResponse,
|
res: ExpressResponse,
|
||||||
operation: (ctx: ClientSessionCtx, session: Session) => Promise<void>
|
operation: (ctx: ClientSessionCtx, session: Session, rateLimit?: RateLimitInfo) => Promise<void>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (req.params.workspaceId === undefined || req.params.workspaceId === '') {
|
if (req.params.workspaceId === undefined || req.params.workspaceId === '') {
|
||||||
@ -136,62 +155,87 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rpc = transactorRpc
|
const rpc = transactorRpc
|
||||||
await sessions.handleRPC(ctx, rpc.session, rpc.client, async (ctx) => {
|
const rateLimit = await sessions.handleRPC(ctx, rpc.session, rpc.client, async (ctx, rateLimit) => {
|
||||||
await operation(ctx, rpc.session)
|
await operation(ctx, rpc.session, rateLimit)
|
||||||
})
|
})
|
||||||
|
if (rateLimit !== undefined) {
|
||||||
|
const { remaining, limit, reset, retryAfter } = rateLimit
|
||||||
|
const retryHeaders: OutgoingHttpHeaders = {
|
||||||
|
...keepAliveOptions,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Retry-After': `${Math.max((retryAfter ?? 0) / 1000, 1)}`,
|
||||||
|
'Retry-After-ms': `${retryAfter ?? 0}`,
|
||||||
|
'X-RateLimit-Limit': `${limit}`,
|
||||||
|
'X-RateLimit-Remaining': `${remaining}`,
|
||||||
|
'X-RateLimit-Reset': `${reset}`
|
||||||
|
}
|
||||||
|
res.writeHead(429, retryHeaders)
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
id: -1,
|
||||||
|
error: unknownError('Rate limit')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
sendError(res, 500, { message: 'Failed to execute operation', error: err.message, stack: err.stack })
|
sendError(res, 500, { message: 'Failed to execute operation', error: err.message, stack: err.stack })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/v1/ping/:workspaceId', (req, res) => {
|
app.get('/api/v1/ping/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
await session.ping(ctx)
|
await session.ping(ctx)
|
||||||
await sendJson(req, res, {
|
await sendJson(
|
||||||
pong: true,
|
req,
|
||||||
lastTx: ctx.pipeline.context.lastTx,
|
res,
|
||||||
lastHash: ctx.pipeline.context.lastHash
|
{
|
||||||
})
|
pong: true,
|
||||||
|
lastTx: ctx.pipeline.context.lastTx,
|
||||||
|
lastHash: ctx.pipeline.context.lastHash
|
||||||
|
},
|
||||||
|
rateLimitToHeaders(rateLimit)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/v1/find-all/:workspaceId', (req, res) => {
|
app.get('/api/v1/find-all/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const _class = req.query.class as Ref<Class<Doc>>
|
const _class = req.query.class as Ref<Class<Doc>>
|
||||||
const query = req.query.query !== undefined ? JSON.parse(req.query.query as string) : {}
|
const query = req.query.query !== undefined ? JSON.parse(req.query.query as string) : {}
|
||||||
const options = req.query.options !== undefined ? JSON.parse(req.query.options as string) : {}
|
const options = req.query.options !== undefined ? JSON.parse(req.query.options as string) : {}
|
||||||
|
|
||||||
const result = await session.findAllRaw(ctx, _class, query, options)
|
const result = await session.findAllRaw(ctx, _class, query, options)
|
||||||
await sendJson(req, res, result)
|
await sendJson(req, res, result, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/v1/find-all/:workspaceId', (req, res) => {
|
app.post('/api/v1/find-all/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const { _class, query, options }: any = (await retrieveJson(req)) ?? {}
|
const { _class, query, options }: any = (await retrieveJson(req)) ?? {}
|
||||||
|
|
||||||
const result = await session.findAllRaw(ctx, _class, query, options)
|
const result = await session.findAllRaw(ctx, _class, query, options)
|
||||||
await sendJson(req, res, result)
|
await sendJson(req, res, result, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/v1/tx/:workspaceId', (req, res) => {
|
app.post('/api/v1/tx/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const tx: any = (await retrieveJson(req)) ?? {}
|
const tx: any = (await retrieveJson(req)) ?? {}
|
||||||
|
|
||||||
const result = await session.txRaw(ctx, tx)
|
const result = await session.txRaw(ctx, tx)
|
||||||
await sendJson(req, res, result.result)
|
await sendJson(req, res, result.result, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
app.get('/api/v1/account/:workspaceId', (req, res) => {
|
app.get('/api/v1/account/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const result = session.getRawAccount()
|
const result = session.getRawAccount()
|
||||||
await sendJson(req, res, result)
|
await sendJson(req, res, result, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/v1/load-model/:workspaceId', (req, res) => {
|
app.get('/api/v1/load-model/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const lastModelTx = parseInt((req.query.lastModelTx as string) ?? '0')
|
const lastModelTx = parseInt((req.query.lastModelTx as string) ?? '0')
|
||||||
const lastHash = req.query.lastHash as string
|
const lastHash = req.query.lastHash as string
|
||||||
const result = await session.loadModelRaw(ctx, lastModelTx, lastHash)
|
const result = await session.loadModelRaw(ctx, lastModelTx, lastHash)
|
||||||
@ -214,12 +258,12 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
|
|||||||
allowedClasess.some((cl) => h.isDerived((it as TxCUD<Doc>).objectClass, cl))
|
allowedClasess.some((cl) => h.isDerived((it as TxCUD<Doc>).objectClass, cl))
|
||||||
)
|
)
|
||||||
|
|
||||||
await sendJson(req, res, filtered)
|
await sendJson(req, res, filtered, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/v1/search-fulltext/:workspaceId', (req, res) => {
|
app.get('/api/v1/search-fulltext/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async (ctx, session) => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const query: SearchQuery = {
|
const query: SearchQuery = {
|
||||||
query: req.query.query as string,
|
query: req.query.query as string,
|
||||||
classes: req.query.classes !== undefined ? JSON.parse(req.query.classes as string) : undefined,
|
classes: req.query.classes !== undefined ? JSON.parse(req.query.classes as string) : undefined,
|
||||||
@ -229,7 +273,7 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
|
|||||||
limit: req.query.limit !== undefined ? parseInt(req.query.limit as string) : undefined
|
limit: req.query.limit !== undefined ? parseInt(req.query.limit as string) : undefined
|
||||||
}
|
}
|
||||||
const result = await session.searchFulltextRaw(ctx, query, options)
|
const result = await session.searchFulltextRaw(ctx, query, options)
|
||||||
await sendJson(req, res, result)
|
await sendJson(req, res, result, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -260,9 +304,9 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
|
|||||||
|
|
||||||
// To use in non-js (rust) clients that can't link to @hcengineering/core
|
// To use in non-js (rust) clients that can't link to @hcengineering/core
|
||||||
app.get('/api/v1/generate-id/:workspaceId', (req, res) => {
|
app.get('/api/v1/generate-id/:workspaceId', (req, res) => {
|
||||||
void withSession(req, res, async () => {
|
void withSession(req, res, async (ctx, session, rateLimit) => {
|
||||||
const result = { id: generateId() }
|
const result = { id: generateId() }
|
||||||
await sendJson(req, res, result)
|
await sendJson(req, res, result, rateLimitToHeaders(rateLimit))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
version: "3"
|
version: '3'
|
||||||
services:
|
services:
|
||||||
mongodb:
|
mongodb:
|
||||||
image: 'mongo:7-jammy'
|
image: 'mongo:7-jammy'
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
command: mongod --port 27018
|
command: mongod --port 27018
|
||||||
environment:
|
environment:
|
||||||
- PUID=1000
|
- PUID=1000
|
||||||
@ -90,7 +90,7 @@ services:
|
|||||||
account:
|
account:
|
||||||
image: hardcoreeng/account
|
image: hardcoreeng/account
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
pull_policy: never
|
pull_policy: never
|
||||||
links:
|
links:
|
||||||
- mongodb
|
- mongodb
|
||||||
@ -112,7 +112,7 @@ services:
|
|||||||
workspace:
|
workspace:
|
||||||
image: hardcoreeng/workspace
|
image: hardcoreeng/workspace
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
links:
|
links:
|
||||||
- mongodb
|
- mongodb
|
||||||
- minio
|
- minio
|
||||||
@ -132,7 +132,7 @@ services:
|
|||||||
front:
|
front:
|
||||||
image: hardcoreeng/front
|
image: hardcoreeng/front
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
pull_policy: never
|
pull_policy: never
|
||||||
links:
|
links:
|
||||||
- account
|
- account
|
||||||
@ -163,7 +163,7 @@ services:
|
|||||||
transactor:
|
transactor:
|
||||||
image: hardcoreeng/transactor
|
image: hardcoreeng/transactor
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
pull_policy: never
|
pull_policy: never
|
||||||
links:
|
links:
|
||||||
- mongodb
|
- mongodb
|
||||||
@ -189,10 +189,12 @@ services:
|
|||||||
- LAST_NAME_FIRST=true
|
- LAST_NAME_FIRST=true
|
||||||
- FULLTEXT_URL=http://fulltext:4710
|
- FULLTEXT_URL=http://fulltext:4710
|
||||||
- BRANDING_PATH=/var/cfg/branding-test.json
|
- BRANDING_PATH=/var/cfg/branding-test.json
|
||||||
|
- RATE_LIMIT_MAX=25000
|
||||||
|
- RATE_LIMIT_WINDOW=1000
|
||||||
collaborator:
|
collaborator:
|
||||||
image: hardcoreeng/collaborator
|
image: hardcoreeng/collaborator
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
links:
|
links:
|
||||||
- mongodb
|
- mongodb
|
||||||
- minio
|
- minio
|
||||||
@ -215,7 +217,7 @@ services:
|
|||||||
print:
|
print:
|
||||||
image: hardcoreeng/print
|
image: hardcoreeng/print
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 4003:4005
|
- 4003:4005
|
||||||
@ -232,7 +234,7 @@ services:
|
|||||||
sign:
|
sign:
|
||||||
image: hardcoreeng/sign
|
image: hardcoreeng/sign
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "huly.local:host-gateway"
|
- 'huly.local:host-gateway'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 4008:4006
|
- 4008:4006
|
||||||
|
@ -57,7 +57,7 @@ import {
|
|||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
import type { Asset, Resource } from '@hcengineering/platform'
|
import type { Asset, Resource } from '@hcengineering/platform'
|
||||||
import type { LiveQuery } from '@hcengineering/query'
|
import type { LiveQuery } from '@hcengineering/query'
|
||||||
import type { ReqId, Request, Response } from '@hcengineering/rpc'
|
import type { RateLimitInfo, ReqId, Request, Response } from '@hcengineering/rpc'
|
||||||
import type { Token } from '@hcengineering/server-token'
|
import type { Token } from '@hcengineering/server-token'
|
||||||
import { type Readable } from 'stream'
|
import { type Readable } from 'stream'
|
||||||
|
|
||||||
@ -720,8 +720,8 @@ export interface SessionManager {
|
|||||||
requestCtx: MeasureContext,
|
requestCtx: MeasureContext,
|
||||||
service: S,
|
service: S,
|
||||||
ws: ConnectionSocket,
|
ws: ConnectionSocket,
|
||||||
operation: (ctx: ClientSessionCtx) => Promise<void>
|
operation: (ctx: ClientSessionCtx, rateLimit?: RateLimitInfo) => Promise<void>
|
||||||
) => Promise<void>
|
) => Promise<RateLimitInfo | undefined>
|
||||||
|
|
||||||
createOpContext: (
|
createOpContext: (
|
||||||
ctx: MeasureContext,
|
ctx: MeasureContext,
|
||||||
@ -730,7 +730,8 @@ export interface SessionManager {
|
|||||||
communicationApi: CommunicationApi,
|
communicationApi: CommunicationApi,
|
||||||
requestId: Request<any>['id'],
|
requestId: Request<any>['id'],
|
||||||
service: Session,
|
service: Session,
|
||||||
ws: ConnectionSocket
|
ws: ConnectionSocket,
|
||||||
|
rateLimit?: RateLimitInfo
|
||||||
) => ClientSessionCtx
|
) => ClientSessionCtx
|
||||||
|
|
||||||
getStatistics: () => WorkspaceStatistics[]
|
getStatistics: () => WorkspaceStatistics[]
|
||||||
|
@ -15,3 +15,4 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
export * from './rpc'
|
export * from './rpc'
|
||||||
|
export * from './sliding'
|
||||||
|
@ -75,6 +75,15 @@ export function rpcJSONReceiver (key: string, value: any): any {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RateLimitInfo {
|
||||||
|
remaining: number
|
||||||
|
limit: number
|
||||||
|
|
||||||
|
current: number // in milliseconds
|
||||||
|
reset: number // in milliseconds
|
||||||
|
retryAfter?: number // in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response object define a server response on transaction request.
|
* Response object define a server response on transaction request.
|
||||||
* Also used to inform other clients about operations being performed by server.
|
* Also used to inform other clients about operations being performed by server.
|
||||||
@ -86,6 +95,8 @@ export interface Response<R> {
|
|||||||
id?: ReqId
|
id?: ReqId
|
||||||
error?: Status
|
error?: Status
|
||||||
terminate?: boolean
|
terminate?: boolean
|
||||||
|
|
||||||
|
rateLimit?: RateLimitInfo
|
||||||
chunk?: {
|
chunk?: {
|
||||||
index: number
|
index: number
|
||||||
final: boolean
|
final: boolean
|
||||||
|
77
server/rpc/src/sliding.ts
Normal file
77
server/rpc/src/sliding.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import type { RateLimitInfo } from './rpc'
|
||||||
|
|
||||||
|
export class SlidingWindowRateLimitter {
|
||||||
|
private readonly rateLimits = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
requests: number[]
|
||||||
|
rejectedRequests: number // Counter for rejected requests
|
||||||
|
resetTime: number
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
readonly rateLimitMax: number,
|
||||||
|
readonly rateLimitWindow: number,
|
||||||
|
readonly now: () => number = Date.now
|
||||||
|
) {
|
||||||
|
this.rateLimitMax = rateLimitMax
|
||||||
|
this.rateLimitWindow = rateLimitWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkRateLimit (groupId: string): RateLimitInfo {
|
||||||
|
const now = this.now()
|
||||||
|
const windowStart = now - this.rateLimitWindow
|
||||||
|
|
||||||
|
let rateLimit = this.rateLimits.get(groupId)
|
||||||
|
if (rateLimit == null) {
|
||||||
|
rateLimit = { requests: [], resetTime: now + this.rateLimitWindow, rejectedRequests: 0 }
|
||||||
|
this.rateLimits.set(groupId, rateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove requests outside the current window
|
||||||
|
rateLimit.requests = rateLimit.requests.filter((time) => time > windowStart)
|
||||||
|
|
||||||
|
// Reset rejected requests counter when window changes
|
||||||
|
if (rateLimit.requests.length === 0) {
|
||||||
|
rateLimit.rejectedRequests = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update reset time
|
||||||
|
rateLimit.resetTime = now + this.rateLimitWindow
|
||||||
|
|
||||||
|
rateLimit.requests.push(now + (rateLimit.rejectedRequests > this.rateLimitMax * 2 ? this.rateLimitWindow * 5 : 0))
|
||||||
|
|
||||||
|
if (rateLimit.requests.length > this.rateLimitMax) {
|
||||||
|
rateLimit.rejectedRequests++
|
||||||
|
|
||||||
|
if (rateLimit.requests.length > this.rateLimitMax * 2) {
|
||||||
|
// Keep only last requests
|
||||||
|
rateLimit.requests.splice(0, rateLimit.requests.length - this.rateLimitMax)
|
||||||
|
}
|
||||||
|
// Find when the oldest request will exit the window
|
||||||
|
const someRequest = Math.round(Math.random() * rateLimit.requests.length)
|
||||||
|
const nextAvailableTime = rateLimit.requests[someRequest] + this.rateLimitWindow
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining: 0,
|
||||||
|
limit: this.rateLimitMax,
|
||||||
|
current: rateLimit.requests.length,
|
||||||
|
reset: rateLimit.resetTime,
|
||||||
|
retryAfter: Math.max(1, nextAvailableTime - now + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining: this.rateLimitMax - rateLimit.requests.length,
|
||||||
|
current: rateLimit.requests.length,
|
||||||
|
limit: this.rateLimitMax,
|
||||||
|
reset: rateLimit.resetTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a reset method for testing purposes
|
||||||
|
public reset (): void {
|
||||||
|
this.rateLimits.clear()
|
||||||
|
}
|
||||||
|
}
|
129
server/rpc/src/test/rateLimit.spec.ts
Normal file
129
server/rpc/src/test/rateLimit.spec.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { SlidingWindowRateLimitter } from '../sliding'
|
||||||
|
|
||||||
|
describe('SlidingWindowRateLimitter', () => {
|
||||||
|
let clock = 100000
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock Date.now to control time
|
||||||
|
clock = 100000
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow requests within the limit', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimitter(5, 60000, () => clock)
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const result = limiter.checkRateLimit('user1')
|
||||||
|
expect(result.remaining).toBe(5 - i - 1)
|
||||||
|
expect(result.limit).toBe(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request should hit the limit
|
||||||
|
const result = limiter.checkRateLimit('user1')
|
||||||
|
expect(result.remaining).toBe(0)
|
||||||
|
expect(result.retryAfter).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject requests beyond the limit', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimitter(3, 60000, () => clock)
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
|
||||||
|
// This should be limited
|
||||||
|
const result = limiter.checkRateLimit('user1')
|
||||||
|
expect(result.remaining).toBe(0)
|
||||||
|
expect(result.retryAfter).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow new requests as the window slides', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimitter(2, 10000, () => clock)
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
|
||||||
|
// This should be limited
|
||||||
|
expect(limiter.checkRateLimit('user1').remaining).toBe(0)
|
||||||
|
|
||||||
|
// Move time forward by 5 seconds (half the window)
|
||||||
|
clock += 5 * 1000 // 5 seconds
|
||||||
|
|
||||||
|
// Should still have one request outside the current window
|
||||||
|
// and one within, so we can make one more request
|
||||||
|
const result = limiter.checkRateLimit('user1')
|
||||||
|
expect(result.remaining).toBe(0) // Now at limit again
|
||||||
|
|
||||||
|
// Move time forward by full window
|
||||||
|
clock += 11 * 1000 // 1011 seconds
|
||||||
|
|
||||||
|
// All previous requests should be outside the window
|
||||||
|
const newResult = limiter.checkRateLimit('user1')
|
||||||
|
expect(newResult.remaining).toBe(1) // One request used, one remaining
|
||||||
|
expect(limiter.checkRateLimit('user1').remaining).toBe(0) // Now at limit again
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different identifiers separately', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimitter(2, 60000, () => clock)
|
||||||
|
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
|
||||||
|
// User1 should be at limit
|
||||||
|
expect(limiter.checkRateLimit('user1').remaining).toBe(0)
|
||||||
|
|
||||||
|
// Different user should have separate limit
|
||||||
|
expect(limiter.checkRateLimit('user2').remaining).toBe(1)
|
||||||
|
expect(limiter.checkRateLimit('user2').remaining).toBe(0)
|
||||||
|
|
||||||
|
// Both users should be at their limits
|
||||||
|
expect(limiter.checkRateLimit('user1').remaining).toBe(0)
|
||||||
|
expect(limiter.checkRateLimit('user2').remaining).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle sliding window correctly', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimitter(10, 60000, () => clock)
|
||||||
|
|
||||||
|
// Use up half the capacity
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move halfway through the window
|
||||||
|
clock += 30 * 1000 + 1 // 30 seconds
|
||||||
|
|
||||||
|
// Make some more requests
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const result = limiter.checkRateLimit('user1')
|
||||||
|
if (i < 5) {
|
||||||
|
expect(result.remaining).toBeGreaterThanOrEqual(0)
|
||||||
|
} else {
|
||||||
|
expect(result.remaining).toBe(0)
|
||||||
|
expect(result.retryAfter).toBeDefined()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('check for ban', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimitter(10, 10000, () => clock)
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
limiter.checkRateLimit('user1')
|
||||||
|
}
|
||||||
|
|
||||||
|
const r1 = limiter.checkRateLimit('user1')
|
||||||
|
expect(r1.remaining).toBe(0)
|
||||||
|
// Pass all window time.
|
||||||
|
clock += 10000
|
||||||
|
|
||||||
|
const r2 = limiter.checkRateLimit('user1')
|
||||||
|
expect(r2.remaining).toBe(0)
|
||||||
|
expect(r2.retryAfter).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
@ -51,7 +51,14 @@ import core, {
|
|||||||
type WorkspaceUuid
|
type WorkspaceUuid
|
||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
import { unknownError, type Status } from '@hcengineering/platform'
|
import { unknownError, type Status } from '@hcengineering/platform'
|
||||||
import { type HelloRequest, type HelloResponse, type Request, type Response } from '@hcengineering/rpc'
|
import {
|
||||||
|
SlidingWindowRateLimitter,
|
||||||
|
type HelloRequest,
|
||||||
|
type HelloResponse,
|
||||||
|
type RateLimitInfo,
|
||||||
|
type Request,
|
||||||
|
type Response
|
||||||
|
} from '@hcengineering/rpc'
|
||||||
import {
|
import {
|
||||||
CommunicationApiFactory,
|
CommunicationApiFactory,
|
||||||
LOGGING_ENABLED,
|
LOGGING_ENABLED,
|
||||||
@ -111,6 +118,9 @@ export class TSessionManager implements SessionManager {
|
|||||||
|
|
||||||
workspaceProducer: PlatformQueueProducer<QueueWorkspaceMessage>
|
workspaceProducer: PlatformQueueProducer<QueueWorkspaceMessage>
|
||||||
usersProducer: PlatformQueueProducer<QueueUserMessage>
|
usersProducer: PlatformQueueProducer<QueueUserMessage>
|
||||||
|
|
||||||
|
now: number = Date.now()
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
readonly ctx: MeasureContext,
|
readonly ctx: MeasureContext,
|
||||||
readonly timeouts: Timeouts,
|
readonly timeouts: Timeouts,
|
||||||
@ -823,7 +833,7 @@ export class TSessionManager implements SessionManager {
|
|||||||
user: sessionRef.session.getSocialIds().find((it) => it.type !== SocialIdType.HULY)?.value,
|
user: sessionRef.session.getSocialIds().find((it) => it.type !== SocialIdType.HULY)?.value,
|
||||||
binary: sessionRef.session.binaryMode,
|
binary: sessionRef.session.binaryMode,
|
||||||
compression: sessionRef.session.useCompression,
|
compression: sessionRef.session.useCompression,
|
||||||
totalTime: Date.now() - sessionRef.session.createTime,
|
totalTime: this.now - sessionRef.session.createTime,
|
||||||
workspaceUsers: workspace?.sessions?.size,
|
workspaceUsers: workspace?.sessions?.size,
|
||||||
totalUsers: this.sessions.size
|
totalUsers: this.sessions.size
|
||||||
})
|
})
|
||||||
@ -1006,7 +1016,8 @@ export class TSessionManager implements SessionManager {
|
|||||||
communicationApi: CommunicationApi,
|
communicationApi: CommunicationApi,
|
||||||
requestId: Request<any>['id'],
|
requestId: Request<any>['id'],
|
||||||
service: Session,
|
service: Session,
|
||||||
ws: ConnectionSocket
|
ws: ConnectionSocket,
|
||||||
|
rateLimit: RateLimitInfo | undefined
|
||||||
): ClientSessionCtx {
|
): ClientSessionCtx {
|
||||||
const st = platformNow()
|
const st = platformNow()
|
||||||
return {
|
return {
|
||||||
@ -1019,8 +1030,9 @@ export class TSessionManager implements SessionManager {
|
|||||||
id: reqId,
|
id: reqId,
|
||||||
result: msg,
|
result: msg,
|
||||||
time: platformNowDiff(st),
|
time: platformNowDiff(st),
|
||||||
bfst: Date.now(),
|
bfst: this.now,
|
||||||
queue: service.requests.size
|
queue: service.requests.size,
|
||||||
|
rateLimit
|
||||||
}),
|
}),
|
||||||
sendPong: () => {
|
sendPong: () => {
|
||||||
ws.sendPong()
|
ws.sendPong()
|
||||||
@ -1032,7 +1044,8 @@ export class TSessionManager implements SessionManager {
|
|||||||
result: msg,
|
result: msg,
|
||||||
error,
|
error,
|
||||||
time: platformNowDiff(st),
|
time: platformNowDiff(st),
|
||||||
bfst: Date.now(),
|
rateLimit,
|
||||||
|
bfst: this.now,
|
||||||
queue: service.requests.size
|
queue: service.requests.size
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1044,7 +1057,6 @@ export class TSessionManager implements SessionManager {
|
|||||||
if (ws === undefined) {
|
if (ws === undefined) {
|
||||||
return new Map()
|
return new Map()
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = new Map<PersonId, AccountUuid>()
|
const res = new Map<PersonId, AccountUuid>()
|
||||||
for (const s of [...Array.from(ws.sessions.values()).map((it) => it.session), ...extra]) {
|
for (const s of [...Array.from(ws.sessions.values()).map((it) => it.session), ...extra]) {
|
||||||
const sessionAccount = s.getUser()
|
const sessionAccount = s.getUser()
|
||||||
@ -1059,6 +1071,12 @@ export class TSessionManager implements SessionManager {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limitter = new SlidingWindowRateLimitter(
|
||||||
|
parseInt(process.env.RATE_LIMIT_MAX ?? '250'),
|
||||||
|
parseInt(process.env.RATE_LIMIT_WINDOW ?? '30000'),
|
||||||
|
() => Date.now()
|
||||||
|
)
|
||||||
|
|
||||||
handleRequest<S extends Session>(
|
handleRequest<S extends Session>(
|
||||||
requestCtx: MeasureContext,
|
requestCtx: MeasureContext,
|
||||||
service: S,
|
service: S,
|
||||||
@ -1067,15 +1085,30 @@ export class TSessionManager implements SessionManager {
|
|||||||
workspaceId: WorkspaceUuid
|
workspaceId: WorkspaceUuid
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userCtx = requestCtx.newChild('📞 client', {})
|
const userCtx = requestCtx.newChild('📞 client', {})
|
||||||
|
const rateLimit = this.limitter.checkRateLimit(service.getUser())
|
||||||
|
// If remaining is 0, rate limit is exceeded
|
||||||
|
if (rateLimit?.remaining === 0) {
|
||||||
|
void ws.send(
|
||||||
|
userCtx,
|
||||||
|
{
|
||||||
|
id: request.id,
|
||||||
|
rateLimit,
|
||||||
|
error: unknownError('Rate limit')
|
||||||
|
},
|
||||||
|
service.binaryMode,
|
||||||
|
service.useCompression
|
||||||
|
)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate total number of clients
|
// Calculate total number of clients
|
||||||
const reqId = generateId()
|
const reqId = generateId()
|
||||||
|
|
||||||
const st = platformNow()
|
const st = Date.now()
|
||||||
return userCtx
|
return userCtx
|
||||||
.with('🧭 handleRequest', {}, async (ctx) => {
|
.with('🧭 handleRequest', {}, async (ctx) => {
|
||||||
if (request.time != null) {
|
if (request.time != null) {
|
||||||
const delta = platformNow() - request.time
|
const delta = Date.now() - request.time
|
||||||
requestCtx.measure('msg-receive-delta', delta)
|
requestCtx.measure('msg-receive-delta', delta)
|
||||||
}
|
}
|
||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
@ -1134,7 +1167,7 @@ export class TSessionManager implements SessionManager {
|
|||||||
await workspace.with(async (pipeline, communicationApi) => {
|
await workspace.with(async (pipeline, communicationApi) => {
|
||||||
await ctx.with('🧨 process', {}, (callTx) =>
|
await ctx.with('🧨 process', {}, (callTx) =>
|
||||||
f.apply(service, [
|
f.apply(service, [
|
||||||
this.createOpContext(callTx, userCtx, pipeline, communicationApi, request.id, service, ws),
|
this.createOpContext(callTx, userCtx, pipeline, communicationApi, request.id, service, ws, rateLimit),
|
||||||
...params
|
...params
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@ -1167,7 +1200,13 @@ export class TSessionManager implements SessionManager {
|
|||||||
service: S,
|
service: S,
|
||||||
ws: ConnectionSocket,
|
ws: ConnectionSocket,
|
||||||
operation: (ctx: ClientSessionCtx) => Promise<void>
|
operation: (ctx: ClientSessionCtx) => Promise<void>
|
||||||
): Promise<void> {
|
): Promise<RateLimitInfo | undefined> {
|
||||||
|
const rateLimitStatus = this.limitter.checkRateLimit(service.getUser())
|
||||||
|
// If remaining is 0, rate limit is exceeded
|
||||||
|
if (rateLimitStatus?.remaining === 0) {
|
||||||
|
return Promise.resolve(rateLimitStatus)
|
||||||
|
}
|
||||||
|
|
||||||
const userCtx = requestCtx.newChild('📞 client', {})
|
const userCtx = requestCtx.newChild('📞 client', {})
|
||||||
|
|
||||||
// Calculate total number of clients
|
// Calculate total number of clients
|
||||||
@ -1189,7 +1228,16 @@ export class TSessionManager implements SessionManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await workspace.with(async (pipeline, communicationApi) => {
|
await workspace.with(async (pipeline, communicationApi) => {
|
||||||
const uctx = this.createOpContext(ctx, userCtx, pipeline, communicationApi, reqId, service, ws)
|
const uctx = this.createOpContext(
|
||||||
|
ctx,
|
||||||
|
userCtx,
|
||||||
|
pipeline,
|
||||||
|
communicationApi,
|
||||||
|
reqId,
|
||||||
|
service,
|
||||||
|
ws,
|
||||||
|
rateLimitStatus
|
||||||
|
)
|
||||||
await operation(uctx)
|
await operation(uctx)
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -1209,6 +1257,7 @@ export class TSessionManager implements SessionManager {
|
|||||||
)
|
)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
userCtx.end()
|
userCtx.end()
|
||||||
|
@ -212,6 +212,8 @@ services:
|
|||||||
- FULLTEXT_URL=http://fulltext:4710
|
- FULLTEXT_URL=http://fulltext:4710
|
||||||
- STATS_URL=http://stats:4901
|
- STATS_URL=http://stats:4901
|
||||||
- ENABLE_COMPRESSION=true
|
- ENABLE_COMPRESSION=true
|
||||||
|
- RATE_LIMIT_MAX=25000
|
||||||
|
- RATE_LIMIT_WINDOW=1000
|
||||||
collaborator:
|
collaborator:
|
||||||
image: hardcoreeng/collaborator
|
image: hardcoreeng/collaborator
|
||||||
links:
|
links:
|
||||||
|
@ -295,6 +295,8 @@ services:
|
|||||||
- LAST_NAME_FIRST=true
|
- LAST_NAME_FIRST=true
|
||||||
- BRANDING_PATH=/var/cfg/branding.json
|
- BRANDING_PATH=/var/cfg/branding.json
|
||||||
- AI_BOT_URL=http://huly.local:4011
|
- AI_BOT_URL=http://huly.local:4011
|
||||||
|
- RATE_LIMIT_MAX=25000
|
||||||
|
- RATE_LIMIT_WINDOW=1000
|
||||||
transactor-europe:
|
transactor-europe:
|
||||||
image: hardcoreeng/transactor
|
image: hardcoreeng/transactor
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@ -326,6 +328,8 @@ services:
|
|||||||
- LAST_NAME_FIRST=true
|
- LAST_NAME_FIRST=true
|
||||||
- BRANDING_PATH=/var/cfg/branding.json
|
- BRANDING_PATH=/var/cfg/branding.json
|
||||||
- AI_BOT_URL=http://huly.local:4011
|
- AI_BOT_URL=http://huly.local:4011
|
||||||
|
- RATE_LIMIT_MAX=25000
|
||||||
|
- RATE_LIMIT_WINDOW=1000
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
rekoni:
|
rekoni:
|
||||||
image: hardcoreeng/rekoni-service
|
image: hardcoreeng/rekoni-service
|
||||||
|
Loading…
Reference in New Issue
Block a user