fix: retry transient pickup checks

Retry transient Foodsharing network failures such as socket hang up for safe read/check actions and add explicit pickup fallback scheduling, tick, and result logs so missed fallback checks can be diagnosed.
This commit is contained in:
2026-06-16 22:11:18 +02:00
parent eb2031feb0
commit 881b3d8d86
3 changed files with 87 additions and 16 deletions

View File

@@ -1,3 +1,3 @@
docs: document git and docker workflow
fix: retry transient pickup checks
Record the requirement to start from a clean worktree, preserve the running Docker state as production reference, commit and push every completed change, and deploy features and fixes live via Docker.
Retry transient Foodsharing network failures such as socket hang up for safe read/check actions and add explicit pickup fallback scheduling, tick, and result logs so missed fallback checks can be diagnosed.

View File

@@ -1196,6 +1196,13 @@ function scheduleRegularPickupRefresh(settings) {
function scheduleFallbackPickupChecks(sessionId, settings) {
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
if (!cronExpression) {
console.log(`[PICKUP] Fallback-Check für Session ${sessionId} nicht geplant: kein Cron-Ausdruck konfiguriert.`);
return null;
}
if (!cron.validate(cronExpression)) {
console.warn(
`[PICKUP] Fallback-Check für Session ${sessionId} nicht geplant: ungültiger Cron-Ausdruck "${cronExpression}".`
);
return null;
}
const job = cron.schedule(
@@ -1203,12 +1210,24 @@ function scheduleFallbackPickupChecks(sessionId, settings) {
() => {
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
console.warn(`[PICKUP] Fallback-Check übersprungen: Session ${sessionId} nicht gefunden.`);
return;
}
const delay = randomDelayMs(settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds);
console.log(
`[PICKUP] Fallback-Check ausgelöst für Session ${sessionId} ` +
`(Profil ${session.profile.id}, Delay ${delay}ms, Cron: ${cronExpression}).`
);
setTimeout(() => {
const config = readConfig(session.profile.id);
runImmediatePickupCheck(sessionId, config, settings).catch((error) => {
runImmediatePickupCheck(sessionId, config, settings)
.then((result) => {
console.log(
`[PICKUP] Fallback-Check abgeschlossen für Session ${sessionId}: ` +
`${result.checked} aktive Einträge geprüft.`
);
})
.catch((error) => {
console.error('[PICKUP] Fallback-Check fehlgeschlagen:', error.message);
});
}, delay);
@@ -1216,6 +1235,7 @@ function scheduleFallbackPickupChecks(sessionId, settings) {
{ timezone: TIME_ZONE }
);
sessionStore.attachJob(sessionId, job);
console.log(`[PICKUP] Fallback-Check geplant für Session ${sessionId} (Cron: ${cronExpression}).`);
return job;
}
@@ -1311,8 +1331,13 @@ async function runImmediatePickupCheck(sessionId, config, settings) {
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry?.active);
if (activeEntries.length === 0) {
console.log(`[PICKUP] Sofortprüfung für Session ${sessionId}: keine aktiven Einträge.`);
return { checked: 0 };
}
console.log(
`[PICKUP] Sofortprüfung für Session ${sessionId}: ${activeEntries.length} aktive Einträge ` +
`(${activeEntries.map((entry) => entry.id).join(', ')}).`
);
for (const entry of activeEntries) {
await checkEntry(sessionId, entry, resolvedSettings);
}

View File

@@ -3,8 +3,14 @@ const sessionStore = require('./sessionStore');
const notificationService = require('./notificationService');
const ADMIN_SESSION_ERROR_COOLDOWN_MS = 6 * 60 * 60 * 1000;
const DEFAULT_TRANSIENT_RETRY_DELAYS_MS = [1000, 3000];
const NON_RETRIABLE_TRANSIENT_LABELS = new Set(['bookSlot']);
const adminSessionErrorCooldowns = new Map();
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function shouldNotifyAdminSessionError(key) {
if (!key) {
return false;
@@ -51,6 +57,26 @@ function isCsrfError(error) {
return message.toLowerCase().includes('csrf');
}
function isTransientNetworkError(error) {
if (!error) {
return false;
}
if (error.response) {
return false;
}
const code = String(error.code || '').toUpperCase();
if (['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'EAI_AGAIN'].includes(code)) {
return true;
}
const message = String(error.message || '').toLowerCase();
return (
message.includes('socket hang up') ||
message.includes('timeout') ||
message.includes('network error') ||
message.includes('connection reset')
);
}
async function refreshSession(session, { label } = {}) {
if (!session?.credentials?.email || !session?.credentials?.password) {
console.warn(
@@ -110,7 +136,7 @@ async function ensureSession(session) {
return true;
}
async function withSessionRetry(session, action, { label } = {}) {
async function withSessionRetry(session, action, { label, transientRetries } = {}) {
if (!session) {
throw new Error('Session fehlt');
}
@@ -120,9 +146,27 @@ async function withSessionRetry(session, action, { label } = {}) {
throw new Error('Session konnte nicht erneuert werden');
}
}
const retryDelays = NON_RETRIABLE_TRANSIENT_LABELS.has(label)
? []
: DEFAULT_TRANSIENT_RETRY_DELAYS_MS.slice(
0,
Number.isFinite(transientRetries) ? Math.max(0, transientRetries) : DEFAULT_TRANSIENT_RETRY_DELAYS_MS.length
);
let transientAttempt = 0;
while (true) {
try {
return await action();
} catch (error) {
if (isTransientNetworkError(error) && transientAttempt < retryDelays.length) {
transientAttempt += 1;
const delay = retryDelays[transientAttempt - 1];
console.warn(
`[SESSION] Transienter Fehler${label ? ` (${label})` : ''}: ${error.message}. ` +
`Retry ${transientAttempt}/${retryDelays.length} in ${delay}ms.`
);
await wait(delay);
continue;
}
if (!isUnauthorizedError(error) && !isCsrfError(error)) {
throw error;
}
@@ -133,9 +177,11 @@ async function withSessionRetry(session, action, { label } = {}) {
return action();
}
}
}
module.exports = {
ensureSession,
isTransientNetworkError,
refreshSession,
withSessionRetry
};