diff --git a/packages/common-aws/package.json b/packages/common-aws/package.json index 2c352b9..2b6ca5c 100644 --- a/packages/common-aws/package.json +++ b/packages/common-aws/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/common-aws", - "version": "2.6.0", + "version": "2.7.0", "description": "Common aws for paradox services", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/common-aws/src/parameter-store/api/parameterStoreApi.ts b/packages/common-aws/src/parameter-store/api/parameterStoreApi.ts index 9ae8138..b6049bd 100644 --- a/packages/common-aws/src/parameter-store/api/parameterStoreApi.ts +++ b/packages/common-aws/src/parameter-store/api/parameterStoreApi.ts @@ -6,10 +6,9 @@ import { PutParameterCommand, SSMClient, } from '@aws-sdk/client-ssm'; -import { Arrays, Limiter } from '@paradoxical-io/common'; -import { log, retry as autoRetry } from '@paradoxical-io/common-server'; +import { Arrays, Limiter, retry as autoRetry } from '@paradoxical-io/common'; +import { log } from '@paradoxical-io/common-server'; import { ErrorCode, ErrorWithCode, isErrorWithCode } from '@paradoxical-io/types'; - import retry = require('async-retry'); export class ParameterStoreApi { diff --git a/packages/common-aws/src/sqs/README.md b/packages/common-aws/src/sqs/README.md index 83cbf50..8150827 100644 --- a/packages/common-aws/src/sqs/README.md +++ b/packages/common-aws/src/sqs/README.md @@ -37,20 +37,18 @@ interface UserEvent { } // Create a publisher -const publisher = new SQSPublisher( - 'https://sqs.us-west-2.amazonaws.com/123456789/my-queue' as QueueUrl -); +const publisher = new SQSPublisher('https://sqs.us-west-2.amazonaws.com/123456789/my-queue' as QueueUrl); // Publish a single message await publisher.publish({ userId: 'user-123', - action: 'login' + action: 'login', }); // Publish multiple messages (automatically batched in chunks of 10) await publisher.publish([ { userId: 'user-123', action: 'login' }, - { userId: 'user-456', action: 'logout' } + { userId: 'user-456', action: 'logout' }, ]); ``` @@ -65,8 +63,8 @@ await publisher.publish( { delay: { type: 'invisible', - seconds: 30 as Seconds - } + seconds: 30 as Seconds, + }, } ); ``` @@ -78,15 +76,15 @@ Schedule messages to be processed at a specific time, even beyond SQS's 15-minut ```typescript import { EpochMS } from '@paradoxical-io/types'; -const processAt = Date.now() + (3 * 24 * 60 * 60 * 1000); // 3 days from now +const processAt = Date.now() + 3 * 24 * 60 * 60 * 1000; // 3 days from now await publisher.publish( { userId: 'user-123', action: 'reminder' }, { delay: { type: 'processAfter', - epoch: processAt as EpochMS - } + epoch: processAt as EpochMS, + }, } ); ``` @@ -108,7 +106,7 @@ class OrderProcessor extends SQSConsumer { constructor() { super('https://sqs.us-west-2.amazonaws.com/123456789/orders' as QueueUrl, { maxNumberOfMessages: 5, - longPollWaitTimeSeconds: 20 as Seconds + longPollWaitTimeSeconds: 20 as Seconds, }); } @@ -122,7 +120,7 @@ class OrderProcessor extends SQSConsumer { return { type: 'retry-later', reason: 'Database temporarily unavailable', - retryInSeconds: 60 as Seconds + retryInSeconds: 60 as Seconds, }; } throw error; // Non-retryable errors let the message go to DLQ @@ -147,10 +145,10 @@ import { newConsumer, SQSConfig } from '@paradoxical-io/common-aws/sqs'; const config: SQSConfig = { queueUrl: 'https://sqs.us-west-2.amazonaws.com/123456789/orders' as QueueUrl, - maxNumberOfMessages: 10 + maxNumberOfMessages: 10, }; -const consumer = newConsumer(async (event) => { +const consumer = newConsumer(async event => { console.log('Processing:', event); // Returning void acknowledges the message }, config); @@ -209,6 +207,7 @@ async process(data: OrderEvent): Promise { The retry delay will be calculated as: `min + 2^(publishCount)` Examples: + - 1st retry: 1 + 2^1 = 3 seconds - 2nd retry: 1 + 2^2 = 5 seconds - 3rd retry: 1 + 2^3 = 9 seconds @@ -245,6 +244,7 @@ await runSQS([consumer1, consumer2]); ``` The `runSQS` function: + - Starts all consumers concurrently - Registers shutdown signal handlers (SIGTERM, SIGINT) - Ensures graceful shutdown with proper cleanup @@ -310,11 +310,13 @@ await docker.container.stop(); Main class for publishing messages to SQS. **Constructor:** + ```typescript new SQSPublisher(queueUrl: string, sqs?: SQSClient) ``` **Methods:** + - `publish(data: T | T[], opts?: PublishOptions): Promise` - Publish one or more messages ### SQSConsumer @@ -322,28 +324,32 @@ new SQSPublisher(queueUrl: string, sqs?: SQSClient) Abstract base class for consuming messages. **Constructor:** + ```typescript new SQSConsumer(queueUrl: QueueUrl, opts?: Partial) ``` **Abstract Methods:** + - `process(data: T): Promise` - Implement to handle messages **Methods:** + - `start(): Promise` - Start consuming messages - `stop(opts?: { timeoutMilli?: number; flush?: boolean }): void` - Stop the consumer - `adhoc(message: Message): Promise` - Process a single message ad-hoc **Options:** + ```typescript interface Options { - longPollWaitTimeSeconds: Seconds; // Default: 20 - maxNumberOfMessages: Max10; // Default: 10 - sqs: SQSClient; // Default: new SQSClient() - makeAvailableOnError: boolean; // Default: false - timeProvider: TimeProvider; // Default: defaultTimeProvider() + longPollWaitTimeSeconds: Seconds; // Default: 20 + maxNumberOfMessages: Max10; // Default: 10 + sqs: SQSClient; // Default: new SQSClient() + makeAvailableOnError: boolean; // Default: false + timeProvider: TimeProvider; // Default: defaultTimeProvider() maxVisibilityTimeoutSeconds?: Seconds; // Optional - proxyProvider?: ProxyQueueProvider; // Optional + proxyProvider?: ProxyQueueProvider; // Optional } ``` @@ -384,11 +390,13 @@ interface RetryMessageLater { interface RepublishMessage { type: 'republish-later'; reason: string; - retryInSeconds: Seconds | { - type: 'exponential-backoff'; - max: Seconds; - min: Seconds; - }; + retryInSeconds: + | Seconds + | { + type: 'exponential-backoff'; + max: Seconds; + min: Seconds; + }; expireFromFirstPublishMS?: Milliseconds; } ``` @@ -417,4 +425,4 @@ interface RepublishMessage { - **Trace Propagation**: Trace IDs are automatically generated and propagated through message lifecycle for distributed tracing - **Visibility Management**: The module handles message visibility for both retry strategies appropriately - **Error Handling**: Unhandled errors are logged with trace context and metrics but don't affect other messages in the batch -- **Development Proxy**: In non-production environments, messages can be proxied to additional queues for testing \ No newline at end of file +- **Development Proxy**: In non-production environments, messages can be proxied to additional queues for testing diff --git a/packages/common-hapi/package.json b/packages/common-hapi/package.json index 84ddbee..7a2b4df 100644 --- a/packages/common-hapi/package.json +++ b/packages/common-hapi/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/common-hapi", - "version": "2.6.0", + "version": "2.7.0", "description": "Common hapi code for paradoxical services", "files": [ "dist/**/*.js", diff --git a/packages/common-server/README.md b/packages/common-server/README.md index fa74ff3..2b1ac11 100644 --- a/packages/common-server/README.md +++ b/packages/common-server/README.md @@ -130,26 +130,26 @@ const configShape = (env: Env) => ({ doc: 'Server port', format: 'port', default: 3000, - env: 'PORT' + env: 'PORT', }, database: { host: { doc: 'Database host', format: String, - default: 'localhost' + default: 'localhost', }, port: { doc: 'Database port', format: 'port', - default: 5432 - } + default: 5432, + }, }, apiKey: { doc: 'API key', format: String, default: '', - sensitive: true - } + sensitive: true, + }, }); // Load from config/local.json or config/prod.json @@ -248,12 +248,12 @@ const users = await reader.read('/path/to/users.csv'); const csv = new Csv('/path/to/output.csv', [ { id: 'name', title: 'Name' }, { id: 'email', title: 'Email' }, - { id: 'age', title: 'Age' } + { id: 'age', title: 'Age' }, ]); await csv.write([ { name: 'John', email: 'john@example.com', age: '30' }, - { name: 'Jane', email: 'jane@example.com', age: '25' } + { name: 'Jane', email: 'jane@example.com', age: '25' }, ]); ``` @@ -268,13 +268,13 @@ import { spawnPromise, runShell } from '@paradoxical-io/common-server'; const { code, result } = await spawnPromise('npm', ['install'], { verbose: true, cwd: '/path/to/project', - acceptableErrorCodes: [0] + acceptableErrorCodes: [0], }); // Run shell command const exitCode = await runShell('git status', { verbose: true, - cwd: process.cwd() + cwd: process.cwd(), }); ``` @@ -347,6 +347,7 @@ The library respects several environment variables for configuration: ## API ### Logger + - `log.info(msg)` - Log info message - `log.error(msg, error?)` - Log error with optional error object - `log.warn(msg, error?)` - Log warning @@ -356,17 +357,20 @@ The library respects several environment variables for configuration: - `log.alarm(msg)` - Log alertable message ### Metrics + - `Metrics.instance.increment(name, tags?)` - Increment counter - `Metrics.instance.timing(name, ms, tags?)` - Record timing - `Metrics.instance.gauge(name, value, tags?)` - Set gauge value ### Decorators + - `@logMethod(options?)` - Log method calls with arguments - `@timed(options?)` - Track method execution time - `@retry(options?)` - Add retry logic to methods - `@sensitive(redaction?)` - Mark parameters as sensitive ### Tracing + - `withNewTrace(fn, trace?, context?)` - Create new trace context - `traceID()` - Get current trace ID - `setCurrentUserId(userId)` - Set user ID in trace context @@ -378,4 +382,4 @@ MIT ## Author -Anton Kropp \ No newline at end of file +Anton Kropp diff --git a/packages/common-server/package.json b/packages/common-server/package.json index 9384934..46a34ef 100644 --- a/packages/common-server/package.json +++ b/packages/common-server/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/common-server", - "version": "2.6.0", + "version": "2.7.0", "description": "Common code for paradox services", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/common-server/src/index.ts b/packages/common-server/src/index.ts index 0a626ab..c4c649f 100644 --- a/packages/common-server/src/index.ts +++ b/packages/common-server/src/index.ts @@ -12,7 +12,6 @@ export * from './logger'; export * from './metrics'; export * from './path'; export * from './process'; -export * from './retry/retryDecorator'; export * from './sftp'; export * from './test'; export * from './trace'; diff --git a/packages/common-server/src/metrics/metrics.test.ts b/packages/common-server/src/metrics/metrics.test.ts index 5f23455..be5c69f 100644 --- a/packages/common-server/src/metrics/metrics.test.ts +++ b/packages/common-server/src/metrics/metrics.test.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console,@typescript-eslint/no-explicit-any */ // make sure we allow all logging (even if the test runner says otherwise) + process.env.PARADOX_LOG_LEVEL = 'info'; // ensure log decorators always run diff --git a/packages/common-server/src/retry/README.md b/packages/common-server/src/retry/README.md index 6c4cffb..fbfb02a 100644 --- a/packages/common-server/src/retry/README.md +++ b/packages/common-server/src/retry/README.md @@ -77,7 +77,7 @@ class PaymentService { minTimeout: 1000, maxTimeout: 30000, // Bail immediately on validation errors - bailOn: (error: Error) => error.name === 'ValidationError' + bailOn: (error: Error) => error.name === 'ValidationError', }) async processPayment(amount: number): Promise { return await this.paymentGateway.charge(amount); @@ -105,7 +105,7 @@ async function waitForJobCompletion(jobId: string) { minTimeout: 100, maxTimeout: 5000, factor: 2, // Double the wait time on each retry - expiresAfter: asMilli(5, 'minutes') + expiresAfter: asMilli(5, 'minutes'), } ); @@ -136,7 +136,7 @@ async function waitForServerReady(url: string) { } }, 30000 as Milliseconds, // Expire after 30 seconds - 1000 as Milliseconds // Check every 1 second + 1000 as Milliseconds // Check every 1 second ); if (result.type === 'completed') { @@ -154,6 +154,7 @@ async function waitForServerReady(url: string) { A method decorator that automatically retries async methods on failure. **Options:** + - `retries?: number` - Maximum number of retry attempts (default: 10) - `minTimeout?: number` - Minimum wait time in milliseconds between retries (default: 1000) - `maxTimeout?: number` - Maximum wait time in milliseconds between retries (default: Infinity) @@ -168,10 +169,12 @@ A method decorator that automatically retries async methods on failure. A specialized retry decorator for Axios HTTP requests. **Parameters:** + - `skipCodes?: number[]` - HTTP status codes to skip retrying (default: `[400]`) - `options?: asyncRetry.Options` - Additional retry options **Defaults:** + - `retries: 3` - `maxTimeout: 5000ms` - Automatically bails on specified HTTP status codes @@ -181,11 +184,13 @@ A specialized retry decorator for Axios HTTP requests. Polls a function with exponential backoff until it returns a value or times out. **Parameters:** + - `block: () => Promise` - Function to poll; return `undefined` to continue polling - `options: asyncRetry.Options & { expiresAfter: Milliseconds }` - Retry options plus absolute expiration time - `time?: TimeProvider` - Optional time provider for testing **Returns:** `Promise>` where `Result` is: + - `{ type: 'completed', data: T }` - Successfully retrieved data - `{ type: 'timeout' }` - Polling timed out @@ -194,6 +199,7 @@ Polls a function with exponential backoff until it returns a value or times out. Polls a function at fixed intervals until it returns a value or times out. **Parameters:** + - `block: () => Promise` - Function to poll; return `undefined` to continue polling - `expiresIn: Milliseconds` - Time until polling expires - `waitTime: Milliseconds` - Wait time between each poll attempt @@ -204,16 +210,19 @@ Polls a function at fixed intervals until it returns a value or times out. ## Best Practices 1. **Choose the Right Strategy:** + - Use `@retry` for transient failures in method calls - Use `axiosRetry` for HTTP requests - Use `exponentialPoll` when you want to reduce load on the polled resource - Use `linearPoll` when you need predictable polling intervals 2. **Set Appropriate Timeouts:** + - Always set `expiresAfter` or `expiresIn` to prevent infinite polling - Configure `maxTimeout` to prevent excessive wait times 3. **Handle Both Success and Timeout:** + ```typescript const result = await exponentialPoll(...); @@ -233,6 +242,7 @@ Polls a function at fixed intervals until it returns a value or times out. ## Dependencies This module depends on: + - `async-retry` - For retry logic implementation - `@paradoxical-io/common` - For time utilities and helpers - `@paradoxical-io/types` - For type definitions diff --git a/packages/common-server/src/retry/poll.test.ts b/packages/common-server/src/retry/poll.test.ts index b6c50ee..777e1a7 100644 --- a/packages/common-server/src/retry/poll.test.ts +++ b/packages/common-server/src/retry/poll.test.ts @@ -1,9 +1,8 @@ -import { asMilli } from '@paradoxical-io/common'; +import { asMilli, retry } from '@paradoxical-io/common'; import { safeExpect } from '@paradoxical-io/common-test'; import { Milliseconds } from '@paradoxical-io/types'; import { exponentialPoll, linearPoll } from './poll'; -import { retry } from './retryDecorator'; class Retriable { fails = 0; diff --git a/packages/common-server/src/retry/retryDecorator.ts b/packages/common-server/src/retry/retryDecorator.ts index c47e9c6..22a9b5f 100644 --- a/packages/common-server/src/retry/retryDecorator.ts +++ b/packages/common-server/src/retry/retryDecorator.ts @@ -1,60 +1,8 @@ import asyncRetry = require('async-retry'); -import { asMilli } from '@paradoxical-io/common'; +import { asMilli, retry } from '@paradoxical-io/common'; import { notNullOrUndefined } from '@paradoxical-io/types'; import { isAxiosError } from '../http'; -import { log } from '../logger'; - -/** - * Managed retry annotation for a method for async methods. Does not work for sync methods - * and will ignore the retry - * @param opts Async retry options. To stop retrying early on error use the bailOn callback - * - */ -export function retry(opts?: asyncRetry.Options & { bailOn?: (e: Error) => boolean }) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (target: object, method: string, descriptor?: TypedPropertyDescriptor) => { - const className = target.constructor?.name; - const originalMethod = descriptor!.value; - - const defaults: typeof opts = { - ...opts, - - // wrap the retry with a log statement - onRetry: (e, attempt) => { - opts?.onRetry?.(e, attempt); - log.warn(`Retry attempt ${attempt} on method ${className}.${method}`, e); - }, - }; - - if (originalMethod.constructor.name === 'AsyncFunction') { - // apply async retries - // eslint-disable-next-line func-names - descriptor!.value = async function () { - // apply the method - return asyncRetry(async bail => { - try { - // eslint-disable-next-line prefer-rest-params - const result = await originalMethod.apply(this, arguments); - - return result; - } catch (e) { - if (e instanceof Error && opts?.bailOn?.(e)) { - bail(e); - } else { - throw e; - } - } - }, defaults); - }; - } else { - throw new Error(`Cannot apply retry to a not async method ${className}.${method}`); - } - - // return edited descriptor as opposed to overwriting the descriptor - return descriptor; - }; -} /** * Retries axios but ignores certain error codes diff --git a/packages/common-sql/package.json b/packages/common-sql/package.json index 6fc129b..395d764 100644 --- a/packages/common-sql/package.json +++ b/packages/common-sql/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/common-sql", - "version": "2.6.0", + "version": "2.7.0", "files": [ "dist/**/*.js", "dist/**/*.d.ts", diff --git a/packages/common-test/README.md b/packages/common-test/README.md index bb37ddd..9f0e930 100644 --- a/packages/common-test/README.md +++ b/packages/common-test/README.md @@ -62,22 +62,17 @@ safeExpect(users).toContainEqual({ id: '123', name: 'John' }); safeExpect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); // Partial object matching with type safety -safeExpect(response).toMatchObject( - safeObjectContaining({ status: 200 }) -); +safeExpect(response).toMatchObject(safeObjectContaining({ status: 200 })); // Deep partial matching safeExpect(complexObject).toEqual( deepSafeObjectContaining({ - nested: { field: 'value' } + nested: { field: 'value' }, }) ); // Match object excluding specific fields -safeExpect(user).toMatchObjectExcluding( - { name: 'John', age: 30 }, - ['createdAt', 'updatedAt'] -); +safeExpect(user).toMatchObjectExcluding({ name: 'John', age: 30 }, ['createdAt', 'updatedAt']); // Conditional negation const shouldMatch = false; @@ -99,13 +94,9 @@ expect(5).toBeWithinRange(1, 10); expect(someValue).logToCli(); // Debug utility for tests // Error matching for structured errors -expect(error).matchesErrorWithCode( - { code: 'USER_NOT_FOUND', data: { userId: '123' } } -); +expect(error).matchesErrorWithCode({ code: 'USER_NOT_FOUND', data: { userId: '123' } }); -expect(error).matchesUserFacingMessage( - { code: 'ERROR', data: { userFacingMessage: 'Please try again' } } -); +expect(error).matchesUserFacingMessage({ code: 'ERROR', data: { userFacingMessage: 'Please try again' } }); ``` ### Environment Mocking @@ -137,18 +128,24 @@ Handle async code with built-in waits: import { autoAdvanceTimers, retry } from '@paradoxical-io/common-test'; // Auto-advance timers for code with internal waits -it('handles retry logic', autoAdvanceTimers(async () => { - jest.useFakeTimers(); - await functionWithInternalRetries(); - // Timers automatically advanced during execution -})); +it( + 'handles retry logic', + autoAdvanceTimers(async () => { + jest.useFakeTimers(); + await functionWithInternalRetries(); + // Timers automatically advanced during execution + }) +); // Retry flaky tests -it('eventually succeeds', retry(3)(async () => { - // Test code that might be flaky - // Will retry up to 3 times before failing - await someFlakeyOperation(); -})); +it( + 'eventually succeeds', + retry(3)(async () => { + // Test code that might be flaky + // Will retry up to 3 times before failing + await someFlakeyOperation(); + }) +); ``` ### Test File Loading @@ -190,10 +187,7 @@ Add test start logging to your Jest configuration: ```javascript // jest.config.js module.exports = { - reporters: [ - 'default', - require.resolve('@paradoxical-io/common-test/dist/jest/reporter.js'), - ], + reporters: ['default', require.resolve('@paradoxical-io/common-test/dist/jest/reporter.js')], }; ``` @@ -228,7 +222,7 @@ module.exports = { ### Test Utilities -- `loadTestFile(file, root?)` - Load JSON file from __test__ directory +- `loadTestFile(file, root?)` - Load JSON file from **test** directory - `autoAdvanceTimers(callback)` - Auto-advance Jest fake timers during async execution - `retry(times)` - Retry test function multiple times before failing @@ -238,4 +232,4 @@ This package is written in TypeScript and provides full type definitions. All ut ## License -MIT \ No newline at end of file +MIT diff --git a/packages/common-test/package.json b/packages/common-test/package.json index 4cf6453..0108ccc 100644 --- a/packages/common-test/package.json +++ b/packages/common-test/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/common-test", - "version": "2.6.0", + "version": "2.7.0", "description": "Common test-code for jest", "files": [ "dist/**/*.js", diff --git a/packages/common/README.md b/packages/common/README.md index 58b02f5..1cdac8f 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -90,7 +90,7 @@ const shuffled = shuffleArray([1, 2, 3, 4, 5]); // Flatten 2D array by columns const flattened = columnFlatten([ [0, 1], - [2, 3] + [2, 3], ]); // [0, 2, 1, 3] // Group array into chunks @@ -105,14 +105,12 @@ const random = Arrays.random([1, 2, 3, 4]); ```typescript import { PubSub } from '@paradoxical-io/common'; -type Event = - | { type: 'user.created'; userId: string } - | { type: 'user.deleted'; userId: string }; +type Event = { type: 'user.created'; userId: string } | { type: 'user.deleted'; userId: string }; const pubsub = new PubSub(); // Subscribe to specific event types -pubsub.subscribe('user.created', (event) => { +pubsub.subscribe('user.created', event => { console.log(`User created: ${event.userId}`); }); @@ -123,13 +121,7 @@ await pubsub.publish({ type: 'user.created', userId: '123' }); ### String and Text Utilities ```typescript -import { - leftPad, - truncate, - camelCase, - titleCase, - formatAmount -} from '@paradoxical-io/common'; +import { leftPad, truncate, camelCase, titleCase, formatAmount } from '@paradoxical-io/common'; // Pad strings const padded = leftPad(42, 5, '0'); // "00042" @@ -145,7 +137,7 @@ const title = titleCase('hello-world'); // "Hello World" const formatted = formatAmount({ amount: 1234.56 as Amount, includeCommas: true, - includeDollarSign: true + includeDollarSign: true, }); // "$1,234.56" ``` @@ -196,11 +188,7 @@ interface User { }; } -const path = xpath() - .field('profile') - .field('addresses') - .index(0) - .field('city'); +const path = xpath().field('profile').field('addresses').index(0).field('city'); console.log(path.path); // "$.profile.addresses[0].city" ``` @@ -230,7 +218,7 @@ import { Retrier } from '@paradoxical-io/common'; // Try once fast, then retry in background if fails await Retrier.tryFast( () => unreliableApiCall(), - (error) => console.error('Failed after retries:', error), + error => console.error('Failed after retries:', error), () => console.log('Success!'), { retries: 3, minTimeout: 1000 } ); diff --git a/packages/common/package.json b/packages/common/package.json index 73bca04..4c94aa9 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/common", - "version": "2.6.0", + "version": "2.7.0", "description": "Common code for all paradox projects", "files": [ "dist/**/*.js", diff --git a/packages/common/src/decorators/index.ts b/packages/common/src/decorators/index.ts index 495182b..778bf51 100644 --- a/packages/common/src/decorators/index.ts +++ b/packages/common/src/decorators/index.ts @@ -1,2 +1,3 @@ export * from './logDecorator'; +export * from './retryDecorator'; export * from './timingDecorator'; diff --git a/packages/common-server/src/retry/retryDecorator.test.ts b/packages/common/src/decorators/retryDecorator.test.ts similarity index 93% rename from packages/common-server/src/retry/retryDecorator.test.ts rename to packages/common/src/decorators/retryDecorator.test.ts index c51702e..27a8694 100644 --- a/packages/common-server/src/retry/retryDecorator.test.ts +++ b/packages/common/src/decorators/retryDecorator.test.ts @@ -2,8 +2,6 @@ import { safeExpect } from '@paradoxical-io/common-test'; import { retry } from './retryDecorator'; -// store a global so we can track failure counts - class Retriable { fails = 0; diff --git a/packages/common/src/decorators/retryDecorator.ts b/packages/common/src/decorators/retryDecorator.ts new file mode 100644 index 0000000..ca7e1fd --- /dev/null +++ b/packages/common/src/decorators/retryDecorator.ts @@ -0,0 +1,51 @@ +import asyncRetry = require('async-retry'); + +/** + * Managed retry annotation for a method for async methods. Does not work for sync methods + * and will ignore the retry + * @param opts Async retry options. To stop retrying early on error use the bailOn callback + * + */ +export function retry(opts?: asyncRetry.Options & { bailOn?: (e: Error) => boolean }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target: object, method: string, descriptor?: TypedPropertyDescriptor) => { + const className = target.constructor?.name; + const originalMethod = descriptor!.value; + + const defaults: typeof opts = { + ...opts, + + // proxy to the on retry + onRetry: (e, attempt) => { + opts?.onRetry?.(e, attempt); + }, + }; + + if (originalMethod.constructor.name === 'AsyncFunction') { + // apply async retries + // eslint-disable-next-line func-names + descriptor!.value = async function () { + // apply the method + return asyncRetry(async bail => { + try { + // eslint-disable-next-line prefer-rest-params + const result = await originalMethod.apply(this, arguments); + + return result; + } catch (e) { + if (e instanceof Error && opts?.bailOn?.(e)) { + bail(e); + } else { + throw e; + } + } + }, defaults); + }; + } else { + throw new Error(`Cannot apply retry to a not async method ${className}.${method}`); + } + + // return edited descriptor as opposed to overwriting the descriptor + return descriptor; + }; +} diff --git a/packages/common/src/decorators/timingDecorator.ts b/packages/common/src/decorators/timingDecorator.ts index 710231c..f33f5dc 100644 --- a/packages/common/src/decorators/timingDecorator.ts +++ b/packages/common/src/decorators/timingDecorator.ts @@ -6,22 +6,47 @@ export interface Metrics { const customMetrics = Symbol('paradoxical:metrics'); +interface MetricsProviderMetadata { + prop: string | symbol; + method: string | symbol | number; +} + /** * Add an annotation on a logger instance in a class to use as the logging instance for all annotation logMethods * allows you to capture context in a class and have that context be propagated through annotation logging. */ export function metricsProvider(target: Object, prop: string | symbol) { - Reflect.defineMetadata(customMetrics, prop, target); + return customMetricsProvider('timing')(target, prop); +} + +/** + * A metrics provider that uses a specific method on the metrics instance + * @param method + */ +export function customMetricsProvider(method: keyof T) { + return (target: Object, prop: string | symbol) => { + Reflect.defineMetadata( + customMetrics, + { + method, + prop, + } satisfies MetricsProviderMetadata, + target + ); + }; } -function resolveMetrics(target: object): Metrics | undefined { - const property = Reflect.getMetadata(customMetrics, target) ?? 'metrics'; +function resolveMetrics(target: object): Metrics['timing'] | undefined { + const meta: MetricsProviderMetadata = Reflect.getMetadata(customMetrics, target) ?? { + prop: 'metrics', + method: 'timing', + }; // @ts-ignore - const metrics = target?.[property] as Metrics; + const metrics = target?.[meta.prop]; - if (metrics && typeof metrics === 'object' && typeof metrics.timing === 'function') { - return metrics; + if (metrics && typeof metrics === 'object' && typeof metrics[meta.method] === 'function') { + return metrics[meta.method].bind(metrics); } return undefined; @@ -61,7 +86,9 @@ export function timed({ const warningsByClass = new Set(); // editing the descriptor/value parameter descriptor!.value = function () { - const resolvedMetrics: Metrics | undefined = metrics ?? resolveMetrics(this); + const resolvedMetrics: Metrics['timing'] | undefined = metrics + ? metrics['timing'].bind(metrics) + : resolveMetrics(this); const logged = warningsByClass.has(this.constructor.name); // only log once @@ -75,7 +102,7 @@ export function timed({ const timingResult = () => { try { - resolvedMetrics?.timing(metricName, preciseTimeMilli() - start, { + resolvedMetrics?.(metricName, preciseTimeMilli() - start, { ...tags, name: `${target.constructor?.name}.${method}`, }); diff --git a/packages/common/src/promise/resolver.test.ts b/packages/common/src/promise/resolver.test.ts index 450a33d..bdbd460 100644 --- a/packages/common/src/promise/resolver.test.ts +++ b/packages/common/src/promise/resolver.test.ts @@ -1,11 +1,14 @@ import { safeExpect } from '@paradoxical-io/common-test'; +import { Brand } from '@paradoxical-io/types'; import { autoResolve } from './resolver'; +export type User = Brand; + test('auto resolves promises', async () => { const p = { foo: 1, - bar: () => new Promise(r => r('')), + bar: (): Promise | undefined => new Promise(r => r('' as User)), biz: { baz: () => new Promise(r => r(1)), }, diff --git a/packages/types/package.json b/packages/types/package.json index ea7b4cf..62e16be 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@paradoxical-io/types", - "version": "2.6.0", + "version": "2.7.0", "description": "Paradox shared types", "files": [ "dist/**/*.js",