ky
Version:
Tiny and elegant HTTP client based on the Fetch API
130 lines • 6.11 kB
JavaScript
// Older epoch-second values are indistinguishable from ordinary delay seconds, so timestamp compatibility only applies to current-era reset headers.
const timestampThreshold = Date.parse('2024-01-01');
const delayPattern = /^\d+$/;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// HTTP-date parsing is limited to IMF-fixdate and the two obsolete HTTP-date formats. Other date-like strings intentionally fall back to the normal retry delay.
const imfDatePattern = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
const rfc850DatePattern = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
const asctimeDatePattern = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{2}| \d) (\d{2}:\d{2}:\d{2}) (\d{4})$/;
export const getRetryTimingHeader = (headers) => {
const retryAfter = headers.get('Retry-After');
if (retryAfter !== null) {
return { value: retryAfter, allowTimestamp: false };
}
// Retry-After is authoritative when present. Only missing Retry-After falls through to rate-limit headers.
const rateLimitReset = headers.get('RateLimit-Reset');
if (rateLimitReset !== null) {
return { value: rateLimitReset, allowTimestamp: true };
}
const rateLimitRetryAfter = headers.get('X-RateLimit-Retry-After'); // Symfony-based services
if (rateLimitRetryAfter !== null) {
return { value: rateLimitRetryAfter, allowTimestamp: false };
}
const rateLimitResetAlias = headers.get('X-RateLimit-Reset') // GitHub
?? headers.get('X-Rate-Limit-Reset'); // Twitter
if (rateLimitResetAlias !== null) {
return { value: rateLimitResetAlias, allowTimestamp: true };
}
return undefined;
};
const createTimestamp = ({ year, month, day, hours, minutes, seconds }) => {
const monthIndex = months.indexOf(month);
const dayNumber = Number(day);
const hoursNumber = Number(hours);
const minutesNumber = Number(minutes);
const secondsNumber = Number(seconds);
if (monthIndex === -1
|| hoursNumber > 23
|| minutesNumber > 59
|| secondsNumber > 60) {
return undefined;
}
const normalizedSeconds = Math.min(secondsNumber, 59);
const date = new Date(Date.UTC(year, monthIndex, dayNumber, hoursNumber, minutesNumber, normalizedSeconds));
date.setUTCFullYear(year);
const timestamp = date.getTime();
// Date.UTC normalizes overflow, so round-trip the parts to reject invalid dates. A leap second is represented by parsing 60 as 59 and adding one second.
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== monthIndex
|| date.getUTCDate() !== dayNumber
|| date.getUTCHours() !== hoursNumber
|| date.getUTCMinutes() !== minutesNumber
|| date.getUTCSeconds() !== normalizedSeconds) {
return undefined;
}
return secondsNumber === 60 ? timestamp + 1000 : timestamp;
};
const getCapture = (match, index) => match[index];
const parseDate = (value) => {
const imfDate = imfDatePattern.exec(value);
if (imfDate) {
return createTimestamp({
year: Number(getCapture(imfDate, 3)),
month: getCapture(imfDate, 2),
day: getCapture(imfDate, 1),
hours: getCapture(imfDate, 4),
minutes: getCapture(imfDate, 5),
seconds: getCapture(imfDate, 6),
});
}
const rfc850Date = rfc850DatePattern.exec(value);
if (rfc850Date) {
const now = new Date();
const twoDigitYear = Number(getCapture(rfc850Date, 3));
const currentCenturyYear = (Math.floor(now.getUTCFullYear() / 100) * 100) + twoDigitYear;
const fiftyYearsFromNow = Date.UTC(now.getUTCFullYear() + 50, now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds(), now.getUTCMilliseconds());
let timestamp;
// RFC 9110 maps two-digit years to the latest matching century that is not more than 50 years in the future.
for (const year of [
currentCenturyYear - 100,
currentCenturyYear,
currentCenturyYear + 100,
]) {
const candidateTimestamp = createTimestamp({
year,
month: getCapture(rfc850Date, 2),
day: getCapture(rfc850Date, 1),
hours: getCapture(rfc850Date, 4),
minutes: getCapture(rfc850Date, 5),
seconds: getCapture(rfc850Date, 6),
});
if (candidateTimestamp !== undefined
&& candidateTimestamp <= fiftyYearsFromNow) {
timestamp = candidateTimestamp;
}
}
return timestamp;
}
const asctimeDate = asctimeDatePattern.exec(value);
if (asctimeDate) {
const time = getCapture(asctimeDate, 4);
const [hours, minutes, seconds] = time.split(':');
return createTimestamp({
year: Number(getCapture(asctimeDate, 5)),
month: getCapture(asctimeDate, 2),
day: getCapture(asctimeDate, 3).trim(),
hours,
minutes,
seconds,
});
}
return undefined;
};
export const calculateRetryTimingDelay = ({ value, allowTimestamp }) => {
if (delayPattern.test(value)) {
let delay = Number(value) * 1000;
// Standard Retry-After numbers are delay seconds. Timestamp compatibility only applies to reset headers.
if (allowTimestamp
&& delay >= timestampThreshold) {
delay -= Date.now();
}
return Math.max(0, delay);
}
const timestamp = parseDate(value);
if (timestamp === undefined) {
return undefined;
}
const delay = timestamp - Date.now();
return Number.isFinite(delay) ? Math.max(0, delay) : undefined;
};
//# sourceMappingURL=retry-timing.js.map