Skip to content

Commit 86aa149

Browse files
committed
Retry logic for blockchain calls
Signed-off-by: Matthew Whitehead <matthew1001@gmail.com>
1 parent 0b814b7 commit 86aa149

5 files changed

Lines changed: 142 additions & 28 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ are additional methods used by the token connector to guess at the contract ABI
2929
but is the preferred method for most use cases.
3030

3131
To leverage this capability in a running FireFly environment, you must:
32+
3233
1. [Upload the token contract ABI to FireFly](https://hyperledger.github.io/firefly/tutorials/custom_contracts/ethereum.html)
33-
as a contract interface.
34+
as a contract interface.
3435
2. Include the `interface` parameter when [creating the pool on FireFly](https://hyperledger.github.io/firefly/tutorials/tokens).
3536

3637
This will cause FireFly to parse the interface and provide ABI details
@@ -119,7 +120,29 @@ that specific token. If omitted, the approval covers all tokens.
119120

120121
The following APIs are not part of the fftokens standard, but are exposed under `/api/v1`:
121122

122-
* `GET /receipt/:id` - Get receipt for a previous request
123+
- `GET /receipt/:id` - Get receipt for a previous request
124+
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`)
123146

124147
## Running the service
125148

src/main.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ 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);
93+
8794
const passthroughHeaders: string[] = [];
8895
for (const h of passthroughHeaderString.split(',')) {
8996
passthroughHeaders.push(h.toLowerCase());
@@ -93,7 +100,18 @@ async function bootstrap() {
93100
app.get(TokensService).configure(ethConnectUrl, topic, factoryAddress);
94101
app
95102
.get(BlockchainConnectorService)
96-
.configure(ethConnectUrl, fftmUrl, username, password, passthroughHeaders);
103+
.configure(
104+
ethConnectUrl,
105+
fftmUrl,
106+
username,
107+
password,
108+
passthroughHeaders,
109+
retryBackOffFactor,
110+
retryBackOffLimit,
111+
retryBackOffInitial,
112+
retryCondition,
113+
retriesMax,
114+
);
97115
app.get(AbiMapperService).configure(legacyERC20, legacyERC721);
98116

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

src/tokens/blockchain.service.ts

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export class BlockchainConnectorService {
4343
password: string;
4444
passthroughHeaders: string[];
4545

46+
retryBackOffFactor: number;
47+
retryBackOffLimit: number;
48+
retryBackOffInitial: number;
49+
retryCondition: string;
50+
retriesMax: number;
51+
4652
constructor(public http: HttpService) {}
4753

4854
configure(
@@ -51,12 +57,22 @@ export class BlockchainConnectorService {
5157
username: string,
5258
password: string,
5359
passthroughHeaders: string[],
60+
retryBackOffFactor: number,
61+
retryBackOffLimit: number,
62+
retryBackOffInitial: number,
63+
retryCondition: string,
64+
retriesMax: number,
5465
) {
5566
this.baseUrl = baseUrl;
5667
this.fftmUrl = fftmUrl;
5768
this.username = username;
5869
this.password = password;
5970
this.passthroughHeaders = passthroughHeaders;
71+
this.retryBackOffFactor = retryBackOffFactor;
72+
this.retryBackOffLimit = retryBackOffLimit;
73+
this.retryBackOffInitial = retryBackOffInitial;
74+
this.retryCondition = retryCondition;
75+
this.retriesMax = retriesMax;
6076
}
6177

6278
private requestOptions(ctx: Context): AxiosRequestConfig {
@@ -88,15 +104,60 @@ export class BlockchainConnectorService {
88104
});
89105
}
90106

107+
// Check if retry condition matches the err that's been hit
108+
private matchesRetryCondition(err: any): boolean {
109+
return this.retryCondition != '' && err?.toString().match(this.retryCondition) !== null;
110+
}
111+
112+
// Delay by the appropriate amount of time given the iteration the caller is in
113+
private async backoffDelay(iteration: number) {
114+
const delay = Math.min(
115+
this.retryBackOffInitial * Math.pow(this.retryBackOffFactor, iteration),
116+
this.retryBackOffLimit,
117+
);
118+
await new Promise(resolve => setTimeout(resolve, delay));
119+
}
120+
121+
// Generic helper function that makes an given blockchain function retryable
122+
// by using synchronous, back-off delays for cases where the function returns
123+
// an error which matches the configured retry condition
124+
private async retryableCall<T = any>(
125+
blockchainFunction: () => Promise<AxiosResponse<T>>,
126+
): Promise<AxiosResponse<T>> {
127+
let response: any;
128+
for (let retries = 0; retries <= this.retriesMax; retries++) {
129+
try {
130+
response = await blockchainFunction();
131+
break;
132+
} catch (e) {
133+
if (this.matchesRetryCondition(e)) {
134+
this.logger.debug(`Retry condition matched for error ${e}`);
135+
// Wait for a backed-off delay before trying again
136+
await this.backoffDelay(retries);
137+
} else {
138+
// Whatever the error was it's not one we will retry for
139+
break;
140+
}
141+
}
142+
}
143+
144+
return response;
145+
}
146+
91147
async query(ctx: Context, to: string, method?: IAbiMethod, params?: any[]) {
92-
const response = await this.wrapError(
93-
lastValueFrom(
94-
this.http.post<EthConnectReturn>(
95-
this.baseUrl,
96-
{ headers: { type: queryHeader }, to, method, params },
97-
this.requestOptions(ctx),
98-
),
99-
),
148+
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+
),
158+
),
159+
);
160+
},
100161
);
101162
return response.data;
102163
}
@@ -110,26 +171,36 @@ export class BlockchainConnectorService {
110171
params?: any[],
111172
) {
112173
const url = this.fftmUrl !== undefined && this.fftmUrl !== '' ? this.fftmUrl : this.baseUrl;
113-
const response = await this.wrapError(
114-
lastValueFrom(
115-
this.http.post<EthConnectAsyncResponse>(
116-
url,
117-
{ headers: { id, type: sendTransactionHeader }, from, to, method, params },
118-
this.requestOptions(ctx),
119-
),
120-
),
174+
175+
const response = await this.retryableCall<EthConnectAsyncResponse>(
176+
async (): Promise<AxiosResponse<EthConnectAsyncResponse>> => {
177+
return await this.wrapError(
178+
lastValueFrom(
179+
this.http.post(
180+
url,
181+
{ headers: { id, type: sendTransactionHeader }, from, to, method, params },
182+
this.requestOptions(ctx),
183+
),
184+
),
185+
);
186+
},
121187
);
122188
return response.data;
123189
}
124190

125191
async getReceipt(ctx: Context, id: string): Promise<EventStreamReply> {
126-
const response = await this.wrapError(
127-
lastValueFrom(
128-
this.http.get<EventStreamReply>(new URL(`/reply/${id}`, this.baseUrl).href, {
129-
validateStatus: status => status < 300 || status === 404,
130-
...this.requestOptions(ctx),
131-
}),
132-
),
192+
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+
),
202+
);
203+
},
133204
);
134205
if (response.status === 404) {
135206
throw new NotFoundException();

src/tokens/tokens.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('TokensService', () => {
235235
service = module.get(TokensService);
236236
service.configure(BASE_URL, TOPIC, '');
237237
blockchain = module.get(BlockchainConnectorService);
238-
blockchain.configure(BASE_URL, '', '', '', []);
238+
blockchain.configure(BASE_URL, '', '', '', [], 2, 1000, 250, '.*ECONN.*', 15);
239239
});
240240

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

test/app.e2e-context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ export class TestContext {
7171

7272
this.app.get(EventStreamProxyGateway).configure('url', TOPIC);
7373
this.app.get(TokensService).configure(BASE_URL, TOPIC, '');
74-
this.app.get(BlockchainConnectorService).configure(BASE_URL, '', '', '', []);
74+
this.app
75+
.get(BlockchainConnectorService)
76+
.configure(BASE_URL, '', '', '', [], 2, 1000, 250, '.*ECONN.*', 15);
7577

7678
(this.app.getHttpServer() as Server).listen();
7779
this.server = request(this.app.getHttpServer());

0 commit comments

Comments
 (0)