From ad691498a159447760fe16ae9c8c68b319e7b4e7 Mon Sep 17 00:00:00 2001 From: Anton Kropp Date: Fri, 23 Jan 2026 16:45:12 -0800 Subject: [PATCH 1/6] Moving retry to common --- packages/common-aws/src/dynamo/docker.ts | 2 +- .../parameter-store/api/parameterStoreApi.ts | 5 +- packages/common-aws/src/sqs/README.md | 66 +++++++++++-------- packages/common-aws/src/sqs/consumer.test.ts | 2 +- packages/common-aws/src/sqs/consumer.ts | 10 +-- .../common-aws/src/sqs/publisher.itest.ts | 4 +- packages/common-server/README.md | 28 ++++---- .../common-server/src/metrics/metrics.test.ts | 10 +-- packages/common-server/src/retry/README.md | 22 +++++-- packages/common-server/src/retry/poll.test.ts | 3 +- packages/common-server/src/retry/poll.ts | 2 +- .../common-server/src/retry/retryDecorator.ts | 54 +-------------- packages/common-test/README.md | 54 +++++++-------- packages/common-test/src/jest/utils.ts | 2 +- packages/common/README.md | 28 +++----- packages/common/src/decorators/index.ts | 1 + .../src/decorators}/retryDecorator.test.ts | 8 ++- .../common/src/decorators/retryDecorator.ts | 51 ++++++++++++++ .../common/src/decorators/timingDecorator.ts | 43 +++++++++--- packages/common/src/promise/resolver.test.ts | 5 +- packages/common/src/utils/retry.ts | 2 +- 21 files changed, 218 insertions(+), 184 deletions(-) rename packages/{common-server/src/retry => common/src/decorators}/retryDecorator.test.ts (80%) create mode 100644 packages/common/src/decorators/retryDecorator.ts diff --git a/packages/common-aws/src/dynamo/docker.ts b/packages/common-aws/src/dynamo/docker.ts index a1f2db6..ea2cd66 100644 --- a/packages/common-aws/src/dynamo/docker.ts +++ b/packages/common-aws/src/dynamo/docker.ts @@ -54,7 +54,7 @@ export async function newDynamoDocker(): Promise { const dynamo = new DynamoDBClient({ endpoint: base, region: 'us-west-2', - // added retry logic as there appears to be intermittent timeout errors with dynamodb-local + // added retryDecorator logic as there appears to be intermittent timeout errors with dynamodb-local retryStrategy: new ConfiguredRetryStrategy(4, (attempt: number) => 100 + attempt * 1000), }); 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..f054430 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, }); } @@ -120,9 +118,9 @@ class OrderProcessor extends SQSConsumer { if (error.isRetryable) { // Retry after 60 seconds return { - type: 'retry-later', + type: 'retryDecorator-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); @@ -168,7 +166,7 @@ Changes message visibility to retry later. Counts against DLQ delivery attempts: async process(data: OrderEvent): Promise { if (shouldRetry) { return { - type: 'retry-later', + type: 'retryDecorator-later', reason: 'Temporary service unavailable', retryInSeconds: 30 as Seconds }; @@ -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 } ``` @@ -376,7 +382,7 @@ interface SQSEvent { type MessageProcessorResult = void | RetryMessageLater | RepublishMessage; interface RetryMessageLater { - type: 'retry-later'; + type: 'retryDecorator-later'; reason: string; retryInSeconds: Seconds; } @@ -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-aws/src/sqs/consumer.test.ts b/packages/common-aws/src/sqs/consumer.test.ts index 3df5136..803f56d 100644 --- a/packages/common-aws/src/sqs/consumer.test.ts +++ b/packages/common-aws/src/sqs/consumer.test.ts @@ -4,7 +4,7 @@ import { Seconds } from '@paradoxical-io/types'; import { determineRetryDelay, RepublishMessage } from './consumer'; -test('no exponential uses retry value', () => { +test('no exponential uses retryDecorator value', () => { safeExpect(determineRetryDelay({ retryInSeconds: 5 as Seconds } as RepublishMessage, 5)).toEqual(5 as Seconds); }); diff --git a/packages/common-aws/src/sqs/consumer.ts b/packages/common-aws/src/sqs/consumer.ts index cbf7216..6a2031e 100644 --- a/packages/common-aws/src/sqs/consumer.ts +++ b/packages/common-aws/src/sqs/consumer.ts @@ -64,17 +64,17 @@ interface StopParams { /** * If a consumer wants to defer a message return this from the consumer * - * Warning: retries do count against instances of delivery, so if you retry X times and the redrive policy + * Warning: retries do count against instances of delivery, so if you retryDecorator X times and the redrive policy * is to DLQ after X deliveries, the message will DLQ */ export interface RetryMessageLater { - type: 'retry-later'; + type: 'retryDecorator-later'; reason: string; retryInSeconds: Seconds; } /** - * A different version of retry which re-publishes the original message (potentially with a delay) + * A different version of retryDecorator which re-publishes the original message (potentially with a delay) * and ACKS the original message. This means that retrying of messages don't count towards DLQ counts */ export interface RepublishMessage { @@ -339,7 +339,7 @@ export abstract class SQSConsumer implements MessageProcessor { Metrics.instance.increment('sqs.batch_failure', { queue: this.queueUrl }); - // retry in 1 second to prevent spamming on unknown failures + // retryDecorator in 1 second to prevent spamming on unknown failures await sleep(1000 as Milliseconds); } } @@ -443,7 +443,7 @@ export abstract class SQSConsumer implements MessageProcessor { if (result && message.ReceiptHandle) { switch (result.type) { - case 'retry-later': { + case 'retryDecorator-later': { log.info( `Requested to retry event later. Making message visible in ${result.retryInSeconds} seconds. Reason: ${result.reason}` ); diff --git a/packages/common-aws/src/sqs/publisher.itest.ts b/packages/common-aws/src/sqs/publisher.itest.ts index 34b39dc..56c159e 100644 --- a/packages/common-aws/src/sqs/publisher.itest.ts +++ b/packages/common-aws/src/sqs/publisher.itest.ts @@ -101,7 +101,7 @@ test('retrys message to queue', async () => { } if (foundData.get(data)!.length < 3) { - return { retryInSeconds: 1 as Seconds, type: 'retry-later', reason: 'not done yet' }; + return { retryInSeconds: 1 as Seconds, type: 'retryDecorator-later', reason: 'not done yet' }; } return undefined; @@ -231,7 +231,7 @@ test('retrys message via publish later to queue up to max time', async () => { expect(Array.from(foundData.keys()).sort()).toEqual(content.sort()); // we retried each message 1 time because they auto expired from the first republish time - // total count is 2 (initial + retry) + // total count is 2 (initial + retryDecorator) expect(Array.from(foundData.values()).flatMap(i => i).length).toEqual(content.length * 2); } finally { await docker.container.close(); diff --git a/packages/common-server/README.md b/packages/common-server/README.md index fa74ff3..dac9989 100644 --- a/packages/common-server/README.md +++ b/packages/common-server/README.md @@ -101,7 +101,7 @@ class ApiClient { return await fetch('https://api.example.com/data'); } - @axiosRetry([400, 404]) // Skip retry for specific status codes + @axiosRetry([400, 404]) // Skip retryDecorator for specific status codes async postData(payload: object) { return await axios.post('/api/endpoint', payload); } @@ -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/src/metrics/metrics.test.ts b/packages/common-server/src/metrics/metrics.test.ts index 5f23455..7ec73e9 100644 --- a/packages/common-server/src/metrics/metrics.test.ts +++ b/packages/common-server/src/metrics/metrics.test.ts @@ -1,10 +1,5 @@ /* 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 -process.env.PARADOX_SKIP_LOG_DECORATORS = 'false'; - import { metricsProvider, timed as timedRaw } from '@paradoxical-io/common'; import { extendJest, safeExpect } from '@paradoxical-io/common-test'; @@ -12,6 +7,11 @@ import { logMethod } from '../logger'; import { Metrics } from './metrics'; import { timed } from './timingDecorator'; +process.env.PARADOX_LOG_LEVEL = 'info'; + +// ensure log decorators always run +process.env.PARADOX_SKIP_LOG_DECORATORS = 'false'; + extendJest(); class TestWithInvalidMetricsType { diff --git a/packages/common-server/src/retry/README.md b/packages/common-server/src/retry/README.md index 6c4cffb..538b4a7 100644 --- a/packages/common-server/src/retry/README.md +++ b/packages/common-server/src/retry/README.md @@ -31,7 +31,7 @@ import { retry } from '@paradoxical-io/common-server'; class DatabaseService { @retry({ retries: 3, minTimeout: 100, maxTimeout: 5000 }) async fetchUser(userId: string): Promise { - // This method will automatically retry up to 3 times + // This method will automatically retryDecorator up to 3 times // with exponential backoff between 100ms and 5000ms const response = await this.db.query('SELECT * FROM users WHERE id = ?', [userId]); return response.rows[0]; @@ -48,7 +48,7 @@ import { axiosRetry } from '@paradoxical-io/common-server'; import axios from 'axios'; class ApiClient { - // Retries on 5xx errors, but skips retry on 400 Bad Request + // Retries on 5xx errors, but skips retryDecorator on 400 Bad Request @axiosRetry([400]) async fetchData(endpoint: string): Promise { const response = await axios.get(`https://api.example.com/${endpoint}`); @@ -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); @@ -104,8 +104,8 @@ async function waitForJobCompletion(jobId: string) { retries: 10, minTimeout: 100, maxTimeout: 5000, - factor: 2, // Double the wait time on each retry - expiresAfter: asMilli(5, 'minutes') + factor: 2, // Double the wait time on each retryDecorator + 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/poll.ts b/packages/common-server/src/retry/poll.ts index 9f3757b..8c945f1 100644 --- a/packages/common-server/src/retry/poll.ts +++ b/packages/common-server/src/retry/poll.ts @@ -14,7 +14,7 @@ class NoDataError extends Error {} /** * Supports exponential polling backoff * @param block - * @param opts The retry options for exponential backoff along with when the polling expires + * @param opts The retryDecorator options for exponential backoff along with when the polling expires * @param time */ export async function exponentialPoll( 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-test/README.md b/packages/common-test/README.md index bb37ddd..deccc17 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 retryDecorator 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 retryDecorator 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/src/jest/utils.ts b/packages/common-test/src/jest/utils.ts index fdb3d90..afdd33a 100644 --- a/packages/common-test/src/jest/utils.ts +++ b/packages/common-test/src/jest/utils.ts @@ -2,7 +2,7 @@ * Jest has decent support for fake timers if the test actually wants to verify that the wait happens, * and can manually control the individual time moving aspects. * - * Where it becomes more limited is when testing functions that have built in waits (retry loops, waiting for something to finish, etc). Wrapping the test + * Where it becomes more limited is when testing functions that have built in waits (retryDecorator loops, waiting for something to finish, etc). Wrapping the test * logic with this function will cause all timers to automatically and immediately resolve. * * @note See App P2P.test.ts for example usages (the p2p control functions internally use waitForTransactionToSettle which has built in waits and retries) diff --git a/packages/common/README.md b/packages/common/README.md index 58b02f5..62b8bcb 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" ``` @@ -227,10 +215,10 @@ const container = withCycles(manager => { ```typescript import { Retrier } from '@paradoxical-io/common'; -// Try once fast, then retry in background if fails +// Try once fast, then retryDecorator 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/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 80% rename from packages/common-server/src/retry/retryDecorator.test.ts rename to packages/common/src/decorators/retryDecorator.test.ts index c51702e..ae30432 100644 --- a/packages/common-server/src/retry/retryDecorator.test.ts +++ b/packages/common/src/decorators/retryDecorator.test.ts @@ -2,11 +2,11 @@ import { safeExpect } from '@paradoxical-io/common-test'; import { retry } from './retryDecorator'; -// store a global so we can track failure counts - class Retriable { fails = 0; + retried = false; + @retry({ maxTimeout: 50, minTimeout: 1 }) async succeedsPromise(failUntil: number): Promise { if (this.fails < failUntil) { @@ -19,11 +19,12 @@ class Retriable { } } -test('doesnt retry if method succeeds', async () => { +test('doesnt retryDecorator if method succeeds', async () => { const retriable = new Retriable(); safeExpect(await retriable.succeedsPromise(0)).toEqual(0); safeExpect(retriable.fails).toEqual(0); + safeExpect(retriable.retried).toEqual(false); }); test('retries if method fails', async () => { @@ -31,4 +32,5 @@ test('retries if method fails', async () => { safeExpect(await retriable.succeedsPromise(3)).toEqual(3); safeExpect(retriable.fails).toEqual(3); + safeExpect(retriable.retried).toEqual(true); }); diff --git a/packages/common/src/decorators/retryDecorator.ts b/packages/common/src/decorators/retryDecorator.ts new file mode 100644 index 0000000..03a901b --- /dev/null +++ b/packages/common/src/decorators/retryDecorator.ts @@ -0,0 +1,51 @@ +import asyncRetry = require('async-retry'); + +/** + * Managed retryDecorator annotation for a method for async methods. Does not work for sync methods + * and will ignore the retryDecorator + * @param opts Async retryDecorator 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/common/src/utils/retry.ts b/packages/common/src/utils/retry.ts index 30824e0..706fdb8 100644 --- a/packages/common/src/utils/retry.ts +++ b/packages/common/src/utils/retry.ts @@ -2,7 +2,7 @@ import asyncRetry = require('async-retry'); export class Retrier { /** - * Try a promise ONCE and if it fails kick it into a non awaited retry mode + * Try a promise ONCE and if it fails kick it into a non awaited retryDecorator mode * * This allows you to try quickly and then dangle a promise separately outside of the await context to complete * @param p From a9a59df5a0fe6e21c34f2493c0ed5c64ca6682f4 Mon Sep 17 00:00:00 2001 From: Anton Kropp Date: Fri, 23 Jan 2026 16:46:27 -0800 Subject: [PATCH 2/6] Moving retry to common --- packages/common-aws/src/dynamo/docker.ts | 2 +- packages/common-aws/src/sqs/README.md | 6 +++--- packages/common-aws/src/sqs/consumer.test.ts | 2 +- packages/common-aws/src/sqs/consumer.ts | 10 +++++----- packages/common-aws/src/sqs/publisher.itest.ts | 4 ++-- packages/common-server/README.md | 2 +- packages/common-server/src/index.ts | 1 - packages/common-server/src/retry/README.md | 6 +++--- packages/common-server/src/retry/poll.ts | 2 +- packages/common-test/README.md | 4 ++-- packages/common-test/src/jest/utils.ts | 2 +- packages/common/README.md | 2 +- packages/common/src/decorators/retryDecorator.test.ts | 2 +- packages/common/src/decorators/retryDecorator.ts | 6 +++--- packages/common/src/utils/retry.ts | 2 +- 15 files changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/common-aws/src/dynamo/docker.ts b/packages/common-aws/src/dynamo/docker.ts index ea2cd66..a1f2db6 100644 --- a/packages/common-aws/src/dynamo/docker.ts +++ b/packages/common-aws/src/dynamo/docker.ts @@ -54,7 +54,7 @@ export async function newDynamoDocker(): Promise { const dynamo = new DynamoDBClient({ endpoint: base, region: 'us-west-2', - // added retryDecorator logic as there appears to be intermittent timeout errors with dynamodb-local + // added retry logic as there appears to be intermittent timeout errors with dynamodb-local retryStrategy: new ConfiguredRetryStrategy(4, (attempt: number) => 100 + attempt * 1000), }); diff --git a/packages/common-aws/src/sqs/README.md b/packages/common-aws/src/sqs/README.md index f054430..8150827 100644 --- a/packages/common-aws/src/sqs/README.md +++ b/packages/common-aws/src/sqs/README.md @@ -118,7 +118,7 @@ class OrderProcessor extends SQSConsumer { if (error.isRetryable) { // Retry after 60 seconds return { - type: 'retryDecorator-later', + type: 'retry-later', reason: 'Database temporarily unavailable', retryInSeconds: 60 as Seconds, }; @@ -166,7 +166,7 @@ Changes message visibility to retry later. Counts against DLQ delivery attempts: async process(data: OrderEvent): Promise { if (shouldRetry) { return { - type: 'retryDecorator-later', + type: 'retry-later', reason: 'Temporary service unavailable', retryInSeconds: 30 as Seconds }; @@ -382,7 +382,7 @@ interface SQSEvent { type MessageProcessorResult = void | RetryMessageLater | RepublishMessage; interface RetryMessageLater { - type: 'retryDecorator-later'; + type: 'retry-later'; reason: string; retryInSeconds: Seconds; } diff --git a/packages/common-aws/src/sqs/consumer.test.ts b/packages/common-aws/src/sqs/consumer.test.ts index 803f56d..3df5136 100644 --- a/packages/common-aws/src/sqs/consumer.test.ts +++ b/packages/common-aws/src/sqs/consumer.test.ts @@ -4,7 +4,7 @@ import { Seconds } from '@paradoxical-io/types'; import { determineRetryDelay, RepublishMessage } from './consumer'; -test('no exponential uses retryDecorator value', () => { +test('no exponential uses retry value', () => { safeExpect(determineRetryDelay({ retryInSeconds: 5 as Seconds } as RepublishMessage, 5)).toEqual(5 as Seconds); }); diff --git a/packages/common-aws/src/sqs/consumer.ts b/packages/common-aws/src/sqs/consumer.ts index 6a2031e..cbf7216 100644 --- a/packages/common-aws/src/sqs/consumer.ts +++ b/packages/common-aws/src/sqs/consumer.ts @@ -64,17 +64,17 @@ interface StopParams { /** * If a consumer wants to defer a message return this from the consumer * - * Warning: retries do count against instances of delivery, so if you retryDecorator X times and the redrive policy + * Warning: retries do count against instances of delivery, so if you retry X times and the redrive policy * is to DLQ after X deliveries, the message will DLQ */ export interface RetryMessageLater { - type: 'retryDecorator-later'; + type: 'retry-later'; reason: string; retryInSeconds: Seconds; } /** - * A different version of retryDecorator which re-publishes the original message (potentially with a delay) + * A different version of retry which re-publishes the original message (potentially with a delay) * and ACKS the original message. This means that retrying of messages don't count towards DLQ counts */ export interface RepublishMessage { @@ -339,7 +339,7 @@ export abstract class SQSConsumer implements MessageProcessor { Metrics.instance.increment('sqs.batch_failure', { queue: this.queueUrl }); - // retryDecorator in 1 second to prevent spamming on unknown failures + // retry in 1 second to prevent spamming on unknown failures await sleep(1000 as Milliseconds); } } @@ -443,7 +443,7 @@ export abstract class SQSConsumer implements MessageProcessor { if (result && message.ReceiptHandle) { switch (result.type) { - case 'retryDecorator-later': { + case 'retry-later': { log.info( `Requested to retry event later. Making message visible in ${result.retryInSeconds} seconds. Reason: ${result.reason}` ); diff --git a/packages/common-aws/src/sqs/publisher.itest.ts b/packages/common-aws/src/sqs/publisher.itest.ts index 56c159e..34b39dc 100644 --- a/packages/common-aws/src/sqs/publisher.itest.ts +++ b/packages/common-aws/src/sqs/publisher.itest.ts @@ -101,7 +101,7 @@ test('retrys message to queue', async () => { } if (foundData.get(data)!.length < 3) { - return { retryInSeconds: 1 as Seconds, type: 'retryDecorator-later', reason: 'not done yet' }; + return { retryInSeconds: 1 as Seconds, type: 'retry-later', reason: 'not done yet' }; } return undefined; @@ -231,7 +231,7 @@ test('retrys message via publish later to queue up to max time', async () => { expect(Array.from(foundData.keys()).sort()).toEqual(content.sort()); // we retried each message 1 time because they auto expired from the first republish time - // total count is 2 (initial + retryDecorator) + // total count is 2 (initial + retry) expect(Array.from(foundData.values()).flatMap(i => i).length).toEqual(content.length * 2); } finally { await docker.container.close(); diff --git a/packages/common-server/README.md b/packages/common-server/README.md index dac9989..2b1ac11 100644 --- a/packages/common-server/README.md +++ b/packages/common-server/README.md @@ -101,7 +101,7 @@ class ApiClient { return await fetch('https://api.example.com/data'); } - @axiosRetry([400, 404]) // Skip retryDecorator for specific status codes + @axiosRetry([400, 404]) // Skip retry for specific status codes async postData(payload: object) { return await axios.post('/api/endpoint', payload); } 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/retry/README.md b/packages/common-server/src/retry/README.md index 538b4a7..fbfb02a 100644 --- a/packages/common-server/src/retry/README.md +++ b/packages/common-server/src/retry/README.md @@ -31,7 +31,7 @@ import { retry } from '@paradoxical-io/common-server'; class DatabaseService { @retry({ retries: 3, minTimeout: 100, maxTimeout: 5000 }) async fetchUser(userId: string): Promise { - // This method will automatically retryDecorator up to 3 times + // This method will automatically retry up to 3 times // with exponential backoff between 100ms and 5000ms const response = await this.db.query('SELECT * FROM users WHERE id = ?', [userId]); return response.rows[0]; @@ -48,7 +48,7 @@ import { axiosRetry } from '@paradoxical-io/common-server'; import axios from 'axios'; class ApiClient { - // Retries on 5xx errors, but skips retryDecorator on 400 Bad Request + // Retries on 5xx errors, but skips retry on 400 Bad Request @axiosRetry([400]) async fetchData(endpoint: string): Promise { const response = await axios.get(`https://api.example.com/${endpoint}`); @@ -104,7 +104,7 @@ async function waitForJobCompletion(jobId: string) { retries: 10, minTimeout: 100, maxTimeout: 5000, - factor: 2, // Double the wait time on each retryDecorator + factor: 2, // Double the wait time on each retry expiresAfter: asMilli(5, 'minutes'), } ); diff --git a/packages/common-server/src/retry/poll.ts b/packages/common-server/src/retry/poll.ts index 8c945f1..9f3757b 100644 --- a/packages/common-server/src/retry/poll.ts +++ b/packages/common-server/src/retry/poll.ts @@ -14,7 +14,7 @@ class NoDataError extends Error {} /** * Supports exponential polling backoff * @param block - * @param opts The retryDecorator options for exponential backoff along with when the polling expires + * @param opts The retry options for exponential backoff along with when the polling expires * @param time */ export async function exponentialPoll( diff --git a/packages/common-test/README.md b/packages/common-test/README.md index deccc17..9f0e930 100644 --- a/packages/common-test/README.md +++ b/packages/common-test/README.md @@ -129,7 +129,7 @@ import { autoAdvanceTimers, retry } from '@paradoxical-io/common-test'; // Auto-advance timers for code with internal waits it( - 'handles retryDecorator logic', + 'handles retry logic', autoAdvanceTimers(async () => { jest.useFakeTimers(); await functionWithInternalRetries(); @@ -142,7 +142,7 @@ it( 'eventually succeeds', retry(3)(async () => { // Test code that might be flaky - // Will retryDecorator up to 3 times before failing + // Will retry up to 3 times before failing await someFlakeyOperation(); }) ); diff --git a/packages/common-test/src/jest/utils.ts b/packages/common-test/src/jest/utils.ts index afdd33a..fdb3d90 100644 --- a/packages/common-test/src/jest/utils.ts +++ b/packages/common-test/src/jest/utils.ts @@ -2,7 +2,7 @@ * Jest has decent support for fake timers if the test actually wants to verify that the wait happens, * and can manually control the individual time moving aspects. * - * Where it becomes more limited is when testing functions that have built in waits (retryDecorator loops, waiting for something to finish, etc). Wrapping the test + * Where it becomes more limited is when testing functions that have built in waits (retry loops, waiting for something to finish, etc). Wrapping the test * logic with this function will cause all timers to automatically and immediately resolve. * * @note See App P2P.test.ts for example usages (the p2p control functions internally use waitForTransactionToSettle which has built in waits and retries) diff --git a/packages/common/README.md b/packages/common/README.md index 62b8bcb..1cdac8f 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -215,7 +215,7 @@ const container = withCycles(manager => { ```typescript import { Retrier } from '@paradoxical-io/common'; -// Try once fast, then retryDecorator in background if fails +// Try once fast, then retry in background if fails await Retrier.tryFast( () => unreliableApiCall(), error => console.error('Failed after retries:', error), diff --git a/packages/common/src/decorators/retryDecorator.test.ts b/packages/common/src/decorators/retryDecorator.test.ts index ae30432..7ab1e94 100644 --- a/packages/common/src/decorators/retryDecorator.test.ts +++ b/packages/common/src/decorators/retryDecorator.test.ts @@ -19,7 +19,7 @@ class Retriable { } } -test('doesnt retryDecorator if method succeeds', async () => { +test('doesnt retry if method succeeds', async () => { const retriable = new Retriable(); safeExpect(await retriable.succeedsPromise(0)).toEqual(0); diff --git a/packages/common/src/decorators/retryDecorator.ts b/packages/common/src/decorators/retryDecorator.ts index 03a901b..ca7e1fd 100644 --- a/packages/common/src/decorators/retryDecorator.ts +++ b/packages/common/src/decorators/retryDecorator.ts @@ -1,9 +1,9 @@ import asyncRetry = require('async-retry'); /** - * Managed retryDecorator annotation for a method for async methods. Does not work for sync methods - * and will ignore the retryDecorator - * @param opts Async retryDecorator options. To stop retrying early on error use the bailOn callback + * 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 }) { diff --git a/packages/common/src/utils/retry.ts b/packages/common/src/utils/retry.ts index 706fdb8..30824e0 100644 --- a/packages/common/src/utils/retry.ts +++ b/packages/common/src/utils/retry.ts @@ -2,7 +2,7 @@ import asyncRetry = require('async-retry'); export class Retrier { /** - * Try a promise ONCE and if it fails kick it into a non awaited retryDecorator mode + * Try a promise ONCE and if it fails kick it into a non awaited retry mode * * This allows you to try quickly and then dangle a promise separately outside of the await context to complete * @param p From 9519601793263659ddc4b88b1c0788e0d877fb52 Mon Sep 17 00:00:00 2001 From: Anton Kropp Date: Fri, 23 Jan 2026 16:46:49 -0800 Subject: [PATCH 3/6] Moving retry to common --- packages/common-aws/package.json | 2 +- packages/common-hapi/package.json | 2 +- packages/common-server/package.json | 2 +- packages/common-sql/package.json | 2 +- packages/common-test/package.json | 2 +- packages/common/package.json | 2 +- packages/types/package.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) 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-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/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-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/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/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/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", From 8b282967968ae76d7d79d6d427d0a100aa63f68e Mon Sep 17 00:00:00 2001 From: Anton Kropp Date: Fri, 23 Jan 2026 16:47:28 -0800 Subject: [PATCH 4/6] Moving retry to common --- packages/common-server/src/metrics/metrics.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/common-server/src/metrics/metrics.test.ts b/packages/common-server/src/metrics/metrics.test.ts index 7ec73e9..be5c69f 100644 --- a/packages/common-server/src/metrics/metrics.test.ts +++ b/packages/common-server/src/metrics/metrics.test.ts @@ -1,5 +1,11 @@ /* 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 +process.env.PARADOX_SKIP_LOG_DECORATORS = 'false'; + import { metricsProvider, timed as timedRaw } from '@paradoxical-io/common'; import { extendJest, safeExpect } from '@paradoxical-io/common-test'; @@ -7,11 +13,6 @@ import { logMethod } from '../logger'; import { Metrics } from './metrics'; import { timed } from './timingDecorator'; -process.env.PARADOX_LOG_LEVEL = 'info'; - -// ensure log decorators always run -process.env.PARADOX_SKIP_LOG_DECORATORS = 'false'; - extendJest(); class TestWithInvalidMetricsType { From 79287eb0ce7456af168784068e9cf2b16603a1a6 Mon Sep 17 00:00:00 2001 From: Anton Kropp Date: Fri, 23 Jan 2026 16:49:34 -0800 Subject: [PATCH 5/6] Fixing tests --- packages/common/src/decorators/retryDecorator.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/common/src/decorators/retryDecorator.test.ts b/packages/common/src/decorators/retryDecorator.test.ts index 7ab1e94..d11f999 100644 --- a/packages/common/src/decorators/retryDecorator.test.ts +++ b/packages/common/src/decorators/retryDecorator.test.ts @@ -5,8 +5,6 @@ import { retry } from './retryDecorator'; class Retriable { fails = 0; - retried = false; - @retry({ maxTimeout: 50, minTimeout: 1 }) async succeedsPromise(failUntil: number): Promise { if (this.fails < failUntil) { @@ -32,5 +30,4 @@ test('retries if method fails', async () => { safeExpect(await retriable.succeedsPromise(3)).toEqual(3); safeExpect(retriable.fails).toEqual(3); - safeExpect(retriable.retried).toEqual(true); }); From a44d94eedc99af021ccbb49123b3756a8f60f413 Mon Sep 17 00:00:00 2001 From: Anton Kropp Date: Fri, 23 Jan 2026 16:55:34 -0800 Subject: [PATCH 6/6] Fixing tests --- packages/common/src/decorators/retryDecorator.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/common/src/decorators/retryDecorator.test.ts b/packages/common/src/decorators/retryDecorator.test.ts index d11f999..27a8694 100644 --- a/packages/common/src/decorators/retryDecorator.test.ts +++ b/packages/common/src/decorators/retryDecorator.test.ts @@ -22,7 +22,6 @@ test('doesnt retry if method succeeds', async () => { safeExpect(await retriable.succeedsPromise(0)).toEqual(0); safeExpect(retriable.fails).toEqual(0); - safeExpect(retriable.retried).toEqual(false); }); test('retries if method fails', async () => {