Skip to content

Commit

Permalink
feat(effects): make resubscription handler customizable
Browse files Browse the repository at this point in the history
BREAKING CHANGE:
`resubscribeOnError` renamed to `useEffectsErrorHandler` in `createEffect` metadata

Closes ngrx#2294
  • Loading branch information
zak-cloudnc committed Jan 19, 2020
1 parent a528320 commit ecd9916
Show file tree
Hide file tree
Showing 17 changed files with 310 additions and 70 deletions.
56 changes: 56 additions & 0 deletions docs/effects/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,62 @@ export class UserEffects implements OnRunEffects {
}
```

### EffectsErrorHandler

By default, if an effect has `{useEffectsErrorHandler: true}` (the effect metadata default), when the effect encounters an error it
is automatically resubscribed to, and the Angular `ErrorHandler.handleError` method is called with the error, and the
effect observable resubscribed to.

If you want to customize this behavior, for example if you have a [custom error handler](https://angular.io/api/core/ErrorHandler) that needs specific input, or you
only want to resubscribe on certain errors etc, you may provide a custom handler using the `EFFECTS_ERROR_HANDLER`
injection token.

Usage:

```ts
import { EffectsModule, EFFECTS_ERROR_HANDLER } from '@ngrx/effects';
import { MovieEffects } from './effects/movie.effects';
import { CustomErrorHandler, isRetryable } from '../custom-error-handler';
import { Action } from '@ngrx/store';
import { Observable, throwError } from 'rxjs';
import { retryWhen, mergeMap } from 'rxjs/operators';

export function effectResubscriptionHandler<T extends Action>(
observable$: Observable<T>,
errorHandler?: CustomErrorHandler
): Observable<T> {
return observable$.pipe(
retryWhen(errors =>
errors.pipe(
mergeMap(e => {
if (isRetryable(e)) {
return errorHandler.handleRetryableError(e);
}

errorHandler.handleError(e);
return throwError(e);
})
)
)
);
}

