Skip to content

Commit 5b75060

Browse files
authored
feat: throttle requests to rate limited domains (#97)
* feat: first draft * feat: draft 2 * feat: validate host status before fetch * test: more headers * chore: test progress * fix: use timestamptz for time columns * test: call resume * docs: add readme docs * fix: set default 1 hour for retry * build: remove unsafe-perm from Dockerfile * chore: upgrade undici * fix: multiple header values
1 parent 282d9c3 commit 5b75060

15 files changed

+371
-37
lines changed

Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ WORKDIR /app
44
COPY . .
55

66
RUN apk add --no-cache --virtual .build-deps git
7-
RUN npm config set unsafe-perm true && \
8-
npm ci && \
7+
RUN npm ci && \
98
npm run build && \
109
npm run generate:git-info && \
1110
npm prune --production

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ The
147147
component constantly listens for the following Stacks Blockchain API events:
148148

149149
* **Smart contract log events**
150-
150+
151151
If a contract `print` event conforms to SIP-019, it finds the affected tokens and marks them for
152152
metadata refresh.
153153

@@ -192,3 +192,7 @@ metadata ingestion.
192192
This job fetches the metadata JSON object for a single token as well as other relevant properties
193193
depending on the token type (symbol, decimals, etc.). Once fetched, it parses and ingests this data
194194
to save it into the local database for API endpoints to return.
195+
196+
If a `429` (Too Many Requests) status code is returned by a hostname used to fetch metadata, the
197+
service will cease all further requests to it until a reasonable amount of time has passed or until
198+
the time specified by the host in a `Retry-After` response header.

migrations/1670264425574_smart-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ export function up(pgm: MigrationBuilder): void {
3434
type: 'numeric',
3535
},
3636
created_at: {
37-
type: 'timestamp',
37+
type: 'timestamptz',
3838
default: pgm.func('(NOW())'),
3939
notNull: true,
4040
},
4141
updated_at: {
42-
type: 'timestamp',
42+
type: 'timestamptz',
4343
},
4444
});
4545
pgm.createConstraint('smart_contracts', 'smart_contracts_principal_unique', 'UNIQUE(principal)');

migrations/1670265062169_tokens.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ export function up(pgm: MigrationBuilder): void {
4747
type: 'numeric',
4848
},
4949
created_at: {
50-
type: 'timestamp',
50+
type: 'timestamptz',
5151
default: pgm.func('(NOW())'),
5252
notNull: true,
5353
},
5454
updated_at: {
55-
type: 'timestamp',
55+
type: 'timestamptz',
5656
},
5757
});
5858
pgm.createConstraint(

migrations/1670266371036_jobs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ export function up(pgm: MigrationBuilder): void {
2525
default: 0,
2626
},
2727
created_at: {
28-
type: 'timestamp',
28+
type: 'timestamptz',
2929
default: pgm.func('(NOW())'),
3030
notNull: true,
3131
},
3232
updated_at: {
33-
type: 'timestamp',
33+
type: 'timestamptz',
3434
},
3535
});
3636
pgm.createConstraint('jobs', 'jobs_token_id_fk', 'FOREIGN KEY(token_id) REFERENCES tokens(id)');
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate';
3+
4+
export const shorthands: ColumnDefinitions | undefined = undefined;
5+
6+
export function up(pgm: MigrationBuilder): void {
7+
pgm.createTable('rate_limited_hosts', {
8+
id: {
9+
type: 'serial',
10+
primaryKey: true,
11+
},
12+
hostname: {
13+
type: 'text',
14+
notNull: true,
15+
},
16+
created_at: {
17+
type: 'timestamptz',
18+
default: pgm.func('(NOW())'),
19+
notNull: true,
20+
},
21+
retry_after: {
22+
type: 'timestamptz',
23+
notNull: true,
24+
},
25+
});
26+
pgm.createConstraint(
27+
'rate_limited_hosts',
28+
'rate_limited_hosts_hostname_unique',
29+
'UNIQUE(hostname)'
30+
);
31+
pgm.createIndex('rate_limited_hosts', ['hostname']);
32+
}

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/env.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ interface Env {
6767
* hours).
6868
*/
6969
METADATA_DYNAMIC_TOKEN_REFRESH_INTERVAL: number;
70+
/**
71+
* Time that must elapse between a 429 'Too many requests' response returned by a hostname and the
72+
* next request that is sent to it (seconds). This value will be overridden by the `Retry-After`
73+
* header returned by the domain, if any.
74+
*/
75+
METADATA_RATE_LIMITED_HOST_RETRY_AFTER: number;
7076

