mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
fix(server): skip watch for external cal (#14788)
#### PR Dependency Tree * **PR #14788** 👈 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** * Calendar subscriptions now gracefully fall back to polling when push notifications aren’t supported, keeping syncs working. * Affected subscriptions have webhook details cleared and are marked with a long-lived expiration to avoid repeated webhook attempts. * Prevents repeated retries for unsupported push channels, reducing unnecessary errors and retries. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -594,6 +594,58 @@ test('syncSubscription renews webhook channel when expiring', async t => {
|
||||
t.truthy(updated?.channelExpiration);
|
||||
});
|
||||
|
||||
test('syncSubscription falls back to polling when push is unsupported', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id);
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
mock.method(provider, 'listEvents', async () => ({
|
||||
events: [],
|
||||
nextSyncToken: 'next-sync',
|
||||
}));
|
||||
const watchMock = mock.method(provider, 'watchCalendar', async () => {
|
||||
throw new CalendarProviderRequestError({
|
||||
status: 400,
|
||||
message: JSON.stringify({
|
||||
error: {
|
||||
errors: [
|
||||
{
|
||||
domain: 'calendar',
|
||||
reason: 'pushNotSupportedForRequestedResource',
|
||||
message: 'Push notifications are not supported by this resource.',
|
||||
},
|
||||
],
|
||||
code: 400,
|
||||
message: 'Push notifications are not supported by this resource.',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
let updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(watchMock.mock.callCount(), 1);
|
||||
t.is(updated?.customChannelId, null);
|
||||
t.is(updated?.customResourceId, null);
|
||||
t.truthy(updated?.channelExpiration);
|
||||
t.true(
|
||||
updated!.channelExpiration!.getTime() >
|
||||
Date.now() + 365 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(watchMock.mock.callCount(), 1);
|
||||
t.is(updated?.customChannelId, null);
|
||||
t.is(updated?.customResourceId, null);
|
||||
});
|
||||
|
||||
test('syncSubscription keeps schedule moving when webhook renewal fails', async t => {
|
||||
const now = new Date('2026-01-01T00:00:00.000Z').getTime();
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
@@ -32,6 +32,9 @@ const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
|
||||
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
|
||||
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
||||
const CHANNEL_RENEW_RETRY_MS = 15 * 60 * 1000;
|
||||
const UNSUPPORTED_PUSH_CHANNEL_EXPIRATION = new Date(
|
||||
'9999-12-31T23:59:59.999Z'
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
@@ -805,6 +808,19 @@ export class CalendarService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private isPushUnsupportedError(error: unknown) {
|
||||
if (!(error instanceof CalendarProviderRequestError)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = error.data?.status ?? error.status;
|
||||
if (status !== 400) return false;
|
||||
|
||||
return error.data?.message?.includes(
|
||||
'pushNotSupportedForRequestedResource'
|
||||
);
|
||||
}
|
||||
|
||||
private requireProvider(name: CalendarProviderName) {
|
||||
const provider = this.providerFactory.get(name);
|
||||
if (!provider) {
|
||||
@@ -817,6 +833,7 @@ export class CalendarService {
|
||||
subscription: {
|
||||
id: string;
|
||||
externalCalendarId: string;
|
||||
displayName: string | null;
|
||||
customChannelId: string | null;
|
||||
customResourceId: string | null;
|
||||
channelExpiration: Date | null;
|
||||
@@ -855,13 +872,30 @@ export class CalendarService {
|
||||
|
||||
const channelId = randomUUID();
|
||||
const token = this.getWebhookToken();
|
||||
const result = await provider.watchCalendar({
|
||||
accessToken,
|
||||
calendarId: subscription.externalCalendarId,
|
||||
address,
|
||||
token,
|
||||
channelId,
|
||||
});
|
||||
let result: Awaited<ReturnType<NonNullable<typeof provider.watchCalendar>>>;
|
||||
try {
|
||||
result = await provider.watchCalendar({
|
||||
accessToken,
|
||||
calendarId: subscription.externalCalendarId,
|
||||
address,
|
||||
token,
|
||||
channelId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.isPushUnsupportedError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.models.calendarSubscription.updateChannel(subscription.id, {
|
||||
customChannelId: null,
|
||||
customResourceId: null,
|
||||
channelExpiration: UNSUPPORTED_PUSH_CHANNEL_EXPIRATION,
|
||||
});
|
||||
this.logger.log(
|
||||
`Calendar subscription ${subscription.id} (${subscription.displayName ?? subscription.externalCalendarId}) does not support push notifications; falling back to polling`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.models.calendarSubscription.updateChannel(subscription.id, {
|
||||
customChannelId: result.channelId,
|
||||
|
||||
Reference in New Issue
Block a user