Skip to content

Commit a1ddb32

Browse files
committed
Addressing PR comments
Signed-off-by: Matthew Whitehead <matthew1001@gmail.com>
1 parent 86aa149 commit a1ddb32

4 files changed

Lines changed: 98 additions & 96 deletions

File tree

README.md

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -122,28 +122,6 @@ The following APIs are not part of the fftokens standard, but are exposed under
122122

123123
- `GET /receipt/:id` - Get receipt for a previous request
124124

125-
## Retry behaviour
126-
127-
Most short-term outages should be handled by the blockchain connector. For example if the blockchain node returns `HTTP 429` due to rate limiting
128-
it is the blockchain connector's responsibility to use appropriate back-off retries to attempt to make the required blockchain call successfully.
129-
130-
There are cases where the token connector may need to perform it's own back-off retry for a blockchain action. For example if the blockchain connector
131-
microservice has crashed and is in the process of restarting just as the token connector is trying to query an NFT token URI to enrich a token event, if
132-
the token connector doesn't perform a retry then the event will be returned without the token URI populated.
133-
134-
The token connector has configurable retry behaviour for all blockchain related calls. By default the connector will perform up to 15 retries with a back-off
135-
interval between each one. The default first retry interval is 100ms and doubles up to a maximum of 10s per retry interval. Retries are only performed where
136-
the error returned from the REST call matches a configurable regular expression retry condition. The default retry condition is `._ECONN._` which ensures
137-
retries take place for common TCP errors such as `ECONNRESET` and `ECONNREFUSED`.
138-
139-
Setting the retry condition to "" disables retries. The configurable retry settings are:
140-
141-
- `RETRY_BACKOFF_FACTOR` (default `2`)
142-
- `RETRY_BACKOFF_LIMIT_MS` (default `10000`)
143-
- `RETRY_BACKOFF_INITIAL_MS` (default `100`)
144-
- `RETRY_CONDITION` (default `.*ECONN.*`)
145-
- `RETRY_MAX_ATTEMPTS` (default `15`)
146-
147125
## Running the service
148126

