feat: enhance user-agent identification and implement backoff strategy for university source requests
- Added PSU_CONTACT environment variable to docker-compose for owner contact in User-Agent. - Updated source.js to include user-agent construction with service version and contact information. - Implemented exponential backoff strategy for failed requests to the university, with retry logic based on HTTP response status. - Modified status.js to display backoff information in the status response. - Adjusted watch.js to reduce request frequency during university downtime. - Enhanced test coverage with new backoff.test.js to validate backoff behavior and user-agent format.
This commit is contained in:
@@ -52,6 +52,8 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
|
|||||||
|
|
||||||
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле `catalog.lastCrawl`) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` там же — время и ошибка последней проверки.
|
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле `catalog.lastCrawl`) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` там же — время и ошибка последней проверки.
|
||||||
|
|
||||||
|
**Вежливость к университету.** Каждый запрос к Nextcloud представляется: `User-Agent: PSU-Schedule/0.1.0 (+<PSU_PUBLIC_URL>; <PSU_CONTACT>)`. Например, `PSU_CONTACT=mailto:you@example.com` — тогда администраторы университета видят, чей это сервис и кому написать, а не безымянного робота. Когда университет перестаёт отвечать (нет соединения, `502`–`504`, `429`, долго не отвечает листинг папки), сервер делает паузу: до её конца к университету не уходит ни один запрос — ни от наблюдателя, ни от «Обновить» в клиентах, ни при открытии документа. Студенты получают сохранённую копию. Пауза удваивается после каждой неудачной попытки: 5, 10, 20, 40 минут, дальше раз в час (`PSU_SOURCE_BACKOFF_MS`, `PSU_SOURCE_BACKOFF_MAX_MS`), и учитывает `Retry-After`. Первый же ответ её снимает. Ошибка одного документа (`404`, `500`) и медленная загрузка файла паузу не включают. На дашборде у плитки «Университет» видно время следующей попытки, в `/status/data.json` — `catalog.backoff`.
|
||||||
|
|
||||||
**Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней:
|
**Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней:
|
||||||
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
|
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
|
||||||
- **запросы за последний час** — ключевые числа, график по минутам (с ответом, `304`, ошибки), адреса API со средним временем и самые активные клиенты;
|
- **запросы за последний час** — ключевые числа, график по минутам (с ответом, `304`, ошибки), адреса API со средним временем и самые активные клиенты;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,9 @@ services:
|
|||||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD:-}
|
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD:-}
|
||||||
# Публичный адрес для ссылок в сообщениях, например https://psu.rumblur.by
|
# Публичный адрес для ссылок в сообщениях, например https://psu.rumblur.by
|
||||||
PSU_PUBLIC_URL: ${PSU_PUBLIC_URL:-}
|
PSU_PUBLIC_URL: ${PSU_PUBLIC_URL:-}
|
||||||
|
# Контакт владельца в User-Agent запросов к университету (почта или ссылка),
|
||||||
|
# чтобы администраторы Nextcloud знали, кому написать: mailto:you@example.com
|
||||||
|
PSU_CONTACT: ${PSU_CONTACT:-}
|
||||||
# Путь к LibreOffice для импорта старых .doc уже есть в образе.
|
# Путь к LibreOffice для импорта старых .doc уже есть в образе.
|
||||||
volumes:
|
volumes:
|
||||||
# Кэш каталога, таблицы недель и распознанных документов.
|
# Кэш каталога, таблицы недель и распознанных документов.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { parseWeeksDocx, parseBellsDocx } from "./calendar.js";
|
import { parseWeeksDocx, parseBellsDocx } from "./calendar.js";
|
||||||
import { apiError, sourceError } from "./errors.js";
|
import { apiError, sourceError } from "./errors.js";
|
||||||
@@ -43,6 +44,39 @@ const backingOff = (what) => Date.now() - failedAt[what] < MIN_REFRESH;
|
|||||||
|
|
||||||
const TIMEOUT = Number(process.env.PSU_SOURCE_TIMEOUT_MS) || 25000;
|
const TIMEOUT = Number(process.env.PSU_SOURCE_TIMEOUT_MS) || 25000;
|
||||||
const MAX_SIZE = 10 * 1024 * 1024;
|
const MAX_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Every request says who asks and how to reach the owner, so the university's administrators
|
||||||
|
// see a known reader rather than an anonymous robot: "PSU-Schedule/0.1.0 (+https://…; mailto:…)".
|
||||||
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
||||||
|
const contact = [process.env.PSU_PUBLIC_URL && `+${process.env.PSU_PUBLIC_URL}`, process.env.PSU_CONTACT].filter(Boolean);
|
||||||
|
export const USER_AGENT = `PSU-Schedule/${VERSION}${contact.length ? ` (${contact.join("; ")})` : ""}`;
|
||||||
|
|
||||||
|
// While the university is down, nothing reaches it until the pause is over: the watcher, a
|
||||||
|
// forced refresh and a document opened by a student all get the saved copy or an error at
|
||||||
|
// once. The pause doubles with every failed attempt (5, 10, 20, 40 minutes…) up to an hour and
|
||||||
|
// ends with the first answer. A failing document alone (HTTP 404, 500) or a slow download does
|
||||||
|
// not pause the rest; the server's whole answering does: no connection, 502–504, 429.
|
||||||
|
const BACKOFF = process.env.PSU_SOURCE_BACKOFF_MS ? Number(process.env.PSU_SOURCE_BACKOFF_MS) : 5 * 60 * 1000;
|
||||||
|
const BACKOFF_MAX = Number(process.env.PSU_SOURCE_BACKOFF_MAX_MS) || 60 * 60 * 1000;
|
||||||
|
const outage = { failures: 0, until: 0, error: null };
|
||||||
|
const DOWN = new Set([429, 502, 503, 504]);
|
||||||
|
/** Seconds or a date in Retry-After, as milliseconds from now; 0 without one. */
|
||||||
|
const retryAfter = (value) => {
|
||||||
|
if (!value) return 0;
|
||||||
|
const ms = /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - Date.now();
|
||||||
|
return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), 6 * BACKOFF_MAX) : 0;
|
||||||
|
};
|
||||||
|
function sourceDown(error, wait = 0) {
|
||||||
|
outage.failures++;
|
||||||
|
outage.error = error;
|
||||||
|
if (BACKOFF) outage.until = Date.now() + Math.max(Math.min(BACKOFF * 2 ** (outage.failures - 1), BACKOFF_MAX), wait);
|
||||||
|
}
|
||||||
|
/** The pause after failed attempts, for /status: null while the university answers. */
|
||||||
|
export const sourceBackoff = () =>
|
||||||
|
outage.failures
|
||||||
|
? { failures: outage.failures, retryAt: outage.until > Date.now() ? new Date(outage.until).toISOString() : null }
|
||||||
|
: null;
|
||||||
|
|
||||||
export async function request(path, method = "GET") {
|
export async function request(path, method = "GET") {
|
||||||
// Only the university share is reachable; listings and catalog entries supply the paths.
|
// Only the university share is reachable; listings and catalog entries supply the paths.
|
||||||
if (
|
if (
|
||||||
@@ -52,16 +86,33 @@ export async function request(path, method = "GET") {
|
|||||||
!["GET", "PROPFIND"].includes(method)
|
!["GET", "PROPFIND"].includes(method)
|
||||||
)
|
)
|
||||||
throw new Error("Недопустимый путь документа.");
|
throw new Error("Недопустимый путь документа.");
|
||||||
|
if (Date.now() < outage.until) throw sourceError(outage.error);
|
||||||
// Node's fetch keeps connections to the university open between requests, so a catalog
|
// Node's fetch keeps connections to the university open between requests, so a catalog
|
||||||
// crawl of a hundred listings pays for one TLS handshake, not a hundred. Certificates are
|
// crawl of a hundred listings pays for one TLS handshake, not a hundred. Certificates are
|
||||||
// checked, redirects are refused, and the answer is cut off past 10 MB or 25 seconds.
|
// checked, redirects are refused, and the answer is cut off past 10 MB or 25 seconds.
|
||||||
try {
|
try {
|
||||||
const response = await fetch(ORIGIN + path, {
|
let response;
|
||||||
method,
|
try {
|
||||||
headers: method === "PROPFIND" ? { Depth: "1" } : {},
|
response = await fetch(ORIGIN + path, {
|
||||||
redirect: "manual",
|
method,
|
||||||
signal: AbortSignal.timeout(TIMEOUT),
|
headers: { "User-Agent": USER_AGENT, ...(method === "PROPFIND" && { Depth: "1" }) },
|
||||||
});
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// No connection at all, or a listing (small and quick when all is well) timed out.
|
||||||
|
if (error?.name !== "TimeoutError" || method === "PROPFIND") sourceDown(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (DOWN.has(response.status)) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
const error = new Error(`HTTP ${response.status}`);
|
||||||
|
sourceDown(error, retryAfter(response.headers.get("retry-after")));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Any other answer, a missing or broken document included: the university is back.
|
||||||
|
outage.failures = 0;
|
||||||
|
outage.until = 0;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -478,6 +529,8 @@ export async function sourceStatus(today = todayIso()) {
|
|||||||
// The last check: how many listings it cost and how many folders came from the cache.
|
// The last check: how many listings it cost and how many folders came from the cache.
|
||||||
lastCrawl: status.lastCrawl,
|
lastCrawl: status.lastCrawl,
|
||||||
failingSince: status.catalogErrorSince,
|
failingSince: status.catalogErrorSince,
|
||||||
|
// While it is down: how many attempts failed and when the next one may reach it.
|
||||||
|
backoff: sourceBackoff(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
calendar: calendar
|
calendar: calendar
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ function tiles(d) {
|
|||||||
"Университет",
|
"Университет",
|
||||||
failing ? "bad" : "ok",
|
failing ? "bad" : "ok",
|
||||||
failing ? "Не отвечает" : "Отвечает",
|
failing ? "Не отвечает" : "Отвечает",
|
||||||
failing ? `с ${dateTime(failing)}` : d.watch?.lastError || `таблица недель: ${d.calendar?.weeksFiles?.length ? "есть" : "нет"}`,
|
failing
|
||||||
|
? `с ${dateTime(failing)}` + (d.catalog?.backoff?.retryAt ? ` · следующая попытка ${dateTime(d.catalog.backoff.retryAt)}` : "")
|
||||||
|
: d.watch?.lastError || `таблица недель: ${d.calendar?.weeksFiles?.length ? "есть" : "нет"}`,
|
||||||
),
|
),
|
||||||
tile(
|
tile(
|
||||||
"Разбор",
|
"Разбор",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { checkCatalog, checkSource } from "./alerts.js";
|
|||||||
|
|
||||||
// The server checks the university's catalog by itself, not only when a request asks: a
|
// The server checks the university's catalog by itself, not only when a request asks: a
|
||||||
// republished document is parsed within minutes, the clients' next refresh gets it, and the
|
// republished document is parsed within minutes, the clients' next refresh gets it, and the
|
||||||
// owner hears about problems (see alerts.js). An unchanged catalog costs two listings.
|
// owner hears about problems (see alerts.js). An unchanged catalog costs two listings. While the
|
||||||
|
// university is down, checks reach it less and less often (source.js pauses after failures).
|
||||||
|
|
||||||
const INTERVAL = Number(process.env.PSU_WATCH_INTERVAL_MS) || 5 * 60 * 1000;
|
const INTERVAL = Number(process.env.PSU_WATCH_INTERVAL_MS) || 5 * 60 * 1000;
|
||||||
|
|
||||||
|
|||||||
64
test/backoff.test.js
Normal file
64
test/backoff.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
|
import { startApi } from "./support/nextcloud.js";
|
||||||
|
|
||||||
|
// The university is asked politely: every request names the service and its owner, and while
|
||||||
|
// the university is down nothing reaches it until a pause that doubles with every failure.
|
||||||
|
let api, watch;
|
||||||
|
const health = async () =>
|
||||||
|
(await fetch(api.base + "/status/data.json", { headers: { Authorization: "Basic " + Buffer.from("admin:secret").toString("base64") } })).json();
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
api = await startApi({
|
||||||
|
PSU_PUBLIC_URL: "https://psu.example",
|
||||||
|
PSU_CONTACT: "mailto:owner@example.com",
|
||||||
|
PSU_MIN_REFRESH_MS: "0",
|
||||||
|
// A short pause instead of five minutes: 300 ms, then 600 ms.
|
||||||
|
PSU_SOURCE_BACKOFF_MS: "300",
|
||||||
|
DASHBOARD_PASSWORD: "secret",
|
||||||
|
});
|
||||||
|
watch = await import("../server/watch.js");
|
||||||
|
await watch.watchOnce();
|
||||||
|
});
|
||||||
|
after(() => api?.stop());
|
||||||
|
|
||||||
|
test("Источник: запросы представляются сервисом и контактом владельца", () => {
|
||||||
|
assert.match(api.stats.userAgent, /^PSU-Schedule\/\d+\.\d+\.\d+ \(\+https:\/\/psu\.example; mailto:owner@example\.com\)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Источник: пока университет не отвечает, проверки ждут всё дольше", async () => {
|
||||||
|
api.catalog.down(true);
|
||||||
|
let requests = api.stats.requests;
|
||||||
|
await watch.watchOnce();
|
||||||
|
assert.equal(api.stats.requests - requests, 1, "одна попытка, дальше обход не идёт");
|
||||||
|
let { catalog } = await health();
|
||||||
|
assert.equal(catalog.backoff.failures, 1);
|
||||||
|
assert.ok(Date.parse(catalog.backoff.retryAt) > Date.now());
|
||||||
|
|
||||||
|
// Within the pause neither the watcher nor a forced refresh reaches the university, and
|
||||||
|
// students still get the saved catalog.
|
||||||
|
requests = api.stats.requests;
|
||||||
|
await watch.watchOnce();
|
||||||
|
const refreshed = await fetch(api.base + "/api/catalog?refresh=1");
|
||||||
|
assert.equal(refreshed.status, 200);
|
||||||
|
assert.equal((await refreshed.json()).stale, true);
|
||||||
|
assert.equal(api.stats.requests, requests);
|
||||||
|
|
||||||
|
// After the pause: one more attempt, and the next pause is twice as long.
|
||||||
|
await sleep(350);
|
||||||
|
await watch.watchOnce();
|
||||||
|
assert.equal(api.stats.requests - requests, 1);
|
||||||
|
({ catalog } = await health());
|
||||||
|
assert.equal(catalog.backoff.failures, 2);
|
||||||
|
assert.ok(Date.parse(catalog.backoff.retryAt) - Date.now() > 350);
|
||||||
|
|
||||||
|
// Back again: the first attempt after the pause reads the catalog, and the pause is over.
|
||||||
|
api.catalog.down(false);
|
||||||
|
await sleep(650);
|
||||||
|
await watch.watchOnce();
|
||||||
|
({ catalog } = await health());
|
||||||
|
assert.equal(catalog.backoff, null);
|
||||||
|
assert.equal(catalog.failingSince, null);
|
||||||
|
assert.ok(!(await (await fetch(api.base + "/api/catalog")).json()).stale);
|
||||||
|
});
|
||||||
@@ -50,10 +50,11 @@ const listing = (folder) => {
|
|||||||
* stand-in's documents during a test, and `stop()`.
|
* stand-in's documents during a test, and `stop()`.
|
||||||
*/
|
*/
|
||||||
export async function startApi(env = {}) {
|
export async function startApi(env = {}) {
|
||||||
const stats = { requests: 0, connections: 0, listings: 0 };
|
const stats = { requests: 0, connections: 0, listings: 0, userAgent: null };
|
||||||
let down = false;
|
let down = false;
|
||||||
const source = createServer((req, res) => {
|
const source = createServer((req, res) => {
|
||||||
stats.requests++;
|
stats.requests++;
|
||||||
|
stats.userAgent = req.headers["user-agent"] || null;
|
||||||
if (req.method === "PROPFIND") stats.listings++;
|
if (req.method === "PROPFIND") stats.listings++;
|
||||||
if (down) return res.writeHead(503).end();
|
if (down) return res.writeHead(503).end();
|
||||||
const path = decodeURIComponent(req.url);
|
const path = decodeURIComponent(req.url);
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ before(async () => {
|
|||||||
TELEGRAM_API: `http://127.0.0.1:${telegram.address().port}`,
|
TELEGRAM_API: `http://127.0.0.1:${telegram.address().port}`,
|
||||||
PSU_PUBLIC_URL: "https://psu.example",
|
PSU_PUBLIC_URL: "https://psu.example",
|
||||||
PSU_ALERT_SOURCE_DOWN_MS: "0",
|
PSU_ALERT_SOURCE_DOWN_MS: "0",
|
||||||
// Every check reaches the stand-in, however soon after the last one.
|
// Every check reaches the stand-in, however soon after the last one, even while it is down
|
||||||
|
// (the pause after failures has its own test, backoff.test.js).
|
||||||
PSU_MIN_REFRESH_MS: "0",
|
PSU_MIN_REFRESH_MS: "0",
|
||||||
|
PSU_SOURCE_BACKOFF_MS: "0",
|
||||||
DASHBOARD_PASSWORD: "secret",
|
DASHBOARD_PASSWORD: "secret",
|
||||||
});
|
});
|
||||||
watch = await import("../server/watch.js");
|
watch = await import("../server/watch.js");
|
||||||
|
|||||||
Reference in New Issue
Block a user