Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
aadcd27
feat: add conditional failure tracking to evaluation fetch retries
duyhungtnn Oct 16, 2025
412ff89
Fix suite name typo in EvaluationTask.spec.ts
duyhungtnn Oct 16, 2025
8c25903
Refactor options handling in fetchEvaluationsInternal
duyhungtnn Oct 16, 2025
05562df
Refactor formatting in BKTClientImpl methods
duyhungtnn Oct 16, 2025
637f365
Ignore storage layer errors in evaluation logging
duyhungtnn Oct 16, 2025
0690d13
Refactor fetchEvaluations options handling
duyhungtnn Oct 16, 2025
cf642f9
Remove redundant nullish coalescing in options destructure
duyhungtnn Oct 16, 2025
cc7edb2
Refactor test to use fetchEvaluationsInternal parameter types
duyhungtnn Oct 16, 2025
846f491
feat: add generic retry utility with tests
duyhungtnn Oct 16, 2025
61bf9bc
Rename FutureRetriable to PromiseRetriable and update usage
duyhungtnn Oct 16, 2025
e04739c
Add backoff strategy to promiseRetriable retries
duyhungtnn Oct 16, 2025
87915fb
Add tests for ApiClientImpl and postInternal retry logic
duyhungtnn Oct 17, 2025
bfc8636
Refactor evaluation retry logic and failure tracking
duyhungtnn Oct 17, 2025
69c03c9
Refine retry logic in post and EvaluationTask
duyhungtnn Oct 17, 2025
5915cb8
Skip retries when polling interval is short
duyhungtnn Oct 17, 2025
7b18504
Improve timer handling in postInternal test
duyhungtnn Oct 17, 2025
71dcd62
fix(scheduler): prevent EvaluationTask from stopping after fetch
duyhungtnn Oct 20, 2025
a915fa9
Add test for repeated timer execution in EvaluationTask
duyhungtnn Oct 21, 2025
1c3b790
chore: remove redundant code
duyhungtnn Oct 21, 2025
e82cf2c
revert: restore code for EvaluationTask
duyhungtnn Oct 21, 2025
e24753a
Add debug logging for storage layer errors
duyhungtnn Oct 21, 2025
a53ba5b
Change storage error logging from debug to error
duyhungtnn Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/BKTClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface BKTClient {
export class BKTClientImpl implements BKTClient {
taskScheduler: TaskScheduler | null = null

constructor(public component: Component) {}
constructor(public component: Component) { }

async initializeInternal(timeoutMillis: number): Promise<void> {
this.scheduleTasks()
Expand Down Expand Up @@ -310,7 +310,10 @@ export class BKTClientImpl implements BKTClient {
ApiId.GET_EVALUATIONS,
component.config().featureTag,
result.error,
)
).catch((err) => {
/* ignore error from the storage layer */
console.error('BKTClient: Storage layer error in fetchEvaluations::trackFailure', err)
})
throw result.error
} else {
await component
Expand All @@ -320,7 +323,10 @@ export class BKTClientImpl implements BKTClient {
component.config().featureTag,
result.seconds,
result.sizeByte,
)
).catch((err) => {
/* ignore error from the storage layer */
console.error('BKTClient: Storage layer error in fetchEvaluations::trackSuccess', err)
})
}
}
}
Expand Down
80 changes: 80 additions & 0 deletions src/internal/remote/PromiseRetriable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export interface RetryPolicy {
/** Maximum number of retry attempts */
maxRetries: number
/** Base delay before the first retry attempt in milliseconds */
delay: number
/** Strategy for calculating retry delays. Defaults to linear. */
backoffStrategy?: BackoffStrategy
}

export type BackoffStrategy = 'constant' | 'linear'

/**
* Function to determine if an error should trigger a retry
*/
export type ShouldRetryFn = (error: Error) => boolean

