feat: handle calendar sync failed (#14510)

#### PR Dependency Tree


* **PR #14510** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved calendar sync reliability with exponential backoff for
repeated failures.
* Better handling of token refresh failures with automatic account
invalidation and cleanup when needed.
* Subscriptions are now automatically disabled and related events
removed when the calendar provider reports missing resources.

* **Tests**
* Added comprehensive tests covering sync failures, backoff behavior,
token refresh handling, skipping retries during backoff, and recovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-24 22:45:47 +08:00
committed by GitHub
parent 9c99293c92
commit 41b3b0e82e
4 changed files with 488 additions and 35 deletions
@@ -12,6 +12,7 @@ import {
Mutex,
URLHelper,
} from '../../base';
import { SessionRedis } from '../../base/redis';
import { Models } from '../../models';
import type { CalendarCalDAVProviderPreset } from './config';
import {
@@ -27,6 +28,10 @@ import type { LinkCalDAVAccountInput } from './types';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FUTURE_DAYS = 180;
const SYNC_FAILURE_BACKOFF_KEY_PREFIX = 'calendar:sync:backoff:';
const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_TTL_SECONDS = 24 * 60 * 60;
@Injectable()
export class CalendarService {
@@ -37,6 +42,7 @@ export class CalendarService {
private readonly models: Models,
private readonly providerFactory: CalendarProviderFactory<CalendarProvider>,
private readonly mutex: Mutex,
private readonly redis: SessionRedis,
private readonly config: Config,
private readonly url: URLHelper
) {}
@@ -307,6 +313,12 @@ export class CalendarService {
return;
}
const now = Date.now();
const backoff = await this.getSyncFailureBackoff(subscription.id);
if (backoff && now < backoff.nextRetryAt.getTime()) {
return;
}
await using lock = await this.mutex.acquire(
`calendar:subscription:${subscriptionId}`
);
@@ -314,6 +326,12 @@ export class CalendarService {
return;
}
const lockedNow = Date.now();
const lockedBackoff = await this.getSyncFailureBackoff(subscription.id);
if (lockedBackoff && lockedNow < lockedBackoff.nextRetryAt.getTime()) {
return;
}
const provider = this.providerFactory.get(
account.provider as CalendarProviderName
);
@@ -321,8 +339,18 @@ export class CalendarService {
return;
}
const { accessToken } = await this.ensureAccessToken(account);
if (!accessToken) {
let accessToken: string | null = null;
try {
const tokens = await this.ensureAccessToken(account);
if (!tokens.accessToken) return;
accessToken = tokens.accessToken;
} catch (error) {
await this.handleSubscriptionSyncFailure({
error,
subscription,
account,
provider,
});
return;
}
@@ -352,31 +380,42 @@ export class CalendarService {
await this.models.calendarSubscription.updateSync(subscription.id, {
syncToken: null,
});
await this.syncWithProvider({
provider,
subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId,
accessToken,
account,
timeMin,
timeMax,
subscriptionTimezone: subscription.timezone ?? undefined,
});
synced = true;
} else {
if (this.isTokenInvalidError(error)) {
await this.invalidateAccount(account.id, (error as Error).message);
} else {
this.logger.warn(
`Calendar sync failed for subscription ${subscription.id}`,
error as Error
);
try {
await this.syncWithProvider({
provider,
subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId,
accessToken,
account,
timeMin,
timeMax,
subscriptionTimezone: subscription.timezone ?? undefined,
});
synced = true;
} catch (syncTokenRetryError) {
await this.handleSubscriptionSyncFailure({
error: syncTokenRetryError,
subscription,
account,
provider,
accessToken,
});
return;
}
} else {
await this.handleSubscriptionSyncFailure({
error,
subscription,
account,
provider,
accessToken,
});
return;
}
}
if (synced) {
await this.clearSyncFailureBackoff(subscription.id);
await this.ensureWebhookChannel(subscription, provider, accessToken);
}
@@ -764,19 +803,6 @@ export class CalendarService {
return false;
}
private async invalidateAccount(accountId: string, lastError?: string) {
await this.models.calendarAccount.updateStatus(
accountId,
'invalid',
lastError ?? null
);
const subscriptions =
await this.models.calendarSubscription.listByAccount(accountId);
const subscriptionIds = subscriptions.map(s => s.id);
await this.models.calendarEvent.deleteBySubscriptionIds(subscriptionIds);
await this.models.calendarSubscription.clearSyncTokensByAccount(accountId);
}
private requireProvider(name: CalendarProviderName) {
const provider = this.providerFactory.get(name);
if (!provider) {
@@ -841,4 +867,180 @@ export class CalendarService {
channelExpiration: result.expiration ?? null,
});
}
private async handleSubscriptionSyncFailure(params: {
error: unknown;
subscription: {
id: string;
externalCalendarId: string;
customChannelId: string | null;
customResourceId: string | null;
};
account: CalendarAccount;
provider: CalendarProvider;
accessToken?: string;
}) {
if (this.isSubscriptionMissingError(params.error)) {
await this.disableSubscription({
subscriptionId: params.subscription.id,
provider: params.provider,
accessToken: params.accessToken,
customChannelId: params.subscription.customChannelId,
customResourceId: params.subscription.customResourceId,
});
this.logger.warn(
`Calendar subscription ${params.subscription.id} was disabled because provider returned 404 for calendar ${params.subscription.externalCalendarId}`
);
return;
}
if (this.isTokenInvalidError(params.error)) {
await this.clearSyncFailureBackoff(params.subscription.id);
await this.models.calendarAccount.invalidateAndPurge(
params.account.id,
this.formatSyncError(params.error)
);
return;
}
const backoff = await this.bumpSyncFailureBackoff(params.subscription.id);
const interval = params.account.refreshIntervalMinutes ?? 60;
const lastSyncAt = this.calculateLastSyncAtForRetry(
backoff.nextRetryAt,
interval
);
await this.models.calendarSubscription.updateLastSyncAt(
params.subscription.id,
lastSyncAt
);
this.logger.warn(
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${backoff.attempt}, next retry at ${backoff.nextRetryAt.toISOString()}`,
this.toError(params.error)
);
}
private isSubscriptionMissingError(error: unknown) {
if (!(error instanceof CalendarProviderRequestError)) {
return false;
}
const status = error.data?.status ?? error.status;
return status === 404;
}
private calculateLastSyncAtForRetry(
nextRetryAt: Date,
refreshIntervalMinutes: number
) {
// Cron schedules by `now - lastSyncAt >= refreshInterval`, so back-calculate
// a synthetic lastSyncAt to defer the next attempt to `nextRetryAt`.
return new Date(nextRetryAt.getTime() - refreshIntervalMinutes * 60 * 1000);
}
private async disableSubscription(params: {
subscriptionId: string;
provider: CalendarProvider;
accessToken?: string;
customChannelId: string | null;
customResourceId: string | null;
}) {
if (
params.provider.stopChannel &&
params.accessToken &&
params.customChannelId &&
params.customResourceId
) {
try {
await params.provider.stopChannel({
accessToken: params.accessToken,
channelId: params.customChannelId,
resourceId: params.customResourceId,
});
} catch (error) {
this.logger.warn(
`Failed to stop webhook channel for disabled calendar subscription ${params.subscriptionId}`,
this.toError(error)
);
}
}
await this.models.calendarSubscription.disableAndPurge(
params.subscriptionId
);
await this.clearSyncFailureBackoff(params.subscriptionId);
}
private getSyncFailureBackoffKey(subscriptionId: string) {
return `${SYNC_FAILURE_BACKOFF_KEY_PREFIX}${subscriptionId}`;
}
private async getSyncFailureBackoff(subscriptionId: string) {
const key = this.getSyncFailureBackoffKey(subscriptionId);
const value = await this.redis.get(key);
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as {
attempt?: number;
nextRetryAt?: string;
};
if (!parsed.attempt || !parsed.nextRetryAt) {
return null;
}
const nextRetryAt = new Date(parsed.nextRetryAt);
if (Number.isNaN(nextRetryAt.getTime())) {
return null;
}
return {
attempt: parsed.attempt,
nextRetryAt,
};
} catch {
return null;
}
}
private async bumpSyncFailureBackoff(subscriptionId: string) {
const state = await this.getSyncFailureBackoff(subscriptionId);
const attempt = (state?.attempt ?? 0) + 1;
const delay = Math.min(
SYNC_FAILURE_BACKOFF_BASE_MS * 2 ** (attempt - 1),
SYNC_FAILURE_BACKOFF_MAX_MS
);
const nextRetryAt = new Date(Date.now() + delay);
const key = this.getSyncFailureBackoffKey(subscriptionId);
await this.redis.set(
key,
JSON.stringify({
attempt,
nextRetryAt: nextRetryAt.toISOString(),
}),
'EX',
SYNC_FAILURE_BACKOFF_TTL_SECONDS
);
return {
attempt,
nextRetryAt,
};
}
private async clearSyncFailureBackoff(subscriptionId: string) {
const key = this.getSyncFailureBackoffKey(subscriptionId);
await this.redis.del(key);
}
private formatSyncError(error: unknown) {
if (error instanceof Error && error.message) {
return error.message;
}
return String(error);
}
private toError(error: unknown) {
if (error instanceof Error) {
return error;
}
return new Error(this.formatSyncError(error));
}
}