Skip to content

Commit af3ebca

Browse files
fix(backend): gate dev routes and unify sync error envelopes
Stop verifyIsDev from calling next after a production 403, require delete-all to match the session user, map Sync proxy failures to real HTTP statuses (never 600), and make unknown mutation errors non-retryable. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com>
1 parent b212287 commit af3ebca

12 files changed

Lines changed: 265 additions & 62 deletions

File tree

docs/backend/backend-error-handling.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,20 @@ Compass uses typed operational errors plus a centralized Express error handler.
55
## Principles
66

77
- Minimize the number of `try/catch` blocks in the code.
8+
- Never return non-HTTP statuses such as `Status.UNSURE` (600) on live paths.
9+
- Event mutation routes use the `EventMutationError` envelope
10+
(`code` / `message` / `retryable`). Unknown/programmer errors are
11+
**non-retryable 500** — only true Sync/provider failures stay retryable
12+
`PROVIDER_FAILURE`.
13+
- Sync proxy failures on calendar/auth reads use `throwSyncProxyFailure` /
14+
`unwrapSyncResult` (503/502), never `GenericError.NotSure`.
815

916
## Source Files
1017

1118
- `packages/backend/src/common/errors/handlers/error.handler.ts`
1219
- `packages/backend/src/common/errors/handlers/error.express.handler.ts`
20+
- `packages/backend/src/common/services/sync-service/sync-proxy-error.ts`
21+
- `packages/backend/src/event/event.error.ts`
1322
- feature error metadata files under `packages/backend/src/common/errors/**`
1423
- `packages/core/src/errors/errors.base.ts`
1524

@@ -20,15 +29,20 @@ Preferred backend pattern:
2029
1. define reusable error metadata in the relevant feature file
2130
2. create a `BaseError` through `error(...)`
2231
3. let controller/service code throw that error
23-
4. let centralized Express handling turn it into the client payload
32+
4. let centralized Express handling (`res.promise``handleExpressError`) turn it into the client payload
33+
34+
Event mutation controllers may catch locally and call `toEventMutationError`
35+
so the strict `{ code, message, retryable }` envelope is preserved. User
36+
controllers return JSON via `toClientErrorPayload` (or `{ code, message }` for
37+
unexpected errors) rather than empty bodies.
2438

2539
Example:
2640

2741
```ts
2842
import { AuthError } from "@backend/common/errors/auth/auth.errors";
2943
import { error } from "@backend/common/errors/handlers/error.handler";
3044

31-
throw error(AuthError.MissingRefreshToken, "Google connection required");
45+
throw error(AuthError.SyncConnectionUnavailable, "Could not reach sync");
3246
```
3347

3448
## Client Payload Rules
@@ -39,13 +53,15 @@ For `BaseError`, backend responses are intentionally small:
3953
- `message`: safe user-facing description
4054
- `code`: optional stable machine-readable identifier for frontend branching
4155

56+
Event mutations use `{ code, message, retryable }` instead.
57+
4258
Internal details such as stack traces and operational flags stay server-side.
4359

4460
## Unexpected Error Rules
4561

46-
- non-`BaseError` values are routed through `handleExpressError(...)`
47-
- Google API errors get special handling for revoked tokens, invalid values, and full-sync recovery
48-
- programmer errors can terminate the process after logging
62+
- non-`BaseError` values are routed through `handleExpressError(...)` when using `res.promise`
63+
- Sync client failures map to 502/503 (or typed mutation codes) — never HTTP 600
64+
- programmer errors can terminate the process after logging when `isOperational` is false
4965