149127
The easiest way to run this service is as part of a stack created via
@@ -202,3 +180,27 @@ $ npm run lint
202180
# formatting
203181
$ npm run format
204182
```
183+
184+
## Blockchain retry behaviour
185+
186+
Most short-term outages should be handled by the blockchain connector. For example if the blockchain node returns `HTTP 429` due to rate limiting
187+
it is the blockchain connector's responsibility to use appropriate back-off retries to attempt to make the required blockchain call successfully.
188+
189+
There are cases where the token connector may need to perform its own back-off retry for a blockchain action. For example if the blockchain connector
190+
microservice has crashed and is in the process of restarting just as the token connector is trying to query an NFT token URI to enrich a token event, if
191+
the token connector doesn't perform a retry then the event will be returned without the token URI populated.
192+
193+
The token connector has configurable retry behaviour for all blockchain related calls. By default the connector will perform up to 15 retries with a back-off
194+
interval between each one. The default first retry interval is 100ms and doubles up to a maximum of 10s per retry interval. Retries are only performed where
195+
the error returned from the REST call matches a configurable regular expression retry condition. The default retry condition is `.*ECONN.*` which ensures
196+
retries take place for common TCP errors such as `ECONNRESET` and `ECONNREFUSED`.
197+
198+
The configurable retry settings are:
199+
200+
- `RETRY_BACKOFF_FACTOR` (default `2`)
201+
- `RETRY_BACKOFF_LIMIT_MS` (default `10000`)
202+
- `RETRY_BACKOFF_INITIAL_MS` (default `100`)
203+
- `RETRY_CONDITION` (default `.*ECONN.*`)
204+
- `RETRY_MAX_ATTEMPTS` (default `15`)
205+
206+
Setting `RETRY_CONDITION` to `""` disables retries. Setting `RETRY_MAX_ATTEMPTS` to `-1` causes it to retry indefinitely.

src/main.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { EventStreamReply } from './event-stream/event-stream.interfaces';
2525
import { EventStreamService } from './event-stream/event-stream.service';
2626
import { requestIDMiddleware } from './request-context/request-id.middleware';
2727
import { RequestLoggingInterceptor } from './request-logging.interceptor';
28-
import { BlockchainConnectorService } from './tokens/blockchain.service';
28+
import { BlockchainConnectorService, RetryConfiguration } from './tokens/blockchain.service';
2929
import {
3030
TokenApprovalEvent,
3131
TokenBurnEvent,
@@ -84,12 +84,14 @@ async function bootstrap() {
8484
const legacyERC20 = config.get<string>('USE_LEGACY_ERC20_SAMPLE', '').toLowerCase() === 'true';
8585
const legacyERC721 = config.get<string>('USE_LEGACY_ERC721_SAMPLE', '').toLowerCase() === 'true';
8686

87-
// Configuration for retries
88-
const retryBackOffFactor = config.get<number>('RETRY_BACKOFF_FACTOR', 2);
89-
const retryBackOffLimit = config.get<number>('RETRY_BACKOFF_LIMIT_MS', 10000);
90-
const retryBackOffInitial = config.get<number>('RETRY_BACKOFF_INITIAL_MS', 100);
91-
const retryCondition = config.get<string>('RETRY_CONDITION', '.*ECONN.*');
92-
const retriesMax = config.get<number>('RETRY_MAX_ATTEMPTS', 15);
87+
// Configuration for blockchain call retries
88+
const blockchainRetryCfg: RetryConfiguration = {
89+
retryBackOffFactor: config.get<number>('RETRY_BACKOFF_FACTOR', 2),
90+
retryBackOffLimit: config.get<number>('RETRY_BACKOFF_LIMIT_MS', 10000),
91+
retryBackOffInitial: config.get<number>('RETRY_BACKOFF_INITIAL_MS', 100),
92+
retryCondition: config.get<string>('RETRY_CONDITION', '.*ECONN.*'),
93+
retriesMax: config.get<number>('RETRY_MAX_ATTEMPTS', 15),
94+
};
9395

9496
const passthroughHeaders: string[] = [];
9597
for (const h of passthroughHeaderString.split(',')) {
@@ -100,18 +102,7 @@ async function bootstrap() {
100102
app.get(TokensService).configure(ethConnectUrl, topic, factoryAddress);
101103
app
102104
.get(BlockchainConnectorService)
103-
.configure(
104-
ethConnectUrl,
105-
fftmUrl,
106-
username,
107-
password,
108-
passthroughHeaders,
109-
retryBackOffFactor,
110-
retryBackOffLimit,
111-
retryBackOffInitial,
112-
retryCondition,
113-
retriesMax,
114-
);
105+
.configure(ethConnectUrl, fftmUrl, username, password, passthroughHeaders, blockchainRetryCfg);
115106
app.get(AbiMapperService).configure(legacyERC20, legacyERC721);
116107

117108
if (autoInit.toLowerCase() !== 'false') {

src/tokens/blockchain.service.ts

Lines changed: 54 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import { Context } from '../request-context/request-context.decorator';
3030
import { FFRequestIDHeader } from '../request-context/constants';
3131
import { EthConnectAsyncResponse, EthConnectReturn, IAbiMethod } from './tokens.interfaces';
3232

33+
export interface RetryConfiguration {
34+
retryBackOffFactor: number;
35+
retryBackOffLimit: number;
36+
retryBackOffInitial: number;
37+
retryCondition: string;
38+
retriesMax: number;
39+
}
40+
3341
const sendTransactionHeader = 'SendTransaction';
3442
const queryHeader = 'Query';
3543

@@ -43,11 +51,7 @@ export class BlockchainConnectorService {
4351
password: string;
4452
passthroughHeaders: string[];
4553

46-
retryBackOffFactor: number;
47-
retryBackOffLimit: number;
48-
retryBackOffInitial: number;
49-
retryCondition: string;
50-
retriesMax: number;
54+
retryConfiguration: RetryConfiguration;
5155

5256
constructor(public http: HttpService) {}
5357

@@ -57,22 +61,14 @@ export class BlockchainConnectorService {
5761
username: string,
5862
password: string,
5963
passthroughHeaders: string[],
60-
retryBackOffFactor: number,
61-
retryBackOffLimit: number,
62-
retryBackOffInitial: number,
63-
retryCondition: string,
64-
retriesMax: number,
64+
retryConfiguration: RetryConfiguration,
6565
) {
6666
this.baseUrl = baseUrl;
6767
this.fftmUrl = fftmUrl;
6868
this.username = username;
6969
this.password = password;
7070
this.passthroughHeaders = passthroughHeaders;
71-
this.retryBackOffFactor = retryBackOffFactor;
72-
this.retryBackOffLimit = retryBackOffLimit;
73-
this.retryBackOffInitial = retryBackOffInitial;
74-
this.retryCondition = retryCondition;
75-
this.retriesMax = retriesMax;
71+
this.retryConfiguration = retryConfiguration;
7672
}
7773

7874
private requestOptions(ctx: Context): AxiosRequestConfig {
@@ -106,58 +102,65 @@ export class BlockchainConnectorService {
106102

107103
// Check if retry condition matches the err that's been hit
108104
private matchesRetryCondition(err: any): boolean {
109-
return this.retryCondition != '' && err?.toString().match(this.retryCondition) !== null;
105+
return (
106+
this.retryConfiguration.retryCondition != '' &&
107+
err?.toString().match(this.retryConfiguration.retryCondition) !== null
108+
);
110109
}
111110

112111
// Delay by the appropriate amount of time given the iteration the caller is in
113112
private async backoffDelay(iteration: number) {
114113
const delay = Math.min(
115-
this.retryBackOffInitial * Math.pow(this.retryBackOffFactor, iteration),
116-
this.retryBackOffLimit,
114+
this.retryConfiguration.retryBackOffInitial *
115+
Math.pow(this.retryConfiguration.retryBackOffFactor, iteration),
116+
this.retryConfiguration.retryBackOffLimit,
117117
);
118118
await new Promise(resolve => setTimeout(resolve, delay));
119119
}
120120

121-
// Generic helper function that makes an given blockchain function retryable
122-
// by using synchronous, back-off delays for cases where the function returns
121+
// Generic helper function that makes a given blockchain function retryable
122+
// by using synchronous back-off delays for cases where the function returns
123123
// an error which matches the configured retry condition
124124
private async retryableCall<T = any>(
125125
blockchainFunction: () => Promise<AxiosResponse<T>>,
126126
): Promise<AxiosResponse<T>> {
127-
let response: any;
128-
for (let retries = 0; retries <= this.retriesMax; retries++) {
127+
let retries = 0;
128+
for (
129+
;
130+
this.retryConfiguration.retriesMax == -1 || retries <= this.retryConfiguration.retriesMax;
131+
this.retryConfiguration.retriesMax == -1 || retries++ // Don't inc 'retries' if 'retriesMax' if set to -1 (infinite retries)
132+
) {
129133
try {
130-
response = await blockchainFunction();
131-
break;
134+
return await blockchainFunction();
132135
} catch (e) {
133136
if (this.matchesRetryCondition(e)) {
134137
this.logger.debug(`Retry condition matched for error ${e}`);
135138
// Wait for a backed-off delay before trying again
136139
await this.backoffDelay(retries);
137140
} else {
138141
// Whatever the error was it's not one we will retry for
139-
break;
142+
throw e;
140143
}
141144
}
142145
}
143146

144-
return response;
147+
throw new InternalServerErrorException(
148+
`Call to blockchain connector failed after ${retries} attempts`,
149+
);
145150
}
146151

147152
async query(ctx: Context, to: string, method?: IAbiMethod, params?: any[]) {
148153
const url = this.baseUrl;
149-
const response = await this.retryableCall<EthConnectReturn>(
150-
async (): Promise<AxiosResponse<EthConnectReturn>> => {
151-
return await this.wrapError(
152-
lastValueFrom(
153-
this.http.post(
154-
url,
155-
{ headers: { type: queryHeader }, to, method, params },
156-
this.requestOptions(ctx),
157-
),
154+
const response = await this.wrapError(
155+
this.retryableCall<EthConnectReturn>(async (): Promise<AxiosResponse<EthConnectReturn>> => {
156+
return await lastValueFrom(
157+
this.http.post(
158+
url,
159+
{ headers: { type: queryHeader }, to, method, params },
160+
this.requestOptions(ctx),
158161
),
159162
);
160-
},
163+
}),
161164
);
162165
return response.data;
163166
}
@@ -172,35 +175,33 @@ export class BlockchainConnectorService {
172175
) {
173176
const url = this.fftmUrl !== undefined && this.fftmUrl !== '' ? this.fftmUrl : this.baseUrl;
174177

175-
const response = await this.retryableCall<EthConnectAsyncResponse>(
176-
async (): Promise<AxiosResponse<EthConnectAsyncResponse>> => {
177-
return await this.wrapError(
178-
lastValueFrom(
178+
const response = await this.wrapError(
179+
this.retryableCall<EthConnectAsyncResponse>(
180+
async (): Promise<AxiosResponse<EthConnectAsyncResponse>> => {
181+
return await lastValueFrom(
179182
this.http.post(
180183
url,
181184
{ headers: { id, type: sendTransactionHeader }, from, to, method, params },
182185
this.requestOptions(ctx),
183186
),
184-
),
185-
);
186-
},
187+
);
188+
},
189+
),
187190
);
188191
return response.data;
189192
}
190193

191194
async getReceipt(ctx: Context, id: string): Promise<EventStreamReply> {
192195
const url = this.baseUrl;
193-
const response = await this.retryableCall<EventStreamReply>(
194-
async (): Promise<AxiosResponse<EventStreamReply>> => {
195-
return await this.wrapError(
196-
lastValueFrom(
197-
this.http.get(new URL(`/reply/${id}`, url).href, {
198-
validateStatus: status => status < 300 || status === 404,
199-
...this.requestOptions(ctx),
200-
}),
201-
),
196+
const response = await this.wrapError(
197+
this.retryableCall<EventStreamReply>(async (): Promise<AxiosResponse<EventStreamReply>> => {
198+
return await lastValueFrom(
199+
this.http.get(new URL(`/reply/${id}`, url).href, {
200+
validateStatus: status => status < 300 || status === 404,
201+
...this.requestOptions(ctx),
202+
}),
202203
);
203-
},
204+
}),
204205
);
205206
if (response.status === 404) {
206207
throw new NotFoundException();

src/tokens/tokens.service.spec.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
import { EventStreamService } from '../event-stream/event-stream.service';
3434
import { EventStreamProxyGateway } from '../eventstream-proxy/eventstream-proxy.gateway';
3535
import { AbiMapperService } from './abimapper.service';
36-
import { BlockchainConnectorService } from './blockchain.service';
36+
import { BlockchainConnectorService, RetryConfiguration } from './blockchain.service';
3737
import {
3838
AsyncResponse,
3939
EthConnectAsyncResponse,
@@ -232,10 +232,18 @@ describe('TokensService', () => {
232232
.useValue(eventstream)
233233
.compile();
234234

235+
let blockchainRetryCfg: RetryConfiguration = {
236+
retryBackOffFactor: 2,
237+
retryBackOffLimit: 10000,
238+
retryBackOffInitial: 100,
239+
retryCondition: '.*ECONN.*',
240+
retriesMax: 15,
241+
};
242+
235243
service = module.get(TokensService);
236244
service.configure(BASE_URL, TOPIC, '');
237245
blockchain = module.get(BlockchainConnectorService);
238-
blockchain.configure(BASE_URL, '', '', '', [], 2, 1000, 250, '.*ECONN.*', 15);
246+
blockchain.configure(BASE_URL, '', '', '', [], blockchainRetryCfg);
239247
});
240248

241249
it('should be defined', () => {

0 commit comments

Comments
 (0)