Skip to content

Commit

Permalink
feat: introducing retryer middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
Plopix committed Oct 28, 2024
1 parent fd12dac commit f8f390a
Show file tree
Hide file tree
Showing 10 changed files with 445 additions and 12 deletions.
6 changes: 5 additions & 1 deletion docs/src/content/docs/built-in-middlewares/cacher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ It's only available for _QueryBus_
As for any Middleware, you can use it by adding it to the `bus` instance.

```typescript
const cacherMiddleware = createCacherMiddleware<QueryHandlerRegistry>({ adapter, cache: 'all', defaultTtl: 3600 });
const cacherMiddleware = createCacherMiddleware<QueryHandlerRegistry>({
adapter,
cache: 'all',
defaultTtl: 3600
});
const queryBus = createQueryBus<QueryHandlerRegistry>();
queryBus.use(cacherMiddleware);
```
Expand Down
56 changes: 56 additions & 0 deletions docs/src/content/docs/built-in-middlewares/retryer.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: Retryer Middleware
description: Built-in middleware to retry handling if in error.
---

import { Icon, Aside } from '@astrojs/starlight/components';

The Retryer Middleware is built-in middleware that gives you capability to retry the handling of an intent.

## How to use it

As for any Middleware, you can use it by adding it to the `bus` instance.

```typescript
const retryerMiddleware = createRetryerMiddleware({
maxAttempts: 5;
waitingAlgorithm: 'exponential',
multiplier: 1.5;
jitter: 0.5;
});
const queryBus = createQueryBus<QueryHandlerRegistry>();
queryBus.use(retryerMiddleware);
```

### Explanation

The Retryer middleware is going to catch the exection and re-run the following middleware until the `maxAttempts` is reached.
Between each attempt, the middleware is going to wait for a certain amount of time.
The `waitingAlgorithm` can be `exponential`, `fibonacci`, or `none`.

- `jitter` is a value between 0 and 1 that will add some randomness to the waiting time.
- `multiplier` is the factor to multiply the waiting time between each attempt. (only used for `exponential`)

<Aside title="Error Stamps" type="caution">
Retryer Middleware will also retry the handling of the intent if it finds more `error` stamps than before.
Some handlers might not throw exceptions but add `error` stamps to the result. A good example would be a lock middleware that would
add an `error` stamp if the lock is not acquired.
</Aside>

## Added Stamps

The Retryer Middleware is going to add:

-
```typescript
type RetriedStamp = Stamp<{ attempt: number; errorMessage: string }, 'missive:retried'>;
```
> Every time the middleware retries the handling of the intent, it will add a `RetriedStamp` to the envelope.


## Going further

<div class='flex flex-row'>
<span className='pr-2'>Look at the code of the </span>
<a href="https://github.com/Missive-js/missive.js/tree/main/libs/missive.js/src/middlewares/retryer-middleware.ts" class="contents" target="_blank"><Icon name="github" class="mr-2"/>Retryer Middleware</a>
</div>
19 changes: 10 additions & 9 deletions docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
title: A Service Bus with Envelope and Stamps fully-typed for Typescript that you needed
description: Fully typed Service Bus for Node.js built in Typescript
description: Fully-typed Service Bus for Node.js fueled by fancy Middlwares, Envelopes and Stamps.
template: splash

