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:
2026-10-22 01:17:54 +03:00
parent 10c85e3bb9
commit c0487e4c2a
9 changed files with 82503 additions and 81477 deletions

View File

@@ -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` там же — время и ошибка последней проверки.
**Вежливость к университету.** Каждый запрос к 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`). На ней:
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
- **запросы за последний час** — ключевые числа, график по минутам (с ответом, `304`, ошибки), адреса API со средним временем и самые активные клиенты;

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,9 @@ services:
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD:-}
# Публичный адрес для ссылок в сообщениях, например https://psu.rumblur.by
PSU_PUBLIC_URL: ${PSU_PUBLIC_URL:-}
# Контакт владельца в User-Agent запросов к университету (почта или ссылка),
# чтобы администраторы Nextcloud знали, кому написать: mailto:you@example.com
PSU_CONTACT: ${PSU_CONTACT:-}
# Путь к LibreOffice для импорта старых .doc уже есть в образе.
volumes:
# Кэш каталога, таблицы недель и распознанных документов.

View File

@@ -1,5 +1,6 @@
import { XMLParser } from "fast-xml-parser";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { parseWeeksDocx, parseBellsDocx } from "./calendar.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 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") {
// Only the university share is reachable; listings and catalog entries supply the paths.
if (
@@ -52,16 +86,33 @@ export async function request(path, method = "GET") {
!["GET", "PROPFIND"].includes(method)
)
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
// 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.
try {
const response = await fetch(ORIGIN + path, {
method,
headers: method === "PROPFIND" ? { Depth: "1" } : {},
redirect: "manual",
signal: AbortSignal.timeout(TIMEOUT),
});
let response;
try {
response = await fetch(ORIGIN + path, {
method,
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) {
await response.body?.cancel();
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.
lastCrawl: status.lastCrawl,
failingSince: status.catalogErrorSince,
// While it is down: how many attempts failed and when the next one may reach it.
backoff: sourceBackoff(),
}
: null,
calendar: calendar

View File

@@ -102,7 +102,9 @@ function tiles(d) {
"Университет",
failing ? "bad" : "ok",
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(
"Разбор",

View File

@@ -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
// 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;

64
test/backoff.test.js Normal file
View 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);
});

View File

@@ -50,10 +50,11 @@ const listing = (folder) => {
* stand-in's documents during a test, and `stop()`.
*/
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;
const source = createServer((req, res) => {
stats.requests++;
stats.userAgent = req.headers["user-agent"] || null;
if (req.method === "PROPFIND") stats.listings++;
if (down) return res.writeHead(503).end();
const path = decodeURIComponent(req.url);

View File

@@ -28,8 +28,10 @@ before(async () => {
TELEGRAM_API: `http://127.0.0.1:${telegram.address().port}`,
PSU_PUBLIC_URL: "https://psu.example",
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_SOURCE_BACKOFF_MS: "0",
DASHBOARD_PASSWORD: "secret",
});
watch = await import("../server/watch.js");