Skip to content

Commit 8c7390e

Browse files
committed
parse separate headers and various reset formats
1 parent 47b2e5f commit 8c7390e

File tree

3 files changed

+149
-32
lines changed

3 files changed

+149
-32
lines changed

package.json

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,29 @@
33
"version": "0.1.0",
44
"description": "Parse RateLimit headers of various forms, including the combined form from draft 7 of the IETF standard, into a normalized format.",
55
"type": "module",
6-
"exports": {
7-
".": {
8-
"import": {
9-
"types": "dist/ratelimit-header-parser.d.mts",
10-
"default": "dist/ratelimit-header-parser.mjs"
11-
},
12-
"require": {
13-
"types": "dist/ratelimit-header-parser.d.cts",
14-
"default": "dist/ratelimit-header-parser.cjs"
15-
}
16-
}
17-
},
18-
"main": "dist/ratelimit-header-parser.cjs",
19-
"module": "dist/ratelimit-header-parser.mjs",
20-
"types": "dist/ratelimit-header-parser.d.ts",
6+
"exports": {
7+
".": {
8+
"import": {
9+
"types": "./dist/index.d.mts",
10+
"default": "./dist/index.mjs"
11+
},
12+
"require": {
13+
"types": "./dist/index.d.cts",
14+
"default": "./dist/index.cjs"
15+
}
16+
}
17+
},
18+
"main": "./dist/index.cjs",
19+
"module": "./dist/index.mjs",
20+
"types": "./dist/index.d.ts",
21+
"files": [
22+
"dist/",
23+
"tsconfig.json",
24+
"package.json",
25+
"readme.md",
26+
"license.md",
27+
"changelog.md"
28+
],
2129
"scripts": {
2230
"test": "jest"
2331
},

source/ratelimit-header-parser.ts

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,72 @@ export type RateLimit = {
88
// todo: policy
99
}
1010

11-
type ResponseOrHeadersObject =
12-
/* Node.js things */
13-
ServerResponse | IncomingHttpHeaders | OutgoingHttpHeaders |
14-
/* fetch things */
15-
Response | Headers;
11+
export type RateLimitOptions = {
12+
reset?: 'date' | 'unix' | 'seconds' | 'milliseconds',
13+
}
14+
15+
// node or fetch
16+
type ResponseObject = ServerResponse | Response;
17+
type HeadersObject = IncomingHttpHeaders | OutgoingHttpHeaders | Headers | Object;
1618