@NgModule({
imports: [EffectsModule.forRoot([MovieEffects])],
providers: [
{
provide: EFFECTS_ERROR_HANDLER,
useValue: effectResubscriptionHandler,
},
{
provide: ErrorHandle,
useClass: CustomErrorHandler,
},
],
})
export class AppModule {}
```

## Utilities

### mergeEffects
Expand Down
24 changes: 13 additions & 11 deletions modules/effects/spec/effect_creator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,30 +52,32 @@ describe('createEffect()', () => {
a = createEffect(() => of({ type: 'a' }));
b = createEffect(() => of({ type: 'b' }), { dispatch: true });
c = createEffect(() => of({ type: 'c' }), { dispatch: false });
d = createEffect(() => of({ type: 'd' }), { resubscribeOnError: true });
d = createEffect(() => of({ type: 'd' }), {
useEffectsErrorHandler: true,
});
e = createEffect(() => of({ type: 'd' }), {
resubscribeOnError: false,
useEffectsErrorHandler: false,
});
f = createEffect(() => of({ type: 'e' }), {
dispatch: false,
resubscribeOnError: false,
useEffectsErrorHandler: false,
});
g = createEffect(() => of({ type: 'e' }), {
dispatch: true,
resubscribeOnError: false,
useEffectsErrorHandler: false,
});
}

const mock = new Fixture();

expect(getCreateEffectMetadata(mock)).toEqual([
{ propertyName: 'a', dispatch: true, resubscribeOnError: true },
{ propertyName: 'b', dispatch: true, resubscribeOnError: true },
{ propertyName: 'c', dispatch: false, resubscribeOnError: true },
{ propertyName: 'd', dispatch: true, resubscribeOnError: true },
{ propertyName: 'e', dispatch: true, resubscribeOnError: false },
{ propertyName: 'f', dispatch: false, resubscribeOnError: false },
{ propertyName: 'g', dispatch: true, resubscribeOnError: false },
{ propertyName: 'a', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'b', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'c', dispatch: false, useEffectsErrorHandler: true },
{ propertyName: 'd', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'e', dispatch: true, useEffectsErrorHandler: false },
{ propertyName: 'f', dispatch: false, useEffectsErrorHandler: false },
{ propertyName: 'g', dispatch: true, useEffectsErrorHandler: false },
]);
});

Expand Down
22 changes: 11 additions & 11 deletions modules/effects/spec/effect_decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,26 @@ describe('@Effect()', () => {
b: any;
@Effect({ dispatch: false })
c: any;
@Effect({ resubscribeOnError: true })
@Effect({ useEffectsErrorHandler: true })
d: any;
@Effect({ resubscribeOnError: false })
@Effect({ useEffectsErrorHandler: false })
e: any;
@Effect({ dispatch: false, resubscribeOnError: false })
@Effect({ dispatch: false, useEffectsErrorHandler: false })
f: any;
@Effect({ dispatch: true, resubscribeOnError: false })
@Effect({ dispatch: true, useEffectsErrorHandler: false })
g: any;
}

const mock = new Fixture();

expect(getEffectDecoratorMetadata(mock)).toEqual([
{ propertyName: 'a', dispatch: true, resubscribeOnError: true },
{ propertyName: 'b', dispatch: true, resubscribeOnError: true },
{ propertyName: 'c', dispatch: false, resubscribeOnError: true },
{ propertyName: 'd', dispatch: true, resubscribeOnError: true },
{ propertyName: 'e', dispatch: true, resubscribeOnError: false },
{ propertyName: 'f', dispatch: false, resubscribeOnError: false },
{ propertyName: 'g', dispatch: true, resubscribeOnError: false },
{ propertyName: 'a', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'b', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'c', dispatch: false, useEffectsErrorHandler: true },
{ propertyName: 'd', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'e', dispatch: true, useEffectsErrorHandler: false },
{ propertyName: 'f', dispatch: false, useEffectsErrorHandler: false },
{ propertyName: 'g', dispatch: true, useEffectsErrorHandler: false },
]);
});

Expand Down
8 changes: 4 additions & 4 deletions modules/effects/spec/effect_sources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,9 @@ describe('EffectSources', () => {
expect(toActions(sources$)).toBeObservable(expected);
});

it('should not resubscribe on error when resubscribeOnError is false', () => {
it('should not resubscribe on error when useEffectsErrorHandler is false', () => {
class Eff {
@Effect({ resubscribeOnError: false })
@Effect({ useEffectsErrorHandler: false })
b$ = hot('a--b--c--d').pipe(
map(v => {
if (v == 'b') throw new Error('An Error');
Expand Down Expand Up @@ -635,7 +635,7 @@ describe('EffectSources', () => {
expect(toActions(sources$)).toBeObservable(expected);
});

it('should not resubscribe on error when resubscribeOnError is false', () => {
it('should not resubscribe on error when useEffectsErrorHandler is false', () => {
const sources$ = of(
new class {
b$ = createEffect(
Expand All @@ -646,7 +646,7 @@ describe('EffectSources', () => {
return v;
})
),
{ dispatch: false, resubscribeOnError: false }
{ dispatch: false, useEffectsErrorHandler: false }
);
}()
);
Expand Down
100 changes: 100 additions & 0 deletions modules/effects/spec/effects_error_handler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ErrorHandler, Provider } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Action, Store } from '@ngrx/store';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { createEffect, EFFECTS_ERROR_HANDLER, EffectsModule } from '..';

describe('NgRx Effects Error Handler spec', () => {
let subscriptionCount: number;
let globalErrorHandler: jasmine.Spy;
let storeNext: jasmine.Spy;

function makeEffectTestBed(...providers: Provider[]) {
subscriptionCount = 0;

TestBed.configureTestingModule({
imports: [EffectsModule.forRoot([ErrorEffect])],
providers: [
{
provide: Store,
useValue: {
next: jasmine.createSpy('storeNext'),
dispatch: jasmine.createSpy('dispatch'),
},
},
{
provide: ErrorHandler,
useValue: {
handleError: jasmine.createSpy('globalErrorHandler'),
},
},
...providers,
],
});

globalErrorHandler = TestBed.get(ErrorHandler).handleError;
const store = TestBed.get(Store);
storeNext = store.next;
}

it('should retry and notify error handler when effect error handler is not provided', () => {
makeEffectTestBed();

// two subscriptions expected:
// 1. Initial subscription to the effect (this will error)
// 2. Resubscription to the effect after error (this will not error)
expect(subscriptionCount).toBe(2);
expect(globalErrorHandler).toHaveBeenCalledWith(new Error('effectError'));
});

it('should use custom error behavior when EFFECTS_ERROR_HANDLER is provided', () => {
const effectsErrorHandlerSpy = jasmine
.createSpy()
.and.callFake((effect$: Observable<any>, errorHandler: ErrorHandler) => {
return effect$.pipe(
catchError(err => {
errorHandler.handleError(
new Error('inside custom handler: ' + err.message)
);
return of({ type: 'custom action' });
})
);
});

makeEffectTestBed({
provide: EFFECTS_ERROR_HANDLER,
useValue: effectsErrorHandlerSpy,
});

expect(effectsErrorHandlerSpy).toHaveBeenCalledWith(
jasmine.any(Observable),
TestBed.get(ErrorHandler)
);
expect(globalErrorHandler).toHaveBeenCalledWith(
new Error('inside custom handler: effectError')
);
expect(subscriptionCount).toBe(1);
expect(storeNext).toHaveBeenCalledWith({ type: 'custom action' });
});

class ErrorEffect {
effect$ = createEffect(errorFirstSubscriber, {
useEffectsErrorHandler: true,
});
}

/**
* This observable factory returns an observable that will never emit, but the first subscriber will get an immediate
* error. All subsequent subscribers will just get an observable that does not emit.
*/
function errorFirstSubscriber(): Observable<Action> {
return new Observable(observer => {
subscriptionCount++;

if (subscriptionCount === 1) {
observer.error(new Error('effectError'));
}
});
}
});
28 changes: 14 additions & 14 deletions modules/effects/spec/effects_metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ describe('Effects metadata', () => {
@Effect({ dispatch: false })
c: any;
d = createEffect(() => of({ type: 'a' }), { dispatch: false });
@Effect({ dispatch: false, resubscribeOnError: false })
@Effect({ dispatch: false, useEffectsErrorHandler: false })
e: any;
z: any;
}

const mock = new Fixture();
const expected: EffectMetadata<Fixture>[] = [
{ propertyName: 'a', dispatch: true, resubscribeOnError: true },
{ propertyName: 'c', dispatch: false, resubscribeOnError: true },
{ propertyName: 'b', dispatch: true, resubscribeOnError: true },
{ propertyName: 'd', dispatch: false, resubscribeOnError: true },
{ propertyName: 'e', dispatch: false, resubscribeOnError: false },
{ propertyName: 'a', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'c', dispatch: false, useEffectsErrorHandler: true },
{ propertyName: 'b', dispatch: true, useEffectsErrorHandler: true },
{ propertyName: 'd', dispatch: false, useEffectsErrorHandler: true },
{ propertyName: 'e', dispatch: false, useEffectsErrorHandler: false },
];

expect(getSourceMetadata(mock)).toEqual(
Expand All @@ -45,20 +45,20 @@ describe('Effects metadata', () => {
e: any;
f = createEffect(() => of({ type: 'f' }), { dispatch: false });
g = createEffect(() => of({ type: 'g' }), {
resubscribeOnError: false,
useEffectsErrorHandler: false,
});
}

const mock = new Fixture();

expect(getEffectsMetadata(mock)).toEqual({
a: { dispatch: true, resubscribeOnError: true },
c: { dispatch: true, resubscribeOnError: true },
e: { dispatch: false, resubscribeOnError: true },
b: { dispatch: true, resubscribeOnError: true },
d: { dispatch: true, resubscribeOnError: true },
f: { dispatch: false, resubscribeOnError: true },
g: { dispatch: true, resubscribeOnError: false },
a: { dispatch: true, useEffectsErrorHandler: true },
c: { dispatch: true, useEffectsErrorHandler: true },
e: { dispatch: false, useEffectsErrorHandler: true },
b: { dispatch: true, useEffectsErrorHandler: true },
d: { dispatch: true, useEffectsErrorHandler: true },
f: { dispatch: false, useEffectsErrorHandler: true },
g: { dispatch: true, useEffectsErrorHandler: false },
});
});

Expand Down
2 changes: 1 addition & 1 deletion modules/effects/src/effect_creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type ObservableType<T, OriginalType> = T extends false ? OriginalType : Action;
* Creates an effect from an `Observable` and an `EffectConfig`.
*
* @param source A function which returns an `Observable`.
* @param config A `Partial<EffectConfig>` to configure the effect. By default, `dispatch` is true and `resubscribeOnError` is true.
* @param config A `Partial<EffectConfig>` to configure the effect. By default, `dispatch` is true and `useEffectsErrorHandler` is true.
* @returns If `EffectConfig`#`dispatch` is true, returns `Observable<Action>`. Else, returns `Observable<unknown>`.
*
* @usageNotes
Expand Down
Loading

0 comments on commit ecd9916

Please sign in to comment.