5066
## Guidance
5167

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type NextFunction, type Request, type Response } from "express";
2+
import { NodeEnv } from "@core/constants/core.constants";
3+
import { Status } from "@core/errors/status.codes";
4+
import authMiddleware from "@backend/auth/middleware/auth.middleware";
5+
import { CONFIG } from "@backend/common/constants/config.constants";
6+
import { afterEach, describe, expect, it, mock } from "bun:test";
7+
8+
describe("auth.middleware verifyIsDev", () => {
9+
const originalNodeEnv = CONFIG.NODE_ENV;
10+
11+
afterEach(() => {
12+
CONFIG.NODE_ENV = originalNodeEnv;
13+
});
14+
15+
const mockRes = () => {
16+
const json = mock();
17+
const res = {
18+
status: mock().mockReturnThis(),
19+
json,
20+
} as unknown as Response;
21+
return { res, json };
22+
};
23+
24+
it("returns 403 and does not call next in production", () => {
25+
CONFIG.NODE_ENV = NodeEnv.Production;
26+
const { res, json } = mockRes();
27+
const next = mock() as NextFunction;
28+
29+
authMiddleware.verifyIsDev({} as Request, res, next);
30+
31+
expect(res.status).toHaveBeenCalledWith(Status.FORBIDDEN);
32+
expect(json).toHaveBeenCalled();
33+
expect(next).not.toHaveBeenCalled();
34+
});
35+
36+
it("calls next in development", () => {
37+
CONFIG.NODE_ENV = NodeEnv.Development;
38+
const { res } = mockRes();
39+
const next = mock() as NextFunction;
40+
41+
authMiddleware.verifyIsDev({} as Request, res, next);
42+
43+
expect(res.status).not.toHaveBeenCalled();
44+
expect(next).toHaveBeenCalledTimes(1);
45+
});
46+
});

packages/backend/src/auth/middleware/auth.middleware.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { type NextFunction, type Request, type Response } from "express";
22
import { Status } from "@core/errors/status.codes";
3-
import { IS_DEV } from "@backend/common/constants/config.constants";
3+
import { isDev } from "@core/util/env.util";
4+
import { CONFIG } from "@backend/common/constants/config.constants";
45
import { AuthError } from "@backend/common/errors/auth/auth.errors";
56
import { error } from "@backend/common/errors/handlers/error.handler";
67