hero:
title: The Service Bus that you needed
tagline: Fully-typed Service Bus for Node.js built in Typescript.
tagline: Fully-typed Service Bus for Node.js fueled by fancy Middlwares, Envelopes and Stamps.
image:
file: ../../assets/bus.svg
actions:
Expand Down Expand Up @@ -40,17 +40,18 @@ We love clean architecture and CQRS, we didn't find any simple Service Bus that
You can use it with any framework or library, wether you're fully backend or backend for frontend, we got you
covered.
</Card>
<Card title="Type-safe" icon="seti:typescript">
Define your `query`, `command` or `event` using a [Zod schema](https://zod.dev) for validation and type
inference. The `bus` instance is fully typed. _(You'll have auto-completion for free!)_
</Card>
<Card title="Middlewares" icon="open-book">
Can we call it a Service Bus without Middlewares? _(we don't)_. Register middlewares to leverage the full power
of the service bus.
We got you covered with built-in middlewares like [Logger](/missive.js/built-in-middlewares/logger), [Caching](/missive.js/built-in-middlewares/cacher), [Retry](/missive.js/built-in-middlewares/retryer).
</Card>
<Card title="Envelopes and Stamps" icon="email">
Handle cross-cutting concerns with Envelopes and Stamps.
</Card>
<Card title="Type-safe" icon="seti:typescript">
Define your `query`, `command` or `event` using a [Zod schema](https://zod.dev) for validation and type
inference. The `bus` instance is fully typed. _(You'll have auto-completion for free!)_
</Card>
</CardGrid>

## In a nutshell
Expand Down Expand Up @@ -97,7 +98,7 @@ We love clean architecture and CQRS, we didn't find any simple Service Bus that

## Built-in Middlewares

Those are the built-in [middlewares](/missive.js/guides/middlewares) that you can use out of the box. They are all optional and you can use them all together or just the ones you need.
Whitout Middlewares, a Service Bus is almost useless, we provide built-in [middlewares](/missive.js/guides/middlewares) that you can use out of the box. They are all optional and you can use them all together or just the ones you need.
Middlewares provides a way to handle cross-cutting concerns. They are key to keep your code clean and maintainable.
And most of all, they are easy to write and to use, and they can be generic!

Expand All @@ -115,8 +116,8 @@ That's the power of Missive.js! Here are the built-in middlewares:
</Card>

<Card title="Retry Middleware" icon="random">
<p>Automatically retries processing a message a specified number of times before considering it as failed, with a backoff strategy.</p>
<LinkButton href={'/missive.js/contributing'} variant="secondary" class="float-right" ><Badge text='coming soon' variant='caution'/></LinkButton>
<p>Automatically retries processing an intent a specified number of times before considering it as failed, with different backoff strategy.</p>
<LinkButton href={'/missive.js/built-in-middlewares/retryer'} variant="secondary" class="float-right" title={"Read the doc about the Retryer Middleware"}>Read the doc</LinkButton>
</Card>

<Card title="Async Middleware" icon="seti:nunjucks">
Expand Down
7 changes: 6 additions & 1 deletion docs/src/content/docs/why.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ centralized system for handling Commands, Queries, and Events, allowing you to b

2. **Middleware system**: Missive.js includes a powerful middleware system that allows you to inject cross-cutting concerns, such as:

- **Logging**: Consistent logging across all operations, providing centralized insight into your application's behavior.
- **Validation**: Reusable validation logic that ensures all incoming data adheres to your application's requirements before it reaches the handlers.
- **Authorization**: Easy-to-implement access control checks that run before commands or queries are processed.

And on top of what you can do on your own, Missive.js provides a set of built-in middlewares to handle common concerns like:
- [**Caching**](/missive.js/built-in-middlewares/cacher): Cache the results of queries to speed up your application.
- [**Retryer**](/missive.js/built-in-middlewares/retryer): Retry handling if an error occurs.
- [**Logger**](/missive.js/built-in-middlewares/logger): Log the messages and the full Envelope (with the Stamps) before and once handled (or errored) in the backend of your choice.


This middleware architecture is familiar if you've used libraries like Express, Fastify, Koa, etc. making it intuitive for both backend and frontend developers.
It centralizes the processing logic, making your code cleaner and easier to maintain.

Expand Down
2 changes: 1 addition & 1 deletion libs/missive.js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"Sébastien Morel <[email protected]>",
"Anaël Chardan"
],
"version": "0.0.4",
"version": "0.0.5",
"type": "module",
"main": "./build/index.cjs",
"module": "./build/index.js",
Expand Down
3 changes: 3 additions & 0 deletions libs/missive.js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ export { createLoggerMiddleware } from './middlewares/logger-middleware.js';

export type { CacherAdapter, CacheableStamp, FromCacheStamp } from './middlewares/cacher-middleware.js';
export { createCacherMiddleware } from './middlewares/cacher-middleware.js';

export type { RetriedStamp } from './middlewares/retryer-middleware.js';
export { createRetryerMiddleware } from './middlewares/retryer-middleware.js';
65 changes: 65 additions & 0 deletions libs/missive.js/src/middlewares/retryer-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Stamp } from '../core/envelope.js';
import { GenericMiddleware } from '../core/middleware.js';
import { createExponentialSleeper, createFibonnaciSleeper } from '../utils/sleeper.js';

type Options = {
maxAttempts: number;
waitingAlgorithm: 'exponential' | 'fibonacci' | 'none';
multiplier: number;
jitter: number;
};

export type RetriedStamp = Stamp<{ attempt: number; errorMessage: string }, 'missive:retried'>;

export function createRetryerMiddleware({
maxAttempts = 3,
waitingAlgorithm = 'exponential',
multiplier = 1.5,
jitter = 0.5,
}: Partial<Options> = {}): GenericMiddleware {
const noneSleeper = () => ({ wait: async () => {}, reset: () => {} });
const sleeper =
waitingAlgorithm === 'none'
? noneSleeper()
: waitingAlgorithm === 'exponential'
? createExponentialSleeper(multiplier, jitter)
: createFibonnaciSleeper(jitter);

return async (envelope, next) => {
let attempt = 1;
sleeper.reset();
let lastError: unknown | null = null;
while (attempt <= maxAttempts) {
try {
const initialErrorStampCount = envelope.stamps.filter((stamp) => stamp.type === 'error').length;
await next();
const errorStampCount = envelope.stamps.filter((stamp) => stamp.type === 'error').length;
const newErrorStampCount = errorStampCount - initialErrorStampCount;
if (newErrorStampCount === 0) {
// no new error, we are goog to go
return;
}
envelope.addStamp<RetriedStamp>('missive:retried', {
attempt,
errorMessage: `New error stamp count: ${newErrorStampCount}`,
});
} catch (error) {
lastError = error;
envelope.addStamp<RetriedStamp>('missive:retried', {
attempt,
errorMessage: error instanceof Error ? error.message : String(error),
});
}
attempt++;
if (attempt > maxAttempts) {
// if we have an error, we throw it
if (lastError !== null) {
throw lastError;
}
// if we don't have an error because the retries we based on an error stamp count, we just return
return;
}
await sleeper.wait();
}
};
}
42 changes: 42 additions & 0 deletions libs/missive.js/src/utils/sleeper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000));

type Deps = {
sleepFn?: (s: number) => Promise<unknown>;
};
export const createFibonnaciSleeper = (jitter = 0, deps?: Deps) => {
const sleepFn = deps?.sleepFn || sleep;
let a = 0,
b = 1;
return {
wait: async () => {
const w = a + b;
a = b;
b = w;
const max = w * (1 + jitter);
const min = w * (1 - jitter);
const jitteredDelay = Math.random() * (max - min) + min;
await sleepFn(jitteredDelay);
},
reset: () => {
a = 0;
b = 1;
},
};
};

export const createExponentialSleeper = (multiplier: number = 1.5, jitter: number = 0.5, deps?: Deps) => {
const sleepFn = deps?.sleepFn || sleep;
let currentDelay = 0.5;
return {
wait: async () => {
const max = currentDelay * (1 + jitter);
const min = currentDelay * (1 - jitter);
const jitteredDelay = Math.random() * (max - min) + min;
await sleepFn(jitteredDelay);
currentDelay = currentDelay * multiplier;
},
reset: () => {
currentDelay = 0.5;
},
};
};
115 changes: 115 additions & 0 deletions libs/missive.js/tests/retryer-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createRetryerMiddleware, RetriedStamp } from '../src/middlewares/retryer-middleware';
import { createEnvelope, Envelope } from '../src/core/envelope';

