feat: Добавить кэширование ответов GET с использованием ETag для повышения производительности и улучшения обработки запросов; обновить обработку ошибок в API для более информативных ответов
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
This commit is contained in:
+18
-15
@@ -518,7 +518,8 @@ app.post('/api/domains', async (req, res) => {
|
||||
if (etag) {
|
||||
const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined);
|
||||
if (current && current.replace(/\"/g, '"') !== String(etag)) {
|
||||
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
|
||||
const meta = await headMeta(FILE_KEY);
|
||||
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -555,11 +556,11 @@ app.get('/api/asns', async (req, res) => {
|
||||
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
// только количество
|
||||
const { total } = await streamPaginatedText({
|
||||
key: 'bgp_data/asns.txt', q, offset: 0, limit: 0,
|
||||
mapLine: (line) => ({})
|
||||
});
|
||||
const cacheKey = `asns:count:${q}`;
|
||||
const cached = getCountOnlyCache(cacheKey);
|
||||
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
||||
const { total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, mapLine: () => ({}) });
|
||||
setCountOnlyCache(cacheKey, total);
|
||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||
} else if (limit !== undefined) {
|
||||
const { items, total } = await streamPaginatedText({
|
||||
@@ -652,10 +653,11 @@ app.get('/api/domains-new', async (req, res) => {
|
||||
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
const { total } = await streamPaginatedText({
|
||||
key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0,
|
||||
mapLine: (line) => ({})
|
||||
});
|
||||
const cacheKey = `domains-new:count:${q}`;
|
||||
const cached = getCountOnlyCache(cacheKey);
|
||||
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
||||
const { total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, mapLine: () => ({}) });
|
||||
setCountOnlyCache(cacheKey, total);
|
||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||
} else if (limit !== undefined) {
|
||||
const { items, total } = await streamPaginatedText({
|
||||
@@ -748,10 +750,11 @@ app.get('/api/ip-ranges', async (req, res) => {
|
||||
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
const { total } = await streamPaginatedText({
|
||||
key: 'bgp_data/ips.txt', q, offset: 0, limit: 0,
|
||||
mapLine: (line) => ({})
|
||||
});
|
||||
const cacheKey = `ip-ranges:count:${q}`;
|
||||
const cached = getCountOnlyCache(cacheKey);
|
||||
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
||||
const { total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, mapLine: () => ({}) });
|
||||
setCountOnlyCache(cacheKey, total);
|
||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||
} else if (limit !== undefined) {
|
||||
const { items, total } = await streamPaginatedText({
|
||||
@@ -1219,7 +1222,7 @@ app.post('/api/locks/:resource', (req, res) => {
|
||||
const now = Date.now();
|
||||
const existing = locks.get(resource);
|
||||
if (existing && existing.expiresAt > now && existing.owner !== owner) {
|
||||
return res.status(423).json({ message: 'Resource is locked by another user', owner: existing.owner, expiresAt: existing.expiresAt });
|
||||
return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', { owner: existing.owner, expiresAt: existing.expiresAt });
|
||||
}
|
||||
const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000;
|
||||
locks.set(resource, { owner, expiresAt });
|
||||
|
||||
+44
-1
@@ -9,9 +9,42 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Простое кеширование ответов GET по ключу запроса + ETag
|
||||
const responseCache = new Map(); // key -> { etag, data, headers }
|
||||
function buildCacheKey(config) {
|
||||
try {
|
||||
const url = config.baseURL ? new URL(config.url, 'http://x').toString().replace('http://x','') : config.url;
|
||||
const params = config.params || {};
|
||||
const keys = Object.keys(params).sort();
|
||||
const qs = keys.map(k => `${k}=${encodeURIComponent(params[k])}`).join('&');
|
||||
return `${config.method || 'get'} ${url}${qs ? '?' + qs : ''}`;
|
||||
} catch {
|
||||
return `${config.method || 'get'} ${config.url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-ретрай для идемпотентных GET: до 2 попыток с экспоненциальной задержкой
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
try {
|
||||
const method = String(response?.config?.method || 'get').toLowerCase();
|
||||
if (method === 'get') {
|
||||
const key = buildCacheKey(response.config);
|
||||
const etag = response.headers?.etag;
|
||||
if (etag) {
|
||||
responseCache.set(key, { etag, data: response.data, headers: response.headers });
|
||||
}
|
||||
// Если пришёл 304 (на всякий случай, axios обычно не кидает в success 304), подменим данными из кеша
|
||||
if (response.status === 304) {
|
||||
const cached = responseCache.get(key);
|
||||
if (cached) {
|
||||
return { ...response, status: 200, data: cached.data, headers: { ...cached.headers, 'x-from-cache': '1' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config || {};
|
||||
const isGet = String(config.method || 'get').toLowerCase() === 'get';
|
||||
@@ -50,6 +83,16 @@ api.interceptors.response.use(
|
||||
// Request интерсептор: If-Match из etag (если не задан явный заголовок)
|
||||
api.interceptors.request.use((config) => {
|
||||
try {
|
||||
// If-None-Match для GET на базе кеша
|
||||
const method = String(config.method || 'get').toLowerCase();
|
||||
if (method === 'get') {
|
||||
const key = buildCacheKey(config);
|
||||
const cached = responseCache.get(key);
|
||||
if (cached?.etag) {
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers['If-None-Match']) config.headers['If-None-Match'] = String(cached.etag);
|
||||
}
|
||||
}
|
||||
// Если заголовок не указан, но в теле есть etag — пробуем проставить If-Match
|
||||
if (!config.headers?.['If-Match'] && config.data && typeof config.data === 'object' && config.data.etag) {
|
||||
config.headers = config.headers || {};
|
||||
|
||||
Reference in New Issue
Block a user