mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat: add retry, pagination, and filtering utilities with comprehensive tests
- Add retry utility with exponential backoff for transient failures - Add pagination utilities supporting both offset and cursor-based pagination - Add filtering and sorting utilities for API requests - Include comprehensive unit tests (109 tests total, all passing) - Utilities designed to work with Prisma ORM Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
294
frontends/nextjs/src/lib/api/filtering.test.ts
Normal file
294
frontends/nextjs/src/lib/api/filtering.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Tests for filtering and sorting utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseFilterString,
|
||||
parseFilterObject,
|
||||
buildPrismaWhere,
|
||||
parseSortString,
|
||||
buildPrismaOrderBy,
|
||||
isValidFieldName,
|
||||
validateFilters,
|
||||
validateSort,
|
||||
} from './filtering'
|
||||
|
||||
describe('filtering and sorting utilities', () => {
|
||||
describe('parseFilterString', () => {
|
||||
it.each([
|
||||
{ input: '', expected: [], description: 'empty string' },
|
||||
{ input: 'name:John', expected: [{ field: 'name', operator: 'eq', value: 'John' }], description: 'simple equality' },
|
||||
{ input: 'age:gt:18', expected: [{ field: 'age', operator: 'gt', value: 18 }], description: 'greater than' },
|
||||
{ input: 'active:eq:true', expected: [{ field: 'active', operator: 'eq', value: true }], description: 'boolean true' },
|
||||
{ input: 'deleted:eq:false', expected: [{ field: 'deleted', operator: 'eq', value: false }], description: 'boolean false' },
|
||||
{ input: 'role:in:admin,user', expected: [{ field: 'role', operator: 'in', value: 'admin' }], description: 'in operator (first value)' },
|
||||
{ input: 'name:contains:john', expected: [{ field: 'name', operator: 'contains', value: 'john' }], description: 'contains operator' },
|
||||
{ input: 'email:startsWith:admin', expected: [{ field: 'email', operator: 'startsWith', value: 'admin' }], description: 'startsWith operator' },
|
||||
])('should parse $description', ({ input, expected }) => {
|
||||
const result = parseFilterString(input)
|
||||
if (expected.length === 0) {
|
||||
expect(result).toEqual(expected)
|
||||
} else {
|
||||
expect(result[0]).toEqual(expected[0])
|
||||
}
|
||||
})
|
||||
|
||||
it('should parse multiple filters', () => {
|
||||
const result = parseFilterString('name:John,age:gt:18,active:true')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toEqual({ field: 'name', operator: 'eq', value: 'John' })
|
||||
expect(result[1]).toEqual({ field: 'age', operator: 'gt', value: 18 })
|
||||
expect(result[2]).toEqual({ field: 'active', operator: 'eq', value: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseFilterObject', () => {
|
||||
it.each([
|
||||
{ input: {}, expected: [], description: 'empty object' },
|
||||
{ input: { name: 'John' }, expected: [{ field: 'name', operator: 'eq', value: 'John' }], description: 'simple equality' },
|
||||
{ input: { age: 18 }, expected: [{ field: 'age', operator: 'eq', value: 18 }], description: 'numeric value' },
|
||||
{ input: { active: true }, expected: [{ field: 'active', operator: 'eq', value: true }], description: 'boolean value' },
|
||||
{ input: { name: null }, expected: [{ field: 'name', operator: 'eq', value: null }], description: 'null value' },
|
||||
])('should parse $description', ({ input, expected }) => {
|
||||
const result = parseFilterObject(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should parse operator objects', () => {
|
||||
const result = parseFilterObject({ age: { $gt: 18, $lt: 65 } })
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ field: 'age', operator: 'gt', value: 18 })
|
||||
expect(result[1]).toEqual({ field: 'age', operator: 'lt', value: 65 })
|
||||
})
|
||||
|
||||
it('should parse multiple fields', () => {
|
||||
const result = parseFilterObject({ name: 'John', age: 30, active: true })
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toContainEqual({ field: 'name', operator: 'eq', value: 'John' })
|
||||
expect(result).toContainEqual({ field: 'age', operator: 'eq', value: 30 })
|
||||
expect(result).toContainEqual({ field: 'active', operator: 'eq', value: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPrismaWhere', () => {
|
||||
it.each([
|
||||
{
|
||||
input: [{ field: 'name', operator: 'eq' as const, value: 'John' }],
|
||||
expected: { name: 'John' },
|
||||
description: 'equality',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'age', operator: 'gt' as const, value: 18 }],
|
||||
expected: { age: { gt: 18 } },
|
||||
description: 'greater than',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'age', operator: 'gte' as const, value: 18 }],
|
||||
expected: { age: { gte: 18 } },
|
||||
description: 'greater than or equal',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'age', operator: 'lt' as const, value: 65 }],
|
||||
expected: { age: { lt: 65 } },
|
||||
description: 'less than',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'age', operator: 'lte' as const, value: 65 }],
|
||||
expected: { age: { lte: 65 } },
|
||||
description: 'less than or equal',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'role', operator: 'in' as const, value: ['admin', 'user'] }],
|
||||
expected: { role: { in: ['admin', 'user'] } },
|
||||
description: 'in array',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'role', operator: 'notIn' as const, value: ['guest'] }],
|
||||
expected: { role: { notIn: ['guest'] } },
|
||||
description: 'not in array',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'name', operator: 'contains' as const, value: 'john' }],
|
||||
expected: { name: { contains: 'john', mode: 'insensitive' } },
|
||||
description: 'contains',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'email', operator: 'startsWith' as const, value: 'admin' }],
|
||||
expected: { email: { startsWith: 'admin', mode: 'insensitive' } },
|
||||
description: 'startsWith',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'name', operator: 'endsWith' as const, value: 'son' }],
|
||||
expected: { name: { endsWith: 'son', mode: 'insensitive' } },
|
||||
description: 'endsWith',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'deletedAt', operator: 'isNull' as const }],
|
||||
expected: { deletedAt: null },
|
||||
description: 'is null',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'deletedAt', operator: 'isNotNull' as const }],
|
||||
expected: { deletedAt: { not: null } },
|
||||
description: 'is not null',
|
||||
},
|
||||
])('should build where clause for $description', ({ input, expected }) => {
|
||||
expect(buildPrismaWhere(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should build where clause with multiple conditions', () => {
|
||||
const conditions = [
|
||||
{ field: 'name', operator: 'eq' as const, value: 'John' },
|
||||
{ field: 'age', operator: 'gt' as const, value: 18 },
|
||||
{ field: 'active', operator: 'eq' as const, value: true },
|
||||
]
|
||||
|
||||
const result = buildPrismaWhere(conditions)
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'John',
|
||||
age: { gt: 18 },
|
||||
active: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSortString', () => {
|
||||
it.each([
|
||||
{ input: '', expected: [], description: 'empty string' },
|
||||
{ input: 'name', expected: [{ field: 'name', direction: 'asc' }], description: 'ascending' },
|
||||
{ input: '-name', expected: [{ field: 'name', direction: 'desc' }], description: 'descending' },
|
||||
{ input: 'name,-age', expected: [{ field: 'name', direction: 'asc' }, { field: 'age', direction: 'desc' }], description: 'multiple fields' },
|
||||
])('should parse $description', ({ input, expected }) => {
|
||||
expect(parseSortString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPrismaOrderBy', () => {
|
||||
it.each([
|
||||
{
|
||||
input: [{ field: 'name', direction: 'asc' as const }],
|
||||
expected: [{ name: 'asc' }],
|
||||
description: 'single ascending',
|
||||
},
|
||||
{
|
||||
input: [{ field: 'name', direction: 'desc' as const }],
|
||||
expected: [{ name: 'desc' }],
|
||||
description: 'single descending',
|
||||
},
|
||||
{
|
||||
input: [
|
||||
{ field: 'name', direction: 'asc' as const },
|
||||
{ field: 'age', direction: 'desc' as const },
|
||||
],
|
||||
expected: [{ name: 'asc' }, { age: 'desc' }],
|
||||
description: 'multiple fields',
|
||||
},
|
||||
])('should build orderBy for $description', ({ input, expected }) => {
|
||||
expect(buildPrismaOrderBy(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidFieldName', () => {
|
||||
it.each([
|
||||
{ input: 'name', expected: true, description: 'simple field' },
|
||||
{ input: 'user_name', expected: true, description: 'with underscore' },
|
||||
{ input: 'user.name', expected: true, description: 'nested field' },
|
||||
{ input: 'user.profile.name', expected: true, description: 'deeply nested' },
|
||||
{ input: 'name123', expected: true, description: 'with numbers' },
|
||||
{ input: 'name-field', expected: false, description: 'with hyphen' },
|
||||
{ input: 'name field', expected: false, description: 'with space' },
|
||||
{ input: 'name;DROP TABLE', expected: false, description: 'SQL injection attempt' },
|
||||
{ input: '../../../etc/passwd', expected: false, description: 'path traversal' },
|
||||
])('should validate $description', ({ input, expected }) => {
|
||||
expect(isValidFieldName(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFilters', () => {
|
||||
it('should validate valid filters', () => {
|
||||
const filters = [
|
||||
{ field: 'name', operator: 'eq' as const, value: 'John' },
|
||||
{ field: 'age', operator: 'gt' as const, value: 18 },
|
||||
]
|
||||
|
||||
const result = validateFilters(filters)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should reject invalid field names', () => {
|
||||
const filters = [
|
||||
{ field: 'name;DROP TABLE', operator: 'eq' as const, value: 'John' },
|
||||
]
|
||||
|
||||
const result = validateFilters(filters)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Invalid field name: name;DROP TABLE')
|
||||
})
|
||||
|
||||
it('should reject invalid operators', () => {
|
||||
const filters = [
|
||||
{ field: 'name', operator: 'invalid' as any, value: 'John' },
|
||||
]
|
||||
|
||||
const result = validateFilters(filters)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Invalid operator: invalid')
|
||||
})
|
||||
|
||||
it('should reject missing values for operators that need them', () => {
|
||||
const filters = [
|
||||
{ field: 'name', operator: 'eq' as const, value: undefined },
|
||||
]
|
||||
|
||||
const result = validateFilters(filters)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Operator eq requires a value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSort', () => {
|
||||
it('should validate valid sort conditions', () => {
|
||||
const sorts = [
|
||||
{ field: 'name', direction: 'asc' as const },
|
||||
{ field: 'age', direction: 'desc' as const },
|
||||
]
|
||||
|
||||
const result = validateSort(sorts)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should reject invalid field names', () => {
|
||||
const sorts = [
|
||||
{ field: 'name;DROP TABLE', direction: 'asc' as const },
|
||||
]
|
||||
|
||||
const result = validateSort(sorts)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Invalid field name: name;DROP TABLE')
|
||||
})
|
||||
|
||||
it('should reject invalid directions', () => {
|
||||
const sorts = [
|
||||
{ field: 'name', direction: 'invalid' as any },
|
||||
]
|
||||
|
||||
const result = validateSort(sorts)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Invalid sort direction: invalid')
|
||||
})
|
||||
})
|
||||
})
|
||||
264
frontends/nextjs/src/lib/api/filtering.ts
Normal file
264
frontends/nextjs/src/lib/api/filtering.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Filtering and sorting utilities for API requests
|
||||
*
|
||||
* Provides utilities to build filter and sort queries
|
||||
*/
|
||||
|
||||
export type FilterOperator =
|
||||
| 'eq' // Equal
|
||||
| 'ne' // Not equal
|
||||
| 'gt' // Greater than
|
||||
| 'gte' // Greater than or equal
|
||||
| 'lt' // Less than
|
||||
| 'lte' // Less than or equal
|
||||
| 'in' // In array
|
||||
| 'notIn' // Not in array
|
||||
| 'contains' // Contains substring
|
||||
| 'startsWith' // Starts with
|
||||
| 'endsWith' // Ends with
|
||||
| 'isNull' // Is null
|
||||
| 'isNotNull' // Is not null
|
||||
|
||||
export interface FilterCondition {
|
||||
field: string
|
||||
operator: FilterOperator
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface SortCondition {
|
||||
field: string
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse filter string to filter conditions
|
||||
* Format: field:operator:value or field:value (defaults to eq)
|
||||
*/
|
||||
export function parseFilterString(filterStr: string): FilterCondition[] {
|
||||
if (!filterStr.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filters: FilterCondition[] = []
|
||||
const parts = filterStr.split(',')
|
||||
|
||||
for (const part of parts) {
|
||||
const segments = part.trim().split(':')
|
||||
|
||||
if (segments.length === 2) {
|
||||
// field:value (default to eq)
|
||||
filters.push({
|
||||
field: segments[0],
|
||||
operator: 'eq',
|
||||
value: parseValue(segments[1]),
|
||||
})
|
||||
} else if (segments.length === 3) {
|
||||
// field:operator:value
|
||||
filters.push({
|
||||
field: segments[0],
|
||||
operator: segments[1] as FilterOperator,
|
||||
value: parseValue(segments[2]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse filter object to filter conditions
|
||||
*/
|
||||
export function parseFilterObject(filter: Record<string, unknown>): FilterCondition[] {
|
||||
const conditions: FilterCondition[] = []
|
||||
|
||||
for (const [field, value] of Object.entries(filter)) {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Handle operator objects: { field: { $gt: 5 } }
|
||||
for (const [op, val] of Object.entries(value)) {
|
||||
const operator = op.startsWith('$') ? op.slice(1) : op
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value: val,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Simple equality: { field: value }
|
||||
conditions.push({
|
||||
field,
|
||||
operator: 'eq',
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse value from string (handles numbers, booleans, null)
|
||||
*/
|
||||
function parseValue(value: string): unknown {
|
||||
if (value === 'null') return null
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
|
||||
const num = Number(value)
|
||||
if (!isNaN(num)) return num
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma where clause from filter conditions
|
||||
*/
|
||||
export function buildPrismaWhere(conditions: FilterCondition[]): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
for (const condition of conditions) {
|
||||
const { field, operator, value } = condition
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
where[field] = value
|
||||
break
|
||||
case 'ne':
|
||||
where[field] = { not: value }
|
||||
break
|
||||
case 'gt':
|
||||
where[field] = { gt: value }
|
||||
break
|
||||
case 'gte':
|
||||
where[field] = { gte: value }
|
||||
break
|
||||
case 'lt':
|
||||
where[field] = { lt: value }
|
||||
break
|
||||
case 'lte':
|
||||
where[field] = { lte: value }
|
||||
break
|
||||
case 'in':
|
||||
where[field] = { in: Array.isArray(value) ? value : [value] }
|
||||
break
|
||||
case 'notIn':
|
||||
where[field] = { notIn: Array.isArray(value) ? value : [value] }
|
||||
break
|
||||
case 'contains':
|
||||
where[field] = { contains: String(value), mode: 'insensitive' }
|
||||
break
|
||||
case 'startsWith':
|
||||
where[field] = { startsWith: String(value), mode: 'insensitive' }
|
||||
break
|
||||
case 'endsWith':
|
||||
where[field] = { endsWith: String(value), mode: 'insensitive' }
|
||||
break
|
||||
case 'isNull':
|
||||
where[field] = null
|
||||
break
|
||||
case 'isNotNull':
|
||||
where[field] = { not: null }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sort string to sort conditions
|
||||
* Format: field or -field (- prefix for desc) or field1,-field2
|
||||
*/
|
||||
export function parseSortString(sortStr: string): SortCondition[] {
|
||||
if (!sortStr.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const sorts: SortCondition[] = []
|
||||
const parts = sortStr.split(',')
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.startsWith('-')) {
|
||||
sorts.push({
|
||||
field: trimmed.slice(1),
|
||||
direction: 'desc',
|
||||
})
|
||||
} else {
|
||||
sorts.push({
|
||||
field: trimmed,
|
||||
direction: 'asc',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sorts
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma orderBy from sort conditions
|
||||
*/
|
||||
export function buildPrismaOrderBy(conditions: SortCondition[]): Record<string, SortDirection>[] {
|
||||
return conditions.map(condition => ({
|
||||
[condition.field]: condition.direction,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field name (prevents SQL injection)
|
||||
*/
|
||||
export function isValidFieldName(field: string): boolean {
|
||||
// Allow alphanumeric, underscore, and dot (for nested fields)
|
||||
return /^[a-zA-Z0-9_.]+$/.test(field)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter conditions
|
||||
*/
|
||||
export function validateFilters(conditions: FilterCondition[]): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (!isValidFieldName(condition.field)) {
|
||||
errors.push(`Invalid field name: ${condition.field}`)
|
||||
}
|
||||
|
||||
const validOperators: FilterOperator[] = [
|
||||
'eq', 'ne', 'gt', 'gte', 'lt', 'lte',
|
||||
'in', 'notIn', 'contains', 'startsWith', 'endsWith',
|
||||
'isNull', 'isNotNull',
|
||||
]
|
||||
|
||||
if (!validOperators.includes(condition.operator)) {
|
||||
errors.push(`Invalid operator: ${condition.operator}`)
|
||||
}
|
||||
|
||||
// Validate value is provided for operators that need it
|
||||
const operatorsNeedingValue = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'contains', 'startsWith', 'endsWith']
|
||||
if (operatorsNeedingValue.includes(condition.operator) && condition.value === undefined) {
|
||||
errors.push(`Operator ${condition.operator} requires a value`)
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sort conditions
|
||||
*/
|
||||
export function validateSort(conditions: SortCondition[]): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (!isValidFieldName(condition.field)) {
|
||||
errors.push(`Invalid field name: ${condition.field}`)
|
||||
}
|
||||
|
||||
if (!['asc', 'desc'].includes(condition.direction)) {
|
||||
errors.push(`Invalid sort direction: ${condition.direction}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
202
frontends/nextjs/src/lib/api/pagination.test.ts
Normal file
202
frontends/nextjs/src/lib/api/pagination.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Tests for pagination utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
normalizePaginationParams,
|
||||
calculatePaginationMetadata,
|
||||
calculateOffset,
|
||||
createPaginationResponse,
|
||||
normalizeCursorPaginationParams,
|
||||
calculateCursorPaginationMetadata,
|
||||
createCursorPaginationResponse,
|
||||
encodeCursor,
|
||||
decodeCursor,
|
||||
getPageNumbers,
|
||||
} from './pagination'
|
||||
|
||||
describe('pagination utilities', () => {
|
||||
describe('normalizePaginationParams', () => {
|
||||
it.each([
|
||||
{ input: {}, expected: { page: 1, limit: 20 }, description: 'default values' },
|
||||
{ input: { page: 2 }, expected: { page: 2, limit: 20 }, description: 'custom page' },
|
||||
{ input: { limit: 50 }, expected: { page: 1, limit: 50 }, description: 'custom limit' },
|
||||
{ input: { page: 3, limit: 30 }, expected: { page: 3, limit: 30 }, description: 'custom page and limit' },
|
||||
{ input: { page: 0 }, expected: { page: 1, limit: 20 }, description: 'negative page to 1' },
|
||||
{ input: { page: -5 }, expected: { page: 1, limit: 20 }, description: 'negative page to 1' },
|
||||
{ input: { limit: 0 }, expected: { page: 1, limit: 1 }, description: 'zero limit to 1' },
|
||||
{ input: { limit: -10 }, expected: { page: 1, limit: 1 }, description: 'negative limit to 1' },
|
||||
{ input: { limit: 200 }, expected: { page: 1, limit: 100 }, description: 'limit capped at 100' },
|
||||
])('should normalize $description', ({ input, expected }) => {
|
||||
expect(normalizePaginationParams(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePaginationMetadata', () => {
|
||||
it.each([
|
||||
{
|
||||
params: { page: 1, limit: 20 },
|
||||
total: 100,
|
||||
expected: { page: 1, limit: 20, total: 100, totalPages: 5, hasNextPage: true, hasPreviousPage: false },
|
||||
description: 'first page',
|
||||
},
|
||||
{
|
||||
params: { page: 3, limit: 20 },
|
||||
total: 100,
|
||||
expected: { page: 3, limit: 20, total: 100, totalPages: 5, hasNextPage: true, hasPreviousPage: true },
|
||||
description: 'middle page',
|
||||
},
|
||||
{
|
||||
params: { page: 5, limit: 20 },
|
||||
total: 100,
|
||||
expected: { page: 5, limit: 20, total: 100, totalPages: 5, hasNextPage: false, hasPreviousPage: true },
|
||||
description: 'last page',
|
||||
},
|
||||
{
|
||||
params: { page: 1, limit: 20 },
|
||||
total: 0,
|
||||
expected: { page: 1, limit: 20, total: 0, totalPages: 0, hasNextPage: false, hasPreviousPage: false },
|
||||
description: 'empty result set',
|
||||
},
|
||||
{
|
||||
params: { page: 1, limit: 20 },
|
||||
total: 15,
|
||||
expected: { page: 1, limit: 20, total: 15, totalPages: 1, hasNextPage: false, hasPreviousPage: false },
|
||||
description: 'single page result',
|
||||
},
|
||||
])('should calculate metadata for $description', ({ params, total, expected }) => {
|
||||
expect(calculatePaginationMetadata(params, total)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateOffset', () => {
|
||||
it.each([
|
||||
{ page: 1, limit: 20, expected: 0, description: 'first page' },
|
||||
{ page: 2, limit: 20, expected: 20, description: 'second page' },
|
||||
{ page: 3, limit: 10, expected: 20, description: 'third page with limit 10' },
|
||||
{ page: 5, limit: 25, expected: 100, description: 'fifth page with limit 25' },
|
||||
])('should calculate offset for $description', ({ page, limit, expected }) => {
|
||||
expect(calculateOffset(page, limit)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPaginationResponse', () => {
|
||||
it('should create complete pagination response', () => {
|
||||
const data = Array.from({ length: 20 }, (_, i) => ({ id: i + 1 }))
|
||||
const params = { page: 2, limit: 20 }
|
||||
const total = 100
|
||||
|
||||
const response = createPaginationResponse(data, params, total)
|
||||
|
||||
expect(response.data).toEqual(data)
|
||||
expect(response.meta).toEqual({
|
||||
page: 2,
|
||||
limit: 20,
|
||||
total: 100,
|
||||
totalPages: 5,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeCursorPaginationParams', () => {
|
||||
it.each([
|
||||
{ input: {}, expected: { limit: 20, after: undefined, before: undefined }, description: 'default values' },
|
||||
{ input: { limit: 50 }, expected: { limit: 50, after: undefined, before: undefined }, description: 'custom limit' },
|
||||
{ input: { after: 'cursor1' }, expected: { limit: 20, after: 'cursor1', before: undefined }, description: 'with after cursor' },
|
||||
{ input: { before: 'cursor2' }, expected: { limit: 20, after: undefined, before: 'cursor2' }, description: 'with before cursor' },
|
||||
{ input: { limit: 200 }, expected: { limit: 100, after: undefined, before: undefined }, description: 'limit capped at 100' },
|
||||
])('should normalize cursor params for $description', ({ input, expected }) => {
|
||||
expect(normalizeCursorPaginationParams(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCursorPaginationMetadata', () => {
|
||||
it('should calculate metadata with items', () => {
|
||||
const items = [
|
||||
{ id: 'id1', name: 'Item 1' },
|
||||
{ id: 'id2', name: 'Item 2' },
|
||||
{ id: 'id3', name: 'Item 3' },
|
||||
]
|
||||
|
||||
const meta = calculateCursorPaginationMetadata(items, 3, true)
|
||||
|
||||
expect(meta).toEqual({
|
||||
limit: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
startCursor: 'id1',
|
||||
endCursor: 'id3',
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate metadata with empty items', () => {
|
||||
const items: { id: string }[] = []
|
||||
|
||||
const meta = calculateCursorPaginationMetadata(items, 20, false)
|
||||
|
||||
expect(meta).toEqual({
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: undefined,
|
||||
endCursor: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCursorPaginationResponse', () => {
|
||||
it('should create cursor pagination response', () => {
|
||||
const data = [
|
||||
{ id: 'id1', name: 'Item 1' },
|
||||
{ id: 'id2', name: 'Item 2' },
|
||||
]
|
||||
|
||||
const response = createCursorPaginationResponse(data, 2, true)
|
||||
|
||||
expect(response.data).toEqual(data)
|
||||
expect(response.meta).toEqual({
|
||||
limit: 2,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
startCursor: 'id1',
|
||||
endCursor: 'id2',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor encoding/decoding', () => {
|
||||
it.each([
|
||||
{ id: 'simple-id', description: 'simple ID' },
|
||||
{ id: 'uuid-v4-1234-5678', description: 'UUID' },
|
||||
{ id: '12345', description: 'numeric ID' },
|
||||
{ id: 'special!@#$%', description: 'special characters' },
|
||||
])('should encode and decode $description', ({ id }) => {
|
||||
const encoded = encodeCursor(id)
|
||||
const decoded = decodeCursor(encoded)
|
||||
|
||||
expect(decoded).toBe(id)
|
||||
expect(encoded).not.toBe(id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPageNumbers', () => {
|
||||
it.each([
|
||||
{ currentPage: 1, totalPages: 5, expected: [1, 2, 3, 4, 5], description: 'all pages visible' },
|
||||
{ currentPage: 1, totalPages: 10, expected: [1, 2, 3, 4, 5, 6, 7], description: 'first page of many' },
|
||||
{ currentPage: 5, totalPages: 10, expected: [2, 3, 4, 5, 6, 7, 8], description: 'middle page' },
|
||||
{ currentPage: 10, totalPages: 10, expected: [4, 5, 6, 7, 8, 9, 10], description: 'last page' },
|
||||
{ currentPage: 1, totalPages: 1, expected: [1], description: 'single page' },
|
||||
{ currentPage: 2, totalPages: 3, expected: [1, 2, 3], description: 'few pages' },
|
||||
])('should get page numbers for $description', ({ currentPage, totalPages, expected }) => {
|
||||
expect(getPageNumbers(currentPage, totalPages)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should respect maxVisible parameter', () => {
|
||||
expect(getPageNumbers(5, 20, 5)).toEqual([3, 4, 5, 6, 7])
|
||||
expect(getPageNumbers(10, 20, 3)).toEqual([9, 10, 11])
|
||||
})
|
||||
})
|
||||
})
|
||||
172
frontends/nextjs/src/lib/api/pagination.ts
Normal file
172
frontends/nextjs/src/lib/api/pagination.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Pagination utilities for API requests and UI components
|
||||
*
|
||||
* Provides utilities for both offset-based and cursor-based pagination
|
||||
*/
|
||||
|
||||
export interface PaginationMetadata {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNextPage: boolean
|
||||
hasPreviousPage: boolean
|
||||
}
|
||||
|
||||
export interface CursorPaginationMetadata {
|
||||
limit: number
|
||||
hasNextPage: boolean
|
||||
hasPreviousPage: boolean
|
||||
startCursor?: string
|
||||
endCursor?: string
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CursorPaginationParams {
|
||||
limit?: number
|
||||
after?: string
|
||||
before?: string
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE = 1
|
||||
const DEFAULT_LIMIT = 20
|
||||
const MAX_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Normalize pagination parameters
|
||||
*/
|
||||
export function normalizePaginationParams(params: PaginationParams): Required<PaginationParams> {
|
||||
const page = Math.max(1, params.page ?? DEFAULT_PAGE)
|
||||
const limit = Math.min(MAX_LIMIT, Math.max(1, params.limit ?? DEFAULT_LIMIT))
|
||||
|
||||
return { page, limit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pagination metadata
|
||||
*/
|
||||
export function calculatePaginationMetadata(
|
||||
params: Required<PaginationParams>,
|
||||
total: number
|
||||
): PaginationMetadata {
|
||||
const { page, limit } = params
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate offset for database query
|
||||
*/
|
||||
export function calculateOffset(page: number, limit: number): number {
|
||||
return (page - 1) * limit
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pagination response
|
||||
*/
|
||||
export function createPaginationResponse<T>(
|
||||
data: T[],
|
||||
params: PaginationParams,
|
||||
total: number
|
||||
): { data: T[]; meta: PaginationMetadata } {
|
||||
const normalizedParams = normalizePaginationParams(params)
|
||||
const meta = calculatePaginationMetadata(normalizedParams, total)
|
||||
|
||||
return { data, meta }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize cursor pagination parameters
|
||||
*/
|
||||
export function normalizeCursorPaginationParams(
|
||||
params: CursorPaginationParams
|
||||
): Required<Omit<CursorPaginationParams, 'after' | 'before'>> & Pick<CursorPaginationParams, 'after' | 'before'> {
|
||||
const limit = Math.min(MAX_LIMIT, Math.max(1, params.limit ?? DEFAULT_LIMIT))
|
||||
|
||||
return {
|
||||
limit,
|
||||
after: params.after,
|
||||
before: params.before,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cursor pagination metadata
|
||||
*/
|
||||
export function calculateCursorPaginationMetadata<T extends { id: string }>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
hasMore: boolean
|
||||
): CursorPaginationMetadata {
|
||||
const startCursor = items.length > 0 ? items[0].id : undefined
|
||||
const endCursor = items.length > 0 ? items[items.length - 1].id : undefined
|
||||
|
||||
return {
|
||||
limit,
|
||||
hasNextPage: hasMore,
|
||||
hasPreviousPage: !!startCursor,
|
||||
startCursor,
|
||||
endCursor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cursor pagination response
|
||||
*/
|
||||
export function createCursorPaginationResponse<T extends { id: string }>(
|
||||
data: T[],
|
||||
limit: number,
|
||||
hasMore: boolean
|
||||
): { data: T[]; meta: CursorPaginationMetadata } {
|
||||
const meta = calculateCursorPaginationMetadata(data, limit, hasMore)
|
||||
|
||||
return { data, meta }
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode cursor (base64 encode the ID)
|
||||
*/
|
||||
export function encodeCursor(id: string): string {
|
||||
return Buffer.from(id).toString('base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode cursor (base64 decode to get ID)
|
||||
*/
|
||||
export function decodeCursor(cursor: string): string {
|
||||
return Buffer.from(cursor, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page numbers for pagination UI
|
||||
*/
|
||||
export function getPageNumbers(currentPage: number, totalPages: number, maxVisible: number = 7): number[] {
|
||||
if (totalPages <= maxVisible) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const halfVisible = Math.floor(maxVisible / 2)
|
||||
let startPage = Math.max(1, currentPage - halfVisible)
|
||||
let endPage = Math.min(totalPages, currentPage + halfVisible)
|
||||
|
||||
// Adjust if we're near the start or end
|
||||
if (currentPage <= halfVisible) {
|
||||
endPage = maxVisible
|
||||
} else if (currentPage >= totalPages - halfVisible) {
|
||||
startPage = totalPages - maxVisible + 1
|
||||
}
|
||||
|
||||
return Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i)
|
||||
}
|
||||
223
frontends/nextjs/src/lib/api/retry.test.ts
Normal file
223
frontends/nextjs/src/lib/api/retry.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Tests for retry utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { retryFetch, retry } from './retry'
|
||||
|
||||
describe('retry utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
describe('retryFetch', () => {
|
||||
it.each([
|
||||
{ statusCode: 200, shouldRetry: false, description: 'successful request' },
|
||||
{ statusCode: 404, shouldRetry: false, description: 'client error (not retryable)' },
|
||||
{ statusCode: 500, shouldRetry: true, description: 'server error (retryable)' },
|
||||
{ statusCode: 502, shouldRetry: true, description: 'bad gateway (retryable)' },
|
||||
{ statusCode: 503, shouldRetry: true, description: 'service unavailable (retryable)' },
|
||||
{ statusCode: 429, shouldRetry: true, description: 'rate limited (retryable)' },
|
||||
])('should handle $description correctly', async ({ statusCode, shouldRetry }) => {
|
||||
let callCount = 0
|
||||
const mockFetch = vi.fn(async () => {
|
||||
callCount++
|
||||
return new Response(JSON.stringify({ test: 'data' }), {
|
||||
status: callCount === 1 && shouldRetry ? statusCode : (callCount === 1 ? statusCode : 200),
|
||||
})
|
||||
})
|
||||
|
||||
let responsePromise: Promise<Response>
|
||||
if (shouldRetry) {
|
||||
responsePromise = retryFetch(mockFetch, { maxRetries: 2, initialDelayMs: 10 })
|
||||
// Fast-forward through retry delays
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
} else {
|
||||
responsePromise = retryFetch(mockFetch, { maxRetries: 2, initialDelayMs: 10 })
|
||||
}
|
||||
|
||||
const response = await responsePromise
|
||||
|
||||
if (shouldRetry) {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(response.status).toBe(200)
|
||||
} else {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(response.status).toBe(statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
it('should retry up to maxRetries times', async () => {
|
||||
let callCount = 0
|
||||
const mockFetch = vi.fn(async () => {
|
||||
callCount++
|
||||
return new Response(JSON.stringify({ error: 'Server error' }), { status: 500 })
|
||||
})
|
||||
|
||||
const promise = retryFetch(mockFetch, { maxRetries: 3, initialDelayMs: 10 })
|
||||
|
||||
// Fast-forward through all retry delays
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
const response = await promise
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4) // initial + 3 retries
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
|
||||
it('should use exponential backoff', async () => {
|
||||
const delays: number[] = []
|
||||
let callCount = 0
|
||||
|
||||
const mockFetch = vi.fn(async () => {
|
||||
callCount++
|
||||
return new Response(JSON.stringify({ error: 'Server error' }), { status: 500 })
|
||||
})
|
||||
|
||||
const promise = retryFetch(mockFetch, {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
backoffMultiplier: 2,
|
||||
})
|
||||
|
||||
// Capture delays by advancing timers incrementally
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
const delay = 100 * Math.pow(2, i)
|
||||
await vi.advanceTimersByTimeAsync(delay)
|
||||
}
|
||||
|
||||
await promise
|
||||
|
||||
// Verify exponential backoff: 100ms, 200ms, 400ms
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('should handle network errors with retries', async () => {
|
||||
let callCount = 0
|
||||
const mockFetch = vi.fn(async () => {
|
||||
callCount++
|
||||
if (callCount < 3) {
|
||||
throw new Error('Network error')
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 })
|
||||
})
|
||||
|
||||
const promise = retryFetch(mockFetch, { maxRetries: 3, initialDelayMs: 10 })
|
||||
|
||||
// Fast-forward through retry delays
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
const response = await promise
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should throw error after max retries exceeded', async () => {
|
||||
const mockFetch = vi.fn(async () => {
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
const promise = retryFetch(mockFetch, { maxRetries: 2, initialDelayMs: 10 })
|
||||
|
||||
// Fast-forward through all retry delays
|
||||
vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(promise).rejects.toThrow('Network error')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3) // initial + 2 retries
|
||||
})
|
||||
|
||||
it('should respect maxDelayMs', async () => {
|
||||
let callCount = 0
|
||||
const mockFetch = vi.fn(async () => {
|
||||
callCount++
|
||||
return new Response(JSON.stringify({ error: 'Server error' }), { status: 500 })
|
||||
})
|
||||
|
||||
const promise = retryFetch(mockFetch, {
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 2000,
|
||||
backoffMultiplier: 2,
|
||||
})
|
||||
|
||||
// The delays would be: 1000, 2000, 2000 (capped), 2000 (capped), 2000 (capped)
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
|
||||
await promise
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry', () => {
|
||||
it('should retry async function on failure', async () => {
|
||||
let callCount = 0
|
||||
const mockFn = vi.fn(async () => {
|
||||
callCount++
|
||||
if (callCount < 2) {
|
||||
throw new Error('Temporary error')
|
||||
}
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const promise = retry(mockFn, { maxRetries: 2, initialDelayMs: 10 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should return result on first success', async () => {
|
||||
const mockFn = vi.fn(async () => 'success')
|
||||
|
||||
const result = await retry(mockFn, { maxRetries: 3, initialDelayMs: 10 })
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should throw after max retries', async () => {
|
||||
const mockFn = vi.fn(async () => {
|
||||
throw new Error('Persistent error')
|
||||
})
|
||||
|
||||
const promise = retry(mockFn, { maxRetries: 2, initialDelayMs: 10 })
|
||||
|
||||
vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(promise).rejects.toThrow('Persistent error')
|
||||
expect(mockFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should use exponential backoff', async () => {
|
||||
let callCount = 0
|
||||
const mockFn = vi.fn(async () => {
|
||||
callCount++
|
||||
if (callCount < 4) {
|
||||
throw new Error('Temporary error')
|
||||
}
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const promise = retry(mockFn, {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
backoffMultiplier: 2,
|
||||
})
|
||||
|
||||
// Advance through all delays: 100ms, 200ms, 400ms
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(4)
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
})
|
||||
})
|
||||
127
frontends/nextjs/src/lib/api/retry.ts
Normal file
127
frontends/nextjs/src/lib/api/retry.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Utility for retrying failed API requests
|
||||
*
|
||||
* Provides exponential backoff retry logic for transient failures
|
||||
*/
|
||||
|
||||
export interface RetryOptions {
|
||||
maxRetries?: number
|
||||
initialDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
backoffMultiplier?: number
|
||||
retryableStatusCodes?: number[]
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<RetryOptions> = {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 5000,
|
||||
backoffMultiplier: 2,
|
||||
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delay with exponential backoff
|
||||
*/
|
||||
function calculateDelay(attempt: number, options: Required<RetryOptions>): number {
|
||||
const delay = options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt)
|
||||
return Math.min(delay, options.maxDelayMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status code is retryable
|
||||
*/
|
||||
function isRetryable(statusCode: number, retryableStatusCodes: number[]): boolean {
|
||||
return retryableStatusCodes.includes(statusCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a fetch request with exponential backoff
|
||||
*
|
||||
* @param fn - Function that returns a fetch promise
|
||||
* @param options - Retry options
|
||||
* @returns Promise that resolves with the response or rejects after all retries
|
||||
*/
|
||||
export async function retryFetch<T>(
|
||||
fn: () => Promise<Response>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<Response> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options }
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fn()
|
||||
|
||||
// If response is ok or not retryable, return it
|
||||
if (response.ok || !isRetryable(response.status, opts.retryableStatusCodes)) {
|
||||
return response
|
||||
}
|
||||
|
||||
// If this was the last attempt, return the failed response
|
||||
if (attempt === opts.maxRetries) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
const delay = calculateDelay(attempt, opts)
|
||||
await sleep(delay)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// If this was the last attempt, throw the error
|
||||
if (attempt === opts.maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
const delay = calculateDelay(attempt, opts)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but TypeScript requires it
|
||||
throw lastError ?? new Error('Retry failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an async function with exponential backoff
|
||||
*
|
||||
* @param fn - Async function to retry
|
||||
* @param options - Retry options
|
||||
* @returns Promise that resolves with the result or rejects after all retries
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options }
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// If this was the last attempt, throw the error
|
||||
if (attempt === opts.maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
const delay = calculateDelay(attempt, opts)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but TypeScript requires it
|
||||
throw lastError ?? new Error('Retry failed')
|
||||
}
|
||||
Reference in New Issue
Block a user