/**
* A generic retry utility that executes a function with configurable retry logic
* @param fn The function to execute
* @param retryPolicy The retry configuration
* @param shouldRetry Function to determine if error should trigger retry
* @returns Promise resolving to the result or throwing the last error
*/
export async function promiseRetriable<T>(
fn: () => Promise<T>,
retryPolicy: RetryPolicy,
shouldRetry: ShouldRetryFn
// Future improvement: Allow cancellation in the future by adding an AbortSignal parameter
): Promise<T> {
const { maxRetries } = retryPolicy
let attempts = 0

while (attempts <= maxRetries) {
attempts++

try {
return await fn()
} catch (error) {
const lastError = error instanceof Error ? error : new Error(String(error))

// If this was the last attempt or we shouldn't retry this error, throw
if (attempts > maxRetries || !shouldRetry(lastError)) {
throw lastError
}

// Wait before next attempt
const waitTime = getBackoffDelay(attempts, retryPolicy)
if (waitTime > 0) {
await sleep(waitTime)
}
}
}

// This should never be reached due to the logic above
throw new Error('Unexpected end of retry loop')
}

/**
* Sleep utility function
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

function getBackoffDelay(attempt: number, retryPolicy: RetryPolicy): number {
const baseDelay = Math.max(0, retryPolicy.delay)
if (baseDelay === 0) {
return 0
}

const strategy = retryPolicy.backoffStrategy ?? 'linear'

switch (strategy) {
case 'constant':
return baseDelay
case 'linear':
default:
return baseDelay * Math.max(1, attempt)
}
}
33 changes: 32 additions & 1 deletion src/internal/remote/post.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NetworkException, TimeoutException } from '../../BKTExceptions'
import { ClientClosedRequestException, NetworkException, TimeoutException } from '../../BKTExceptions'
import { FetchLike, FetchRequestLike, FetchResponseLike } from './fetch'
import { promiseRetriable, RetryPolicy } from './PromiseRetriable'
import { addTimeoutValueIfNeeded, toBKTException } from './toBKTException'

export const postInternal = async (
Expand All @@ -8,6 +9,36 @@ export const postInternal = async (
body: object,
fetch: FetchLike,
timeoutMillis: number,
): Promise<FetchResponseLike> => {
const retryPolicy: RetryPolicy = {
maxRetries: 3,
delay: 1000,
backoffStrategy: 'linear',
}
// Retry only on a deployment-related 499
const shouldRetry = (error: Error): boolean => {
return error instanceof ClientClosedRequestException
}
// Default retry logic when we got a deployment-related 499 error
return promiseRetriable(
() => _postInternal(
endpoint,
headers,
body,
fetch,
timeoutMillis,
),
retryPolicy,
shouldRetry,
)
}

const _postInternal = async (
endpoint: string,
headers: FetchRequestLike['headers'],
body: object,
fetch: FetchLike,
timeoutMillis: number,
): Promise<FetchResponseLike> => {
const controller = new AbortController()

Expand Down
70 changes: 70 additions & 0 deletions test/internal/remote/ApiClientImpl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest'

import { ApiClientImpl } from '../../../src/internal/remote/ApiClient'
import { FetchResponseLike } from '../../../src/internal/remote/fetch'
import { SourceId } from '../../../src/internal/model/SourceId'
import { SDK_VERSION } from '../../../src/internal/version'
import { user1 } from '../../mocks/users'
import * as postModule from '../../../src/internal/remote/post'

describe('ApiClientImpl', () => {
it('should call postInternal', async () => {
vi.restoreAllMocks()

const mockResponse: FetchResponseLike = {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => name === 'Content-Length' ? '10' : null,
},
json: () => Promise.resolve({
evaluations: [],
userEvaluationsId: 'test_id',
}),
text: () => Promise.resolve(''),
}

const spy = vi.spyOn(postModule, 'postInternal').mockResolvedValue(mockResponse)

const apiClient = new ApiClientImpl(
'https://api.example.com',
'test_api_key',
fetch,
SourceId.JAVASCRIPT,
SDK_VERSION,
)

await apiClient.getEvaluations({
user: user1,
userEvaluationsId: 'test_id',
tag: 'test_tag',
userEvaluationCondition: {
evaluatedAt: '0',
userAttributesUpdated: false,
},
})

expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(
'https://api.example.com/get_evaluations',
{
'Content-Type': 'application/json',
Authorization: 'test_api_key',
},
{
user: user1,
userEvaluationsId: 'test_id',
tag: 'test_tag',
sourceId: SourceId.JAVASCRIPT,
sdkVersion: SDK_VERSION,
userEvaluationCondition: {
evaluatedAt: '0',
userAttributesUpdated: false,
},
},
fetch,
30000, // default timeout
)
})
})
Loading