describe('createRetryerMiddleware', () => {
let nextMock: ReturnType<typeof vi.fn>;
let envelope: Envelope<unknown>;

beforeEach(() => {
nextMock = vi.fn();
envelope = createEnvelope('test message');
});

it('should retry the correct number of times with none algorithm', async () => {
const middleware = createRetryerMiddleware({
maxAttempts: 5,
waitingAlgorithm: 'none',
multiplier: 0,
jitter: 0,
});
nextMock.mockRejectedValueOnce(new Error('Test Error'));
nextMock.mockRejectedValueOnce(new Error('Test Error'));
nextMock.mockResolvedValueOnce(undefined);
await middleware(envelope, nextMock);
expect(nextMock).toHaveBeenCalledTimes(3);
const retriedStamps = envelope.stampsOfType<RetriedStamp>('missive:retried');
expect(retriedStamps?.length || 0).toBe(2);
});

it('should add retried stamp on error', async () => {
const middleware = createRetryerMiddleware({
maxAttempts: 3,
waitingAlgorithm: 'none',
multiplier: 0,
jitter: 0,
});
nextMock.mockRejectedValueOnce(new Error('Test Error'));

try {
await middleware(envelope, nextMock);
} catch {
// expected to throw
}
const retriedStamps = envelope.stampsOfType<RetriedStamp>('missive:retried');
expect(retriedStamps?.length || 0).toBe(1);
expect(retriedStamps[0]!.body!.attempt).toBe(1);
expect(retriedStamps[0]!.body!.errorMessage).toBe('Test Error');
});

it('should stop retrying after max attempts', async () => {
const middleware = createRetryerMiddleware({
maxAttempts: 2,
waitingAlgorithm: 'none',
multiplier: 0,
jitter: 0,
});
nextMock.mockRejectedValue(new Error('Test Error'));

try {
await middleware(envelope, nextMock);
} catch {
// expected to throw
}

expect(nextMock).toHaveBeenCalledTimes(2);
});

it('should not retry if no error occurs', async () => {
const middleware = createRetryerMiddleware({
maxAttempts: 3,
waitingAlgorithm: 'none',
multiplier: 0,
jitter: 0,
});
nextMock.mockResolvedValueOnce(undefined);

await middleware(envelope, nextMock);

expect(nextMock).toHaveBeenCalledTimes(1);
expect(envelope.stamps).not.toContainEqual(expect.objectContaining({ type: 'missive:retried' }));
});

it('should respect maxAttempts and throw last error', async () => {
const middleware = createRetryerMiddleware({
maxAttempts: 2,
waitingAlgorithm: 'none',
multiplier: 0,
jitter: 0,
});
nextMock.mockRejectedValue(new Error('test error'));
await expect(middleware(envelope, nextMock)).rejects.toThrow('test error');
expect(nextMock).toHaveBeenCalledTimes(2);
});

it('should handle error stamps from next middleware', async () => {
const middleware = createRetryerMiddleware({
maxAttempts: 3,
waitingAlgorithm: 'none',
multiplier: 0,
jitter: 0,
});

nextMock.mockImplementationOnce(() => {
envelope.addStamp('error', { message: 'test error' });
return Promise.resolve();
});
nextMock.mockImplementationOnce(() => {
envelope.addStamp('error', { message: 'test error' });
return Promise.resolve();
});
nextMock.mockResolvedValueOnce(undefined);
await middleware(envelope, nextMock);
expect(nextMock).toHaveBeenCalledTimes(3);
});
});
Loading

0 comments on commit f8f390a

Please sign in to comment.