fix(sse): treat expected event-stream coordination errors as 403s and never crash the sync loop (#22475)
## Rationale `NOT_AUTHORIZED` and `EVENT_STREAM_ALREADY_EXISTS` on the event-stream mutations are **expected coordination outcomes** — the frontend explicitly recognizes both (`isGracefullyHandledEventStreamError`) and recovers by recreating its stream. But `EventStreamExceptionFilter` rethrows them as `InternalServerError`, which (a) Sentry captures on every single occurrence, and (b) counts as a 500 in operation metrics. **Production evidence (Sentry):** this turned a March client-regression into a 119,510-event / 2,607-user flood ([TWENTY-SERVER-FP3](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-FP3), plus FP0 at ~29k) that buried real errors. The trigger was fixed back then, but the amplifier — error-level capture of an expected signal — is still in place, and a residual trickle still fires today. Second defect, client side: for any *non-graceful* server error (e.g. a lock-acquisition timeout), `SSEQuerySubscribeEffect.handleError` **threw** from inside a debounced callback — an unhandled rejection ([TWENTY-FRONT-62M](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-62M), 233 users; [6MM](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-6MM), 65 users) that left the tab's query listeners permanently out of sync with the server (no more live updates until reload). ## Why this is the root cause, not a symptom patch The protocol design already says these are recoverable client-coordination signals — the bug is purely that the server encodes them with 500 semantics and the client punishes unexpected errors by giving up instead of resetting. This PR aligns both ends with the existing design rather than adding new machinery: - Server: `ForbiddenError` (403) with the same `subCode` — the client's graceful check already accepts `code === 'FORBIDDEN'`, so this is compatible by construction; `FORBIDDEN` is already in `graphQLErrorCodesToFilter`, so monitoring capture stops with no new filtering logic. - Client: the non-graceful path now does exactly what the graceful path does (reset listeners + recreate stream) and *additionally* reports the unexpected error — visibility without a crash. ## User impact Tabs that hit any event-stream error now always self-heal back to live updates instead of silently going stale until reload (~300 users hit the crash path over 90d). On the ops side: expected coordination noise leaves error monitoring, and 403/500 metrics become truthful. ## Test plan - [x] Behavior preserved for graceful codes (same reset path, client check already includes FORBIDDEN) - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22475?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+9
-6
@@ -8,6 +8,7 @@ import { sseEventStreamReadyState } from '@/sse-db-event/states/sseEventStreamRe
|
||||
import { isGracefullyHandledEventStreamError } from '@/sse-db-event/utils/isGracefullyHandledEventStreamError';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { captureException } from '@sentry/react';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useStore } from 'jotai';
|
||||
@@ -47,18 +48,20 @@ export const SSEQuerySubscribeEffect = () => {
|
||||
const extensions = getGraphqlErrorExtensionsFromError(error);
|
||||
|
||||
if (
|
||||
isGracefullyHandledEventStreamError({
|
||||
!isGracefullyHandledEventStreamError({
|
||||
subCode: extensions?.subCode,
|
||||
code: extensions?.code,
|
||||
})
|
||||
) {
|
||||
store.set(activeQueryListenersState.atom, []);
|
||||
store.set(shouldDestroyEventStreamState.atom, true);
|
||||
|
||||
return;
|
||||
captureException(
|
||||
new Error(`Unhandled error for event stream: ${error.message}`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled error for event stream: ${error.message}`);
|
||||
store.set(activeQueryListenersState.atom, []);
|
||||
store.set(shouldDestroyEventStreamState.atom, true);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GqlExceptionFilter } from '@nestjs/graphql';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
EventStreamException,
|
||||
EventStreamExceptionCode,
|
||||
@@ -15,7 +15,7 @@ export class EventStreamExceptionFilter implements GqlExceptionFilter {
|
||||
switch (exception.code) {
|
||||
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
|
||||
case EventStreamExceptionCode.NOT_AUTHORIZED:
|
||||
throw new InternalServerError(exception.message, {
|
||||
throw new ForbiddenError(exception.message, {
|
||||
subCode: exception.code,
|
||||
userFriendlyMessage: exception.userFriendlyMessage,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user