78
class AuthMiddleware {
89
verifyIsDev = (_req: Request, res: Response, next: NextFunction) => {
9-
if (!IS_DEV) {
10+
// Read NODE_ENV from CONFIG each call so production never falls through
11+
// to next() after a 403 (and so tests can flip CONFIG.NODE_ENV).
12+
if (!isDev(CONFIG.NODE_ENV)) {
1013
res
1114
.status(Status.FORBIDDEN)
1215
.json({ error: error(AuthError.DevOnly, "Request Failed") });
16+
return;
1317
}
1418
next();
1519
};

packages/backend/src/calendar/controllers/calendar.controller.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { GenericError } from "@backend/common/errors/generic/generic.errors";
1515
import { error } from "@backend/common/errors/handlers/error.handler";
1616
import { syncCalendarsToBrowser } from "@backend/common/services/sync-service/calendar-list.translation";
1717
import { toSyncPrincipal } from "@backend/common/services/sync-service/sync-principal";
18+
import { throwSyncProxyFailure } from "@backend/common/services/sync-service/sync-proxy-error";
1819
import { getSyncServiceClient } from "@backend/common/services/sync-service/sync-service.factory";
1920
import { type Res_Promise } from "@backend/common/types/express.types";
2021

@@ -76,14 +77,14 @@ const listCalendarsFromSync = async (
7677
client.listConnections(principal),
7778
]);
7879
if (!calendarsResult.ok) {
79-
throw error(
80-
GenericError.NotSure,
80+
throwSyncProxyFailure(
81+
calendarsResult.error.kind,
8182
`Failed to list calendars from sync (${calendarsResult.error.kind})`,
8283
);
8384
}
8485
if (!connectionsResult.ok) {
85-
throw error(
86-
GenericError.NotSure,
86+
throwSyncProxyFailure(
87+
connectionsResult.error.kind,
8788
`Failed to list connections from sync (${connectionsResult.error.kind})`,
8889
);
8990
}
@@ -124,8 +125,8 @@ const getAvailabilityFromSync = async (
124125
purpose: "display",
125126
});
126127
if (!result.ok) {
127-
throw error(
128-
GenericError.NotSure,
128+
throwSyncProxyFailure(
129+
result.error.kind,
129130
`Failed to query availability from sync (${result.error.kind})`,
130131
);
131132
}

packages/backend/src/common/errors/generic/generic.errors.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,19 @@ export const GenericError: GenericErrors = {
2222
},
2323
NotImplemented: {
2424
description: "Not implemented yet",
25-
status: Status.UNSURE,
25+
status: Status.NOT_IMPLEMENTED,
2626
isOperational: true,
2727
},
28+
// Prefer typed Sync/auth/event errors on live paths. Kept as a last-resort
29+
// operational 500 — never Status.UNSURE (600), which is not a real HTTP status.
2830
NotSure: {
2931
description: "Not sure why error occurred. See logs",
30-
status: Status.UNSURE,
32+
status: Status.INTERNAL_SERVER,
3133
isOperational: true,
3234
},
3335
OperationTimeout: {
3436
description: "Operation timed out",
35-
status: Status.UNSURE,
37+
status: Status.GATEWAY_TIMEOUT,
3638
isOperational: true,
3739
},
3840
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BaseError } from "@core/errors/errors.base";
2+
import { Status } from "@core/errors/status.codes";
3+
import {
4+
throwSyncCommandSubmitFailure,
5+
throwSyncProxyFailure,
6+
} from "@backend/common/services/sync-service/sync-proxy-error";
7+
import {
8+
EventMutationException,
9+
toEventMutationError,
10+
} from "@backend/event/event.error";
11+
import { describe, expect, it } from "bun:test";
12+
13+
describe("toEventMutationError", () => {
14+
it("keeps PROVIDER_FAILURE retryable for typed mutation failures", () => {
15+
const mapped = toEventMutationError(
16+
new EventMutationException("PROVIDER_FAILURE", "provider blip"),
17+
);
18+
expect(mapped.status).toBe(502);
19+
expect(mapped.body.retryable).toBe(true);
20+
});
21+
22+
it("maps unknown errors to non-retryable 500", () => {
23+
const mapped = toEventMutationError(new Error("boom"));
24+
expect(mapped.status).toBe(Status.INTERNAL_SERVER);
25+
expect(mapped.body).toEqual({
26+
code: "PROVIDER_FAILURE",
27+
message: "boom",
28+
retryable: false,
29+
});
30+
});
31+
});
32+
33+
describe("sync-proxy-error", () => {
34+
it("maps unavailable Sync reads to 503, never 600", () => {
35+
try {
36+
throwSyncProxyFailure("unavailable", "calendars down");
37+
throw new Error("expected throw");
38+
} catch (e) {
39+
expect(e).toBeInstanceOf(BaseError);
40+
expect((e as BaseError).statusCode).toBe(Status.SERVICE_UNAVAILABLE);
41+
expect((e as BaseError).statusCode).not.toBe(Status.UNSURE);
42+
}
43+
});
44+
45+
it("maps unexpected Sync reads to 502, never 600", () => {
46+
try {
47+
throwSyncProxyFailure("unexpectedStatus", "weird status");
48+
throw new Error("expected throw");
49+
} catch (e) {
50+
expect(e).toBeInstanceOf(BaseError);
51+
expect((e as BaseError).statusCode).toBe(Status.BAD_GATEWAY);
52+
expect((e as BaseError).statusCode).not.toBe(Status.UNSURE);
53+
}
54+
});
55+
56+
it("maps command submit timeout to retryable PROVIDER_FAILURE", () => {
57+
try {
58+
throwSyncCommandSubmitFailure("timeout", "timeout");
59+
throw new Error("expected throw");
60+
} catch (e) {
61+
expect(e).toBeInstanceOf(EventMutationException);
62+
const mapped = toEventMutationError(e);
63+
expect(mapped.status).toBe(502);
64+
expect(mapped.body.retryable).toBe(true);
65+
}
66+
});
67+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Status } from "@core/errors/status.codes";
2+
import { AuthError } from "@backend/common/errors/auth/auth.errors";
3+
import { error } from "@backend/common/errors/handlers/error.handler";
4+
import { type SyncClientErrorKind } from "@backend/common/services/sync-service/sync-service.client";
5+
import { eventMutationError } from "@backend/event/event.error";
6+
7+
/**
8+
* Map a failed Sync HTTP call on a read/proxy route into a real HTTP status.
9+
* Never returns Status.UNSURE (600). Timeout/unavailable → 503; other Sync
10+
* failures → 502. Clients get a retryable service error without Sync-internal
11+
* details leaking beyond the kind already in `userMessage`.
12+
*/
13+
export function throwSyncProxyFailure(
14+
kind: SyncClientErrorKind,
15+
userMessage: string,
16+
): never {
17+
if (kind === "timeout" || kind === "unavailable") {
18+
throw error(AuthError.SyncConnectionUnavailable, userMessage);
19+
}
20+
throw error(
21+
{
22+
description: "Sync service returned an unexpected failure",
23+
status: Status.BAD_GATEWAY,
24+
isOperational: true,
25+
code: "SYNC_PROXY_FAILURE",
26+
},
27+
userMessage,
28+
);
29+
}
30+
31+
/**
32+
* Map a failed Sync command submit on an event mutation route. Timeout and
33+
* unavailable stay PROVIDER_FAILURE (retryable 502) because Sync may already
34+
* have applied the write. Other kinds are also PROVIDER_FAILURE so clients
35+
* never see GenericError.NotSure / HTTP 600.
36+
*/
37+
export function throwSyncCommandSubmitFailure(
38+
kind: SyncClientErrorKind,
39+
detail: string,
40+
): never {
41+
if (kind === "timeout" || kind === "unavailable") {
42+
throw eventMutationError(
43+
"PROVIDER_FAILURE",
44+
`Sync command ${kind}; the mutation may already be applied`,
45+
);
46+
}
47+
throw eventMutationError(
48+
"PROVIDER_FAILURE",
49+
`Failed to submit command to sync (${detail})`,
50+
);
51+
}

packages/backend/src/common/services/sync-service/sync-service.client.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -559,12 +559,11 @@ function statusToKind(status: number): SyncClientErrorKind {
559559
if (status === 400) return "badRequest";
560560
if (status === 404) return "notFound";
561561
if (status === 409) return "conflict";
562+
// unexpectedStatus -> Sync proxy 502 (never Status.UNSURE / HTTP 600).
562563
// 429 is sync's own internal rate limiter (internal-http.ts), tripped
563564
// easily under normal-ish load (a shared 300/min bucket for the whole
564565
// backend). Treated the same as 503: a retryable service-busy state, not
565-
// an unexpected condition - previously this fell through to
566-
// unexpectedStatus -> GenericError.NotSure, whose Status.UNSURE (600) is
567-
// not a real HTTP status and reads as an unretryable mystery to the caller.
566+
// an unexpected condition.
568567
if (status === 429 || status === 503) return "unavailable";
569568
return "unexpectedStatus";
570569
}

packages/backend/src/event/controllers/event.controller.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,4 +387,29 @@ describe("EventController", () => {
387387
retryable: true,
388388
});
389389
});
390+
391+
it("rejects delete-all when the session user does not match :userId", async () => {
392+
const deleteSpy = spyOn(
393+
(await import("@backend/event/services/event.service")).default,
394+
"deleteAllByUser",
395+
);
396+
const { res, json } = jsonRes();
397+
const sessionUser = objectId();
398+
const otherUser = objectId();
399+
400+
await eventController.deleteAllByUser(
401+
sessionReq(sessionUser, { params: { userId: otherUser } }),
402+
res,
403+
);
404+
405+
expect(deleteSpy).not.toHaveBeenCalled();
406+
expect((res.status as ReturnType<typeof mock>).mock.calls[0]?.[0]).toBe(
407+
Status.BAD_REQUEST,
408+
);
409+
expect(json).toHaveBeenCalledWith({
410+
code: "INVALID_INPUT",
411+
message: "Cannot delete events for another user",
412+
retryable: false,
413+
});
414+
});
390415
});

0 commit comments

Comments
 (0)