7177
/** Whether or not the `JobQueue` will continue to try retryable failed jobs indefinitely. */
7278
JOB_QUEUE_STRICT_MODE: boolean;
@@ -225,6 +231,10 @@ export function getEnvVars(): Env {
225231
type: 'number',
226232
default: 86_400, // 24 hours
227233
},
234+
METADATA_RATE_LIMITED_HOST_RETRY_AFTER: {
235+
type: 'number',
236+
default: 3600, // 1 hour
237+
},
228238
JOB_QUEUE_CONCURRENCY_LIMIT: {
229239
type: 'number',
230240
default: 5,

src/pg/pg-store.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {
2121
METADATA_COLUMNS,
2222
METADATA_ATTRIBUTES_COLUMNS,
2323
METADATA_PROPERTIES_COLUMNS,
24+
DbRateLimitedHostInsert,
25+
DbRateLimitedHost,
26+
RATE_LIMITED_HOSTS_COLUMNS,
2427
} from './types';
2528
import { connectPostgres } from './postgres-tools';
2629
import { BasePgStore } from './postgres-tools/base-pg-store';
@@ -393,7 +396,38 @@ export class PgStore extends BasePgStore {
393396
INSERT INTO jobs (token_id) (SELECT id AS token_id FROM token_inserts)
394397
ON CONFLICT (token_id) WHERE smart_contract_id IS NULL DO
395398
UPDATE SET updated_at = NOW(), status = 'pending'
396-
RETURNING *
399+
RETURNING ${this.sql(JOBS_COLUMNS)}
400+
`;
401+
}
402+
403+
async insertRateLimitedHost(args: {
404+
values: DbRateLimitedHostInsert;
405+
}): Promise<DbRateLimitedHost> {
406+
const retryAfter = args.values.retry_after.toString();
407+
const results = await this.sql<DbRateLimitedHost[]>`
408+
INSERT INTO rate_limited_hosts (hostname, created_at, retry_after)
409+
VALUES (${args.values.hostname}, DEFAULT, NOW() + INTERVAL '${this.sql(retryAfter)} seconds')
410+
ON CONFLICT ON CONSTRAINT rate_limited_hosts_hostname_unique DO
411+
UPDATE SET retry_after = EXCLUDED.retry_after
412+
RETURNING ${this.sql(RATE_LIMITED_HOSTS_COLUMNS)}
413+
`;
414+
return results[0];
415+
}
416+
417+
async getRateLimitedHost(args: { hostname: string }): Promise<DbRateLimitedHost | undefined> {
418+
const results = await this.sql<DbRateLimitedHost[]>`
419+
SELECT ${this.sql(RATE_LIMITED_HOSTS_COLUMNS)}
420+
FROM rate_limited_hosts
421+
WHERE hostname = ${args.hostname}
422+
`;
423+
if (results.count > 0) {
424+
return results[0];
425+
}
426+
}
427+
428+
async deleteRateLimitedHost(args: { hostname: string }): Promise<void> {
429+
await this.sql`
430+
DELETE FROM rate_limited_hosts WHERE hostname = ${args.hostname}
397431
`;
398432
}
399433

src/pg/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ export type DbJob = {
8585
updated_at?: string;
8686
};
8787

88+
export type DbRateLimitedHostInsert = {
89+
hostname: string;
90+
// Will be converted into a timestamp upon insert.
91+
retry_after: number;
92+
};
93+
94+
export type DbRateLimitedHost = {
95+
id: number;
96+
hostname: string;
97+
created_at: string;
98+
retry_after: string;
99+
};
100+
88101
export type DbFtInsert = {
89102
name: string | null;
90103
symbol: string | null;
@@ -238,3 +251,5 @@ export const METADATA_ATTRIBUTES_COLUMNS = [
238251
];
239252

240253
export const METADATA_PROPERTIES_COLUMNS = ['id', 'metadata_id', 'name', 'value'];
254+
255+
export const RATE_LIMITED_HOSTS_COLUMNS = ['id', 'hostname', 'created_at', 'retry_after'];

0 commit comments

Comments
 (0)