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:
DarkSky
2026-04-05 16:47:25 +08:00
committed by GitHub
parent 547ab47a5e
commit fc5329a1be
2 changed files with 93 additions and 7 deletions
@@ -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,