Skip to content

Commit d39257d

Browse files
feat(flags): Add guardrails and error backoff to local evaluation poller (#408)
* feat(flags): Add guardrails and error backoff to local evaluation poller * tweak * tweak * add tests * bump version and update changelog * tweak * tweak * Update posthog-node/src/feature-flags.ts Co-authored-by: Dylan Martin <[email protected]> * fix linter --------- Co-authored-by: Dylan Martin <[email protected]>
1 parent 18f7e40 commit d39257d

File tree

5 files changed

+88
-11
lines changed

5 files changed

+88
-11
lines changed

posthog-node/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Next
22

3+
# 4.8.0 - 2025-02-26
4+
5+
1. Add guardrails and exponential error backoff in the feature flag local evaluation poller to prevent high rates of 401/403 traffic towards `/local_evaluation`
6+
37
# 4.7.0 - 2025-02-20
48

59
## Added

posthog-node/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "posthog-node",
3-
"version": "4.7.0",
3+
"version": "4.8.0",
44
"description": "PostHog Node.js integration",
55
"repository": {
66
"type": "git",

posthog-node/src/feature-flags.ts

+32-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FeatureFlagCondition, FlagProperty, PostHogFeatureFlag, PropertyGroup }
33
import { JsonType, PostHogFetchOptions, PostHogFetchResponse } from 'posthog-core/src'
44
import { safeSetTimeout } from 'posthog-core/src/utils'
55
import fetch from './fetch'
6+
import { SIXTY_SECONDS } from './posthog-node'
67

78
// eslint-disable-next-line
89
const LONG_SCALE = 0xfffffffffffffff
@@ -57,6 +58,8 @@ class FeatureFlagsPoller {
5758
debugMode: boolean = false
5859
onError?: (error: Error) => void
5960
customHeaders?: { [key: string]: string }
61+
lastRequestWasAuthenticationError: boolean = false
62+
authenticationErrorCount: number = 0
6063

6164
constructor({
6265
pollingInterval,
@@ -78,7 +81,6 @@ class FeatureFlagsPoller {
7881
this.projectApiKey = projectApiKey
7982
this.host = host
8083
this.poller = undefined
81-
// NOTE: as any is required here as the AbortSignal typing is slightly misaligned but works just fine
8284
this.fetch = options.fetch || fetch
8385
this.onError = options.onError
8486
this.customHeaders = customHeaders
@@ -375,19 +377,44 @@ class FeatureFlagsPoller {
375377
}
376378
}
377379

380+
/**
381+
* If a client is misconfigured with an invalid or improper API key, the polling interval is doubled each time
382+
* until a successful request is made, up to a maximum of 60 seconds.
383+
*
384+
* @returns The polling interval to use for the next request.
385+
*/
386+
private getPollingInterval(): number {
387+
if (!this.lastRequestWasAuthenticationError) {
388+
return this.pollingInterval
389+
}
390+
391+
return Math.min(SIXTY_SECONDS, this.pollingInterval * 2 ** this.authenticationErrorCount)
392+
}
393+
378394
async _loadFeatureFlags(): Promise<void> {
379395
if (this.poller) {
380396
clearTimeout(this.poller)
381397
this.poller = undefined
382398
}
383-
this.poller = setTimeout(() => this._loadFeatureFlags(), this.pollingInterval)
399+
400+
this.poller = setTimeout(() => this._loadFeatureFlags(), this.getPollingInterval())
384401

385402
try {
386403
const res = await this._requestFeatureFlagDefinitions()
387404

388405
if (res && res.status === 401) {
406+
this.lastRequestWasAuthenticationError = true
407+
this.authenticationErrorCount += 1
408+
throw new ClientError(
409+
`Your project key or personal API key is invalid. Setting next polling interval to ${this.getPollingInterval()}ms. More information: https://posthog.com/docs/api#rate-limiting`
410+
)
411+
}
412+
413+
if (res && res.status === 403) {
414+
this.lastRequestWasAuthenticationError = true
415+
this.authenticationErrorCount += 1
389416
throw new ClientError(
390-
`Your personalApiKey is invalid. Are you sure you're not using your Project API key? More information: https://posthog.com/docs/api/overview`
417+
`Your personal API key does not have permission to fetch feature flag definitions for local evaluation. Setting next polling interval to ${this.getPollingInterval()}ms. Are you sure you're using the correct personal and Project API key pair? More information: https://posthog.com/docs/api/overview`
391418
)
392419
}
393420

@@ -410,6 +437,8 @@ class FeatureFlagsPoller {
410437
this.groupTypeMapping = responseJson.group_type_mapping || {}
411438
this.cohorts = responseJson.cohorts || []
412439
this.loadedSuccessfullyOnce = true
440+
this.lastRequestWasAuthenticationError = false
441+
this.authenticationErrorCount = 0
413442
} catch (err) {
414443
// if an error that is not an instance of ClientError is thrown
415444
// we silently ignore the error when reloading feature flags

posthog-node/src/posthog-node.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ export type PostHogOptions = PostHogCoreOptions & {
2828
fetch?: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
2929
}
3030

31-
const THIRTY_SECONDS = 30 * 1000
31+
// Standard local evaluation rate limit is 600 per minute (10 per second),
32+
// so the fastest a poller should ever be set is 100ms.
33+
export const MINIMUM_POLLING_INTERVAL = 100
34+
export const THIRTY_SECONDS = 30 * 1000
35+
export const SIXTY_SECONDS = 60 * 1000
3236
const MAX_CACHE_SIZE = 50 * 1000
3337

3438
// The actual exported Nodejs API.
@@ -47,12 +51,20 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
4751

4852
this.options = options
4953

54+
this.options.featureFlagsPollingInterval =
55+
typeof options.featureFlagsPollingInterval === 'number'
56+
? Math.max(options.featureFlagsPollingInterval, MINIMUM_POLLING_INTERVAL)
57+
: THIRTY_SECONDS
58+
5059
if (options.personalApiKey) {
60+
if (options.personalApiKey.includes('phc_')) {
61+
throw new Error(
62+
'Your Personal API key is invalid. These keys are prefixed with "phx_" and can be created in PostHog project settings.'
63+
)
64+
}
65+
5166
this.featureFlagsPoller = new FeatureFlagsPoller({
52-
pollingInterval:
53-
typeof options.featureFlagsPollingInterval === 'number'
54-
? options.featureFlagsPollingInterval
55-
: THIRTY_SECONDS,
67+
pollingInterval: this.options.featureFlagsPollingInterval,
5668
personalApiKey: options.personalApiKey,
5769
projectApiKey: apiKey,
5870
timeout: options.requestTimeout ?? 10000, // 10 seconds

posthog-node/test/posthog-node.spec.ts

+34-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { PostHog as PostHog } from '../src/posthog-node'
2-
jest.mock('../src/fetch')
1+
import { MINIMUM_POLLING_INTERVAL, PostHog as PostHog, THIRTY_SECONDS } from '../src/posthog-node'
32
import fetch from '../src/fetch'
43
import { anyDecideCall, anyLocalEvalCall, apiImplementation } from './test-utils'
54
import { waitForPromises, wait } from '../../posthog-core/test/test-utils/test-utils'
65
import { randomUUID } from 'crypto'
6+
jest.mock('../src/fetch')
77

88
jest.mock('../package.json', () => ({ version: '1.2.3' }))
99

@@ -666,6 +666,38 @@ describe('PostHog Node.js', () => {
666666
)
667667
})
668668

669+
it('should use minimum featureFlagsPollingInterval of 100ms if set less to less than 100', async () => {
670+
posthog = new PostHog('TEST_API_KEY', {
671+
host: 'http://example.com',
672+
fetchRetryCount: 0,
673+
personalApiKey: 'TEST_PERSONAL_API_KEY',
674+
featureFlagsPollingInterval: 98,
675+
})
676+
677+
expect(posthog.options.featureFlagsPollingInterval).toEqual(MINIMUM_POLLING_INTERVAL)
678+
})
679+
680+
it('should use default featureFlagsPollingInterval of 30000ms if none provided', async () => {
681+
posthog = new PostHog('TEST_API_KEY', {
682+
host: 'http://example.com',
683+
fetchRetryCount: 0,
684+
personalApiKey: 'TEST_PERSONAL_API_KEY',
685+
})
686+
687+
expect(posthog.options.featureFlagsPollingInterval).toEqual(THIRTY_SECONDS)
688+
})
689+
690+
it('should throw an error when creating SDK if a project key is passed in as personalApiKey', async () => {
691+
expect(() => {
692+
posthog = new PostHog('TEST_API_KEY', {
693+
host: 'http://example.com',
694+
fetchRetryCount: 0,
695+
personalApiKey: 'phc_abc123',
696+
featureFlagsPollingInterval: 100,
697+
})
698+
}).toThrow(Error)
699+
})
700+
669701
it('captures feature flags with locally evaluated flags', async () => {
670702
mockedFetch.mockClear()
671703
mockedFetch.mockClear()

0 commit comments

Comments
 (0)