getRecipes$(): Observable<Recipe[]> {
return this.httpClient.get<Recipe[]>('https://super-recipes.com/api/recipes').pipe(
catchError((error) => {
this._snackBar.open('Recipes could not be fetched.', 'Close', {
verticalPosition: 'top',
horizontalPosition: 'right',
panelClass: ['mat-error'],
});
return throwError(() => new Error('Recipes could not be fetched.'));
}),
);
}
Alfred (Batman Begins)
getRecipes$(): Observable<Recipe[]> {
return this.httpClient.get<Recipe[]>('https://super-recipes.com/api/recipes').pipe(
catchError((error) => {
this.showFeedbackMessage('Recipes could not be fetched.');
return throwError(() => new Error('Recipes could not be fetched.'));
}),
retry(3)
);
}
RxJS retry operator
Tim S. Grover
getRecipesWithBackoffStrategy$(): Observable<Recipe[]> {
return this.httpClient.get<Recipe[]>('https://super-recipes.com/api/recipes').pipe(
catchError((error) => {
this.showFeedbackMessage('Recipes could not be fetched.');
return throwError(() => new Error('Recipes could not be fetched.'));
}),
retry({
count: 3,
delay: (error, retryCount) => {
console.log(
`Attempt ${retryCount}:
Error occurred during network request,
retrying in ${Math.pow(2, retryCount)} seconds...
`
);
return timer(Math.pow(2, retryCount) * 1000);
},
}),
);
}
RxJS timer function
export const BulkheadInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) {
private readonly MAX_CONCURRENT_REQUESTS = 3;
private requestQueue = new Subject<Observable<any>>();
constructor() {
this.requestQueue
.pipe(mergeMap((task) => task, this.MAX_CONCURRENT_REQUESTS))
.subscribe();
}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
this.requestQueue.next(next.handle());
return this.requestQueue.asObservable();
}
}
@Injectable()
export class BulkheadInterceptor implements NestInterceptor {
private readonly MAX_CONCURRENT_REQUESTS = 3;
private activeRequests = 0;
private requestQueue: (() => Observable<any>)[] = [];
private processQueue() {
if (
this.requestQueue.length > 0 &&
this.activeRequests < this.MAX_CONCURRENT_REQUESTS
) {
const task = this.requestQueue.shift()!;
this.activeRequests++;
task().subscribe({
complete: () => {
this.activeRequests--;
this.processQueue();
},
});
}
}
intercept<T>(context: ExecutionContext, next: CallHandler): Observable<T> {
const request$ = next.handle().pipe(
catchError((err) => of(err)),
finalize(() => {
this.activeRequests--;
this.processQueue();
}),
);
if (this.activeRequests < this.MAX_CONCURRENT_REQUESTS) {
this.activeRequests++;
return request$;
}
const queueRequest$: Observable<T> = new Observable((observer) => {
this.requestQueue.push(() => {
return next.handle().pipe(
catchError((err) => {
observer.error(err);
return of(err);
}),
map((result) => {
observer.next(result);
return result;
}),
);
});
// try to process the queue right away
this.processQueue();
});
return queueRequest$;
}
}
Queues
Semaphores
Thread Pools
Processes
CLOSED -> OPEN - Consecutive failures reach threshold
OPEN - Automatic reject
OPEN -> HALF_OPEN - After timeout period expires
HALF_OPEN -> OPEN - If next request fails
HALF_OPEN -> CLOSED - If next request pass
private openCircuit() {
if (this.state === 'OPEN') return;
this.state = 'OPEN';
timer(5000)
.subscribe(() => this.halfOpenCircuit());
}
private halfOpenCircuit() {
this.state = 'HALF_OPEN';
}
private closeCircuit() {
this.state = 'CLOSED';
}
getRecipesWithCircuitBreakerStrategy$(): Observable<Recipe[]> {
return defer(() => this.sendRequestIfCircuitNotOpen()).pipe(
catchError((error) => {
this.showMessage();
return throwError(
() => new Error('Recipes could not be fetched.'));
}),
retry({
count: this.state === 'HALF_OPEN' ? 1 : 3,
delay: (error, retryCount) => {
if (this.state === 'HALF_OPEN' || retryCount === 3) {
this.openCircuit();
return throwError(
() => new Error('Circuit is open'));
}
return timer(2000);
},
}),
tap(() => {
// Successful retry, close the circuit
this.closeCircuit();
this._snackBar.dismiss();
}),
);
RxJS defer operator
OPEN -> HALF_OPEN - After timeout period expires
sendRequestIfCircuitNotOpen() {
if (this.state === 'OPEN') {
return throwError(() => new Error('Circuit is open'));
}
return this.httpClient.get<Recipe[]>('https://super-recipes.com/api/recipes');
}
OPEN - Automatic reject
getRecipesWithCircuitBreakerStrategy$(): Observable<Recipe[]> {
return defer(() => this.sendRequestIfCircuitNotOpen()).pipe(
catchError((error) => {
this.showMessage();
return throwError(
() => new Error('Recipes could not be fetched.'));
}),
retry({
count: this.state === 'HALF_OPEN' ? 1 : 3,
delay: (error, retryCount) => {
if (this.state === 'HALF_OPEN' || retryCount === 3) {
this.openCircuit();
return throwError(() => new Error('Circuit is open'));
}
return timer(2000);
},
}),
tap(() => {
// Successful retry, close the circuit
this.closeCircuit();
this._snackBar.dismiss();
}),
);
}
CLOSED -> OPEN
OPEN
OPEN -> HALF_OPEN
HALF_OPEN -> OPEN
HALF_OPEN -> CLOSED
Helen Keller
postRecipe(): void {
this.http
.post<Recipe>('https://super-recipes.com/api/recipes', this.recipe)
.pipe(
optimisticUpdate(this.recipe, (originalItem: Recipe, error: Error) => {
// Rollback UI changes here
console.error('Error updating item:', originalItem);
this.recipes$.next(error);
})
)
.subscribe((recipe: Recipe) => this.recipes$.next(recipe));
}
export function optimisticUpdate<T, E = any>(
originalValue: T,
rollback: (value: T, error: E) => void,
retryConfig: { count: number; delay: number } = { count: 1, delay: 20000 }
): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>) => {
return concat(
of(originalValue),
source.pipe(
ignoreElements(),
retry(retryConfig),
catchError((error) => {
rollback(originalValue, error);
return throwError(() => error);
})
)
);
};
}
postRecipe(): void {
this.http
.post<Recipe>('https://super-recipes.com/api/recipes', this.recipe)
.pipe(
optimisticUpdate(this.recipe, (originalItem: Recipe, error: Error) => {
// Rollback UI changes here
console.error('Error updating item:', originalItem);
this.recipes$.next(error);
})
)
.subscribe((recipe: Recipe) => this.recipes$.next(recipe));
}
Retry
Exponential backoff
Circuit breaker
Bulkhead
getRecipes$(): Observable<Recipe[]> {
return this.httpClient.get<Recipe[]>('/api/recipes').pipe(
catchError((error) => {
this.logError(error);
return throwError(
() => new Error('Recipes could not be fetched.'));
}),
retry(3)
);
}
getRecipesWithBackoffStrategy$(): Observable<Recipe[]> {
return this.httpClient.get<Recipe[]>('/api/recipes').pipe(
catchError((error) => {
this.logError(error);
return throwError(
() => new Error('Recipes could not be fetched.'));
}),
retry({
count: 3,
delay: (error, retryCount) => {
return timer(Math.pow(2, retryCount) * 1000);
},
}),
);
}
getRecipesWithCircuitBreakerStrategy$(): Observable<Recipe[]> {
return defer(() => this.sendRequestIfCircuitNotOpen()).pipe(
catchError((error) => {
this.logError(error);
return throwError(
() => new Error('Recipes could not be fetched.'));
}),
retry({
count: this.state === 'HALF_OPEN' ? 1 : 3,
delay: (error, retryCount) => {
if (this.state === 'HALF_OPEN' || retryCount === 3) {
this.openCircuit();
return throwError(() => new Error('Circuit is open'));
}
return timer(2000);
},
}),
tap(() => {
// Successful retry, close the circuit
this.closeCircuit();
}),
);
}
import { Observable, Subject, mergeMap } from 'rxjs';
@Injectable()
export class BulkheadInterceptor implements NestInterceptor {
private readonly MAX_CONCURRENT_REQUESTS = 3;
private requestQueue = new Subject<Observable<any>>();
constructor() {
this.requestQueue
.pipe(mergeMap((task) => task, this.MAX_CONCURRENT_REQUESTS))
.subscribe();
}
intercept(
context: ExecutionContext,
next: CallHandler
): Observable<any> {
this.requestQueue.next(next.handle());
return this.requestQueue.asObservable();
}
}
nikola.mitrovic1313@gmail.com
You can find me at
Link to the slides
https://github.com/PacktPublishing/RxJS-Cookbook-for-Reactive-Developers