17-
export function parseRateLimitHeaders(input: ResponseOrHeadersObject): RateLimit | undefined {
19+
export function parseRateLimit(input: ResponseObject | HeadersObject, options?: RateLimitOptions): RateLimit | undefined {
1820
if ('headers' in input && typeof input.headers === 'object' && !Array.isArray(input.headers)) {
19-
return parseRateLimitHeaders(input.headers)
21+
return parseRateLimit(input.headers, options)
2022
} else if ('getHeaders' in input && typeof input.getHeaders === 'function') {
21-
return parseRateLimitHeaders(input.getHeaders())
23+
return parseRateLimit(input.getHeaders(), options)
24+
} else {
25+
return parseHeadersObject(input, options)
26+
}
27+
}
28+
29+
function parseHeadersObject(input: HeadersObject, options: RateLimitOptions | undefined): RateLimit | undefined {
30+
let combined = getHeader(input, 'ratelimit')
31+
if (combined ) return parseCombinedRateLimitHeader(combined);
32+
33+
let prefix;
34+
if (getHeader(input, 'ratelimit-remaining')) {
35+
prefix = 'ratelimit-'
36+
} else if (getHeader(input, 'x-ratelimit-remaining')) {
37+
prefix = 'x-ratelimit-'
38+
} else {
39+
// todo: handle vendor-specific headers here - see https://github.com/ietf-wg-httpapi/ratelimit-headers/issues/25
40+
return;
2241
}
23-
if (input['ratelimit']) {
24-
return parseCombinedRateLimitHeader(input['ratelimit'])
42+
43+
const limit = num(getHeader(input, `${prefix}limit`))
44+
const remaining = num(getHeader(input, `${prefix}remaining`))
45+
46+
let reset: Date|undefined = undefined;
47+
const resetRaw = getHeader(input, `${prefix}reset`)
48+
const resetType = options?.reset;
49+
if (resetType == 'date') reset = parseResetDate(resetRaw ?? '');
50+
else if (resetType == 'unix') reset = parseResetUnix(resetRaw ?? '');
51+
else if (resetType == 'seconds') reset = parseResetSeconds(resetRaw ?? '');
52+
else if (resetType == 'milliseconds') reset = parseResetMilliseconds(resetRaw ?? '');
53+
else if (resetRaw) reset = parseResetAuto(resetRaw)
54+
else {
55+
// fallback to retry-after
56+
const retryAfter = getHeader(input, 'retry-after');
57+
if (retryAfter) {
58+
reset = parseResetUnix(retryAfter)
59+
}
60+
}
61+
62+
return {
63+
limit,
64+
current: limit - remaining,
65+
remaining,
66+
reset
2567
}
26-
// todo: handle individual headers
2768
}
2869

2970
const reLimit = /limit\s*=\s*(\d+)/i
3071
const reRemaining = /remaining\s*=\s*(\d+)/i
3172
const reReset = /reset\s*=\s*(\d+)/i
3273
export function parseCombinedRateLimitHeader(input: string): RateLimit {
33-
const limit = parseInt(reLimit.exec(input)?.[1] ?? '-1', 10);
34-
const remaining = parseInt(reRemaining.exec(input)?.[1] ?? '-1', 10);
35-
const resetSeconds = reReset.exec(input)?.[1] ?? '-1';
74+
const limit = num(reLimit.exec(input)?.[1]);
75+
const remaining = num(reRemaining.exec(input)?.[1]);
76+
const resetSeconds = num(reReset.exec(input)?.[1]);
3677
const reset = secondsToDate(resetSeconds);
3778
return {
3879
limit,
@@ -42,8 +83,55 @@ export function parseCombinedRateLimitHeader(input: string): RateLimit {
4283
}
4384
}
4485

45-
function secondsToDate(seconds: string | number): Date {
86+
function secondsToDate(seconds: number): Date {
4687
const d = new Date();
47-
d.setSeconds(d.getSeconds() + (+seconds));
88+
d.setSeconds(d.getSeconds() + seconds);
4889
return d;
90+
}
91+
92+
function num(input: string | number | undefined): number {
93+
if (typeof input == 'number') return input;
94+
return parseInt(input ?? '', 10);
95+
}
96+
97+
function getHeader(headers: HeadersObject, name: string): string | undefined {
98+
if ('get' in headers && typeof headers.get === 'function') {
99+
return headers.get(name) ?? undefined // returns null if missing, but everything else is undefined for missing values
100+
}
101+
return headers[name]
102+
}
103+
104+
function parseResetDate(resetRaw: string): Date {
105+
// todo: take the server's date into account, calculate an offset, then apply that to the current date
106+
return new Date(resetRaw);
107+
}
108+
109+
function parseResetUnix(resetRaw: string | number): Date {
110+
let resetNum = num(resetRaw);
111+
return new Date(resetNum * 1000);
112+
}
113+
114+
function parseResetSeconds(resetRaw: string | number): Date {
115+
let resetNum = num(resetRaw);
116+
return secondsToDate(resetNum);
117+
}
118+
119+
function parseResetMilliseconds(resetRaw: string | number): Date {
120+
let resetNum = num(resetRaw);
121+
return secondsToDate(resetNum/1000);
122+
}
123+
124+
const reLetters = /[a-z]/i;
125+
function parseResetAuto(resetRaw: string): Date {
126+
// if it has any letters, assume it's a date string
127+
if (resetRaw.match(reLetters)) {
128+
return parseResetDate(resetRaw)
129+
}
130+
const resetNum = num(resetRaw);
131+
// looks like a unix timestamp
132+
if (resetNum && resetNum > 1000000000) { // sometime in 2001
133+
return parseResetUnix(resetNum)
134+
}
135+
// could be seconds or milliseconds (or something else!), defaulting to seconds
136+
return parseResetSeconds(resetNum);
49137
}

test/parser-test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
import { describe, it, expect } from '@jest/globals'
2-
import { parseCombinedRateLimitHeader } from '../source/ratelimit-header-parser'
2+
import { parseCombinedRateLimitHeader, parseRateLimit } from '../source/ratelimit-header-parser'
3+
4+
describe('parseRateLimitHeaders', () => {
5+
it ('should handle X-RateLimit-* headers in a fetch Headers object', () => {
6+
const headers = new Headers({
7+
'X-RateLimit-Limit': '100',
8+
'X-RateLimit-Remaining': '70',
9+
'X-RateLimit-Reset': Math.floor(Date.now() / 1000).toString()
10+
})
11+
expect(parseRateLimit(headers)).toMatchObject({
12+
limit: 100,
13+
remaining: 70,
14+
current: 30,
15+
reset: expect.any(Date) // todo: mock the clock, then match to a specific date
16+
})
17+
})
18+
19+
// todo: test with other object types
20+
// todo: test with various options
21+
})
22+
23+
// todo: test parseResetAuto with various formats
324

425
describe('parseCombinedRateLimitHeader', () => {
526
it('should parse a combined header', () => {

0 commit comments

Comments
 (0)