├── .prettierrc ├── nest-cli.json ├── tsconfig.build.json ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── src ├── main.ts ├── app.module.ts ├── app.service.ts ├── app.controller.ts └── app.service.spec.ts ├── tsconfig.json ├── tslint.json ├── .gitignore ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | await app.listen(3000); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule, Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | @Module({ 6 | imports: [HttpModule], 7 | controllers: [AppController], 8 | providers: [AppService], 9 | }) 10 | export class AppModule {} 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpService } from '@nestjs/common'; 2 | import { Observable, throwError, of } from 'rxjs'; 3 | import { catchError, retry, mergeMap } from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class AppService { 7 | constructor(private readonly httpService: HttpService) {} 8 | 9 | getGoogleBad(): Observable { 10 | return this.httpService 11 | .get('http://localhost:3000/callback', { validateStatus: null }) 12 | .pipe( 13 | mergeMap(val => { 14 | if (val.status >= 400) { 15 | return throwError(`${val.status} returned from http call`); 16 | } 17 | return of(val.data); 18 | }), 19 | retry(2), 20 | catchError(err => { 21 | return of(err); 22 | }), 23 | ); 24 | } 25 | 26 | getHello(): string { 27 | return 'Hello World!'; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, BadRequestException } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Controller() 6 | export class AppController { 7 | static callbackCalledTimes = 0; 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get() 11 | getHello(): string { 12 | return this.appService.getHello(); 13 | } 14 | 15 | @Get('fail') 16 | getHttpFail(): Observable { 17 | return this.appService.getGoogleBad(); 18 | } 19 | 20 | @Get('callback') 21 | getCallbackMessage(): string { 22 | console.log('request made to callback'); 23 | if (AppController.callbackCalledTimes < 3) { 24 | AppController.callbackCalledTimes++; 25 | throw new BadRequestException(); 26 | } 27 | AppController.callbackCalledTimes = 0; 28 | return 'callback called'; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-tester", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "tslint -p tsconfig.json -c tslint.json", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^6.7.2", 24 | "@nestjs/core": "^6.7.2", 25 | "@nestjs/platform-express": "^6.7.2", 26 | "reflect-metadata": "^0.1.13", 27 | "rimraf": "^3.0.0", 28 | "rxjs": "^6.5.3" 29 | }, 30 | "devDependencies": { 31 | "@nestjs/cli": "^6.9.0", 32 | "@nestjs/schematics": "^6.7.0", 33 | "@nestjs/testing": "^6.7.1", 34 | "@types/express": "^4.17.1", 35 | "@types/jest": "^24.0.18", 36 | "@types/node": "^12.7.5", 37 | "@types/supertest": "^2.0.8", 38 | "jest": "^24.9.0", 39 | "prettier": "^1.18.2", 40 | "supertest": "^4.0.2", 41 | "ts-jest": "^24.1.0", 42 | "ts-loader": "^6.1.1", 43 | "ts-node": "^8.4.1", 44 | "tsconfig-paths": "^3.9.0", 45 | "tslint": "^5.20.0", 46 | "typescript": "^3.6.3" 47 | }, 48 | "jest": { 49 | "moduleFileExtensions": [ 50 | "js", 51 | "json", 52 | "ts" 53 | ], 54 | "rootDir": "src", 55 | "testRegex": ".spec.ts$", 56 | "transform": { 57 | "^.+\\.(t|j)s$": "ts-jest" 58 | }, 59 | "coverageDirectory": "./coverage", 60 | "testEnvironment": "node" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { AppService } from './app.service'; 3 | import { HttpService } from '@nestjs/common'; 4 | import { of, Observable, Subject, Observer } from 'rxjs'; 5 | 6 | const success = { 7 | status: 200, 8 | data: 'Some Data', 9 | config: {}, 10 | statusText: '', 11 | headers: {}, 12 | }; 13 | const failure = { 14 | status: 400, 15 | data: 'Bad Request', 16 | config: {}, 17 | statusText: '', 18 | headers: {}, 19 | }; 20 | 21 | const mockRetryFunction = (times: number, failureValue: any, successValue: any) => { 22 | let count = 0; 23 | return Observable.create((observer: Observer) => { 24 | if (count++ < times) { 25 | observer.next(failureValue); 26 | } else { 27 | observer.next(successValue); 28 | observer.complete(); 29 | } 30 | }); 31 | }; 32 | 33 | describe('AppService', () => { 34 | let service: AppService; 35 | let http: HttpService; 36 | 37 | beforeEach(async () => { 38 | const module = await Test.createTestingModule({ 39 | providers: [ 40 | AppService, 41 | { 42 | provide: HttpService, 43 | useValue: { 44 | get: jest.fn(), 45 | }, 46 | }, 47 | ], 48 | }).compile(); 49 | service = module.get(AppService); 50 | http = module.get(HttpService); 51 | }); 52 | 53 | afterEach(() => { 54 | jest.clearAllMocks(); 55 | }); 56 | 57 | describe('successful call', () => { 58 | it('should not retry the call at all', (done) => { 59 | const getSpy = jest.spyOn(http, 'get').mockReturnValue( 60 | mockRetryFunction(0, failure, success), 61 | ); 62 | service.getGoogleBad().subscribe({ 63 | next: val => { 64 | expect(val).toBe(success.data); 65 | }, 66 | complete: () => { 67 | expect(getSpy).toBeCalledTimes(1); 68 | done(); 69 | }, 70 | }); 71 | }); 72 | }); 73 | describe('unsuccessful call', () => { 74 | it('should retry the call two times and then succeed', (done) => { 75 | const getSpy = jest.spyOn(http, 'get'); 76 | getSpy 77 | .mockReturnValue(mockRetryFunction(2, failure, success)); 78 | service.getGoogleBad().subscribe({ 79 | next: val => { 80 | expect(val).toBe(success.data); 81 | }, 82 | complete: () => { 83 | expect(getSpy).toBeCalledTimes(1); 84 | done(); 85 | }, 86 | }); 87 | }); 88 | it('should fail too many times', (done) => { 89 | const getSpy = jest.spyOn(http, 'get'); 90 | getSpy 91 | .mockReturnValue(mockRetryFunction(5, failure, success)); 92 | service.getGoogleBad().subscribe({ 93 | next: val => { 94 | expect(val).toBe(`${failure.status} returned from http call`); 95 | }, 96 | complete: () => { 97 | expect(getSpy).toBeCalledTimes(1); 98 | done(); 99 | }, 100 | }); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master 6 | [travis-url]: https://travis-ci.org/nestjs/nest 7 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux 8 | [linux-url]: https://travis-ci.org/nestjs/nest 9 | 10 |

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

11 |

12 |

13 | 14 | --- 15 | 16 |

This repository is archived as I do not plan to update it again. However, the code here should be valid and used as a tool to understand how to create retryable HTTP requests uisng NestJS's HttpService

17 | 18 | --- 19 | 20 | # Retryable-HttpService-NestJS 21 | 22 | ## Description 23 | 24 | By default, if [Axios](https://github.com/axios/axiosgi) receives any HTTP status code that is not in the 200-300 range, it will throw an error and reject the call (via `Promise.reject`). NestJS uses Axios as the underlying HttpService for the HttpModule, which can be injected into any service class, but wraps the response in an Observable. With the response being an RxJS Observable, a lot of really cool things can happen, including response mapping using `map`, internal error handling with `catchError` and even spying on the response with `tap`. However, this all only happen is Axios `resolves` the promise instead of rejecting it (i.e. if the return code is 2xx). This is a problem for any sort retrying you may want to try to do, so here's how it can be fixed. 25 | 26 | ## Configuration 27 | 28 | Either in your `HttpModule` import in in the `HttpService` call, you can pass configuration options to Axios for it to know how to react. I decided to do this at the service level, but it is possible to do in the module's import using `HttpModule.register()` or `HttpModule.registerAsync()`. Looking at the Axios config options, there is one calls `validateStatus` which is usually a function that takes in a number and returns a boolean, to determine what to do with the status code. This is where Axios by default determines that a `status >= 200 && status < 300` is an acceptable response and resolves the promise otherwise rejects. We can either add in our own functionality to determine if the status is a 404 then reject, or if it is a 400 resolve, or we can just set the function directly to `null` or `undefined` and let the function always return `true` (according to the Axios config docs). 29 | 30 | Phew, now that we have the `validateStatus` returning true, we always get an observable response, and we can start retrying our Http calls that fail. To do this, we'll need to make use of the `mergeMap` RxJS operator, to determine what kind of operation to take. 31 | 32 | ```ts 33 | @Injectable() 34 | export class MyService { 35 | 36 | constructor(private readonly httpService: HttpService) {} 37 | 38 | getBadHttpCall(): Observable { 39 | return this.httpService.get('https://www.google.com/item/character', { validateStatus: null }).pipe( 40 | mergeMap(val => { 41 | if (val.status >= 400) { 42 | return throwError(`Received status ${val.status} from HTTP call`); 43 | } 44 | return of (val.data); 45 | }), 46 | retry(2), 47 | catchError(err => { 48 | return of(err); 49 | }), 50 | ); 51 | } 52 | } 53 | ``` 54 | 55 | The above class uses `mergeMap` to check the response sent back from the HTTP call, and if the status code is 400 or greater, we decide to throw and error with the RxJs `throwError` function that allows the `retry` function to get called and fire up to the max number of times we decide. If the HTTP call returns a valid response (either a redirect or a success) we return an observable of the data sent back. This allows us to get rid of the type `Observable>` and just have `Observable` which is a little bit more manageable. Lastly, if we do surpass the max number of calls for `retry`, the `catchError` operator will catch the error and return the error thrown as a message to the end client (or the next function in the stack to subscribe to the observable). 56 | 57 | ## Testing 58 | 59 | So, testing this is a bit tricky, as the http function never gets "called" again, but the http request is made several times. After some Google-fu and understanding what's happening (I think), I was able to find an [answer on StackOverflow](https://stackoverflow.com/a/54083350/9576186) that led me to making an `Observer` that could emit whatever I needed it to in the correct order. With this, rather than testing how many times the http function was called, I was able to assert what the final response was, knowing what it should be based on the number of retries. 60 | 61 | ```ts 62 | const mockRetryFunction = (times: number, failureValue: any, successValue: any) => { 63 | let count = 0; 64 | return Observable.create((observer: Observer) => { 65 | if (count++ < times) { 66 | observer.next(failureValue); 67 | } else { 68 | observer.next(successValue); 69 | observer.complete(); 70 | } 71 | }); 72 | }; 73 | ``` 74 | 75 | And here is the magical function. You can save this little guy as a test helper and set the values as you expect in each test class, or just put it in each class and move on, your choice. From here, if you have a `times` of 0, you'll get a success immediately. If you have a `times` equal to your number of `retries` you'll get a `success` and if you have a `times` greater than your `retries` you will get a failure. Pretty nifty little tool to have around :). 76 | 77 | ## Demo 78 | 79 | Steps to run the server and see how it works: 80 | 81 | 1) git clone 82 | 2) npm i or yarn i 83 | 3) npm run start:dev or yarn start:dev (or just start if you don't want hot reloading) 84 | 4) curl http://localhost:3000/fail 85 | 5) watch the output 86 | 1) run the curl multiple times to see the output change, based on the static variable in the controller 87 | 88 | ## End Notes 89 | 90 | This is a very basic example of how to be able to retry an http call with NestJS, and many parts of the example should probably have much better checking and error handling. Use this as a means to guide you, but **do not use this in production**. 91 | --------------------------------------------------------------------------------