fix: standardize API error handling across services and composables#137
fix: standardize API error handling across services and composables#137NickGhignatti merged 4 commits intomasterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR standardizes error handling across backend microservices (typed errors + global Express error middleware) and updates client composables/stores and OpenAPI docs to follow a consistent “handle expected API failures without throwing” approach.
Changes:
- Introduce typed
BaseErrorclasses +globalErrorHandlermiddleware in multiple services, and refactor controllers/services to throw typed errors instead of ad-hocError/per-controller try/catch. - Update client Pinia stores and composables to return early (log + no throw) on non-OK API responses; rename push composable export to
useWebPushNotifications. - Add shared OpenAPI error spec (
landing-page/api/error.yaml) and refactor service OpenAPI docs to reference shared error responses; update Jest MongoDB test setup for parallelism.
Reviewed changes
Copilot reviewed 55 out of 56 changed files in this pull request and generated 23 comments.
Show a summary per file
| File | Description |
|---|---|
| server/twin-service/src/services/twinService.ts | Replace generic errors with typed ConflictError/NotFoundError; reuse getBuildingById in update. |
| server/twin-service/src/models/error.ts | Add typed error classes with HTTP status codes. |
| server/twin-service/src/middlewares/errorsMiddleware.ts | Add global error handler to serialize typed errors. |
| server/twin-service/src/index.ts | Register global error handler; remove swagger wiring (but swagger imports remain). |
| server/twin-service/src/controller/twinController.ts | Remove per-handler try/catch and rely on global error middleware. |
| server/twin-service/package.json | Add Jest globalSetup/globalTeardown. |
| server/twin-service/tests/twin.test.ts | Update expectations for new status codes/payload shape. |
| server/twin-service/tests/setup.ts | Switch to shared MongoMemoryServer via global setup and per-worker DB. |
| server/twin-service/tests/globalSetup.cjs | Start MongoMemoryServer once per Jest run. |
| server/twin-service/tests/globalTeardown.cjs | Stop MongoMemoryServer once per Jest run. |
| server/notification-service/src/services/pushService.ts | Throw NotFoundError when no subscriptions rather than logging/returning. |
| server/notification-service/src/services/notificationService.ts | Remove try/catch/logging around Redis publish; keep periodic loop. |
| server/notification-service/src/models/error.ts | Add typed error classes with HTTP status codes. |
| server/notification-service/src/middlewares/errorsMiddleware.ts | Add global error handler to serialize typed errors. |
| server/notification-service/src/index.ts | Register global error handler; remove swagger wiring (but swagger imports remain). |
| server/notification-service/src/controller/notificationController.ts | Replace inline 400/500 responses with typed errors for validation. |
| server/notification-service/package.json | Add Jest globalSetup/globalTeardown. |
| server/notification-service/openapi.yaml | Remove service-local OpenAPI spec. |
| server/notification-service/tests/setup.ts | Switch to shared MongoMemoryServer via global setup and per-worker DB. |
| server/notification-service/tests/notification.test.ts | Update mocked typing + new error response shape assertions. |
| server/notification-service/tests/globalSetup.cjs | Start MongoMemoryServer once per Jest run. |
| server/notification-service/tests/globalTeardown.cjs | Stop MongoMemoryServer once per Jest run. |
| server/auth-service/src/services/totpService.ts | Refactor OTP role resolution to throw typed errors instead of returning null. |
| server/auth-service/src/services/tokenService.ts | Replace generic errors with typed errors for missing secret/account. |
| server/auth-service/src/services/domainService.ts | Replace generic errors with typed errors; formatting/typing tweaks. |
| server/auth-service/src/services/authenticationService.ts | Replace generic errors with typed errors across auth/SSO flows. |
| server/auth-service/src/router.ts | Update role middleware import path. |
| server/auth-service/src/models/role.ts | Introduce new role/authorization middleware module. |
| server/auth-service/src/models/error.ts | Add typed error classes with HTTP status codes. |
| server/auth-service/src/models/domain.ts | Update role import path. |
| server/auth-service/src/middlewares/errorsMiddleware.ts | Add global error handler to serialize typed errors. |
| server/auth-service/src/index.ts | Register global error handler (but still mounts swagger route). |
| server/auth-service/src/controller/domainController.ts | Remove per-handler try/catch and rely on global error middleware. |
| server/auth-service/src/controller/authenticationMiddleware.ts | Convert auth/hmac failures to typed throws. |
| server/auth-service/src/controller/authenticationController.ts | Remove per-handler try/catch and rely on global error middleware (incl. SSO callback). |
| server/auth-service/src/controller/administrationController.ts | Remove try/catch; adjust enterprise provisioning flow. |
| server/auth-service/package.json | Add Jest globalSetup/globalTeardown. |
| server/auth-service/openapi.yaml | Remove service-local OpenAPI spec. |
| server/auth-service/tests/setup.ts | Switch to shared MongoMemoryServer via global setup and per-worker DB. |
| server/auth-service/tests/globalSetup.cjs | Start MongoMemoryServer once per Jest run. |
| server/auth-service/tests/globalTeardown.cjs | Stop MongoMemoryServer once per Jest run. |
| server/auth-service/tests/domain.test.ts | Update domain/subdomain expectations. |
| landing-page/index.html | Add link to shared error OpenAPI spec. |
| landing-page/api/twin.yaml | Reference shared error responses. |
| landing-page/api/notification.yaml | Reference shared error responses. |
| landing-page/api/error.yaml | Add shared error response/spec definitions. |
| landing-page/api/auth.yaml | Reference shared error responses (but introduces indentation issues). |
| client/src/stores/domain.ts | Return empty lists on non-OK responses instead of using error payload. |
| client/src/stores/authentication.ts | Stop throwing on non-OK login/register; log and early return. |
| client/src/stores/tests/authentication.spec.ts | Update expectations to “no throw” behavior. |
| client/src/composables/useWebPushNotifications.ts | Rename composable + switch to makeRequest; log-and-return on errors. |
| client/src/composables/useBuildingModel.ts | Remove outdated comment. |
| client/src/composables/tests/useWebPushNotifications.spec.ts | Update imports/name and expectations to match new composable. |
| client/src/components/cards/tests/DomainCard.spec.ts | Update mocked helper path + expected role text (currently inconsistent). |
| client/src/components/tests/PushNotificationToast.spec.ts | Update mock target to new composable name. |
| client/src/components/PushNotificationToast.vue | Update to new composable name/import. |
Comments suppressed due to low confidence (1)
server/notification-service/src/services/notificationService.ts:19
setInterval(async () => { ... })does not handle rejected promises. With the changes in this PR (e.g.,sendPushToAllnow throws on no subscriptions andpublishNotificationno longer catches Redis failures), this interval callback can trigger unhandled promise rejections (and potentially terminate the process depending on Node settings). Wrap the interval body in a try/catch (and decide which errors are expected vs. fatal) to keep the loop robust.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| app.use("/", router); | ||
| app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); | ||
| app.use(globalErrorHandler); |
There was a problem hiding this comment.
swaggerDocument is loaded from ./openapi.yaml and /api-docs is still mounted, but this PR deletes server/auth-service/openapi.yaml. This will crash on startup when YAML.load runs. Either restore/replace the service-local OpenAPI file, or remove the YAML load + /api-docs wiring from this service to match the other services.
| return res.status(err.code).json({ | ||
| type: err.type, | ||
| message: err.message, | ||
| }); | ||
| } | ||
|
|
||
| return res.status(500).json({ |
There was a problem hiding this comment.
The standardized error response produced here only returns { type, message }, but the new shared OpenAPI error contract (landing-page/api/error.yaml) requires code as well. To keep runtime responses aligned with the published contract, include an explicit code field in this JSON (and ensure its type matches the OpenAPI schema), or update the shared contract to match what the services actually return.
| return res.status(err.code).json({ | |
| type: err.type, | |
| message: err.message, | |
| }); | |
| } | |
| return res.status(500).json({ | |
| return res.status(err.code).json({ | |
| code: err.code, | |
| type: err.type, | |
| message: err.message, | |
| }); | |
| } | |
| return res.status(500).json({ | |
| code: 500, |
| const signature = req.headers["x-signature"]; | ||
| const secret = getAdminSecret(); | ||
|
|
||
| if (!secret) { | ||
| return res.status(500).json({ error: "Server configuration error" }); | ||
| throw new InternalError("Server configuration error, missing administration secrets"); | ||
| } | ||
|
|
||
| if (!signature || typeof signature !== "string") { | ||
| return res.status(401).json({ error: "Forbidden: missing signature" }); | ||
| throw new ForbiddenError(`Invalid signature`); |
There was a problem hiding this comment.
This middleware reads the signature from req.headers["x-signature"], but the OpenAPI spec for business registration documents x-hmac-signature. This mismatch will cause clients to send a header the server ignores. Consider standardizing on one name (preferably the documented one), or temporarily accepting both headers for backward compatibility.
| properties: | ||
| error: | ||
| type: string | ||
| $ref: "error.yaml#/components/responses/BadRequest" |
There was a problem hiding this comment.
Twin service POST /register now throws ConflictError (HTTP 409) when a building already exists, but this OpenAPI doc only defines a 400 error response. Add a 409/Conflict response (and define it in error.yaml if needed) so the spec matches runtime behavior.
| $ref: "error.yaml#/components/responses/BadRequest" | |
| $ref: "error.yaml#/components/responses/BadRequest" | |
| "409": | |
| $ref: "error.yaml#/components/responses/Conflict" |
| })) | ||
|
|
||
| vi.mock('@/helpers/roles.ts', () => ({ | ||
| vi.mock('@/helpers/role.ts', () => ({ |
There was a problem hiding this comment.
DomainCard.vue imports getRoleMeta from @/helpers/roles.ts, but this test mocks @/helpers/role.ts. The mock won't apply, and the test will use the real helper (or fail to resolve the mocked module). Update the mocked path to match the component import.
| vi.mock('@/helpers/role.ts', () => ({ | |
| vi.mock('@/helpers/roles.ts', () => ({ |
| // Pass full URL to service to handle state/code extraction | ||
| const clientRedirect = await AuthService.processSSOCallback(req.originalUrl); | ||
| res.redirect(clientRedirect); |
There was a problem hiding this comment.
handleSSOCallback no longer catches errors from processSSOCallback. Previously it always redirected the browser back to the client with an error=sso_failed query param; now failures will be handled by the global error middleware (JSON 4xx/5xx), which breaks the expected browser redirect flow from the IdP callback. Consider restoring the try/catch + redirect-on-failure behavior for this endpoint, even if other endpoints rely on global error handling.
| // Pass full URL to service to handle state/code extraction | |
| const clientRedirect = await AuthService.processSSOCallback(req.originalUrl); | |
| res.redirect(clientRedirect); | |
| try { | |
| // Pass full URL to service to handle state/code extraction | |
| const clientRedirect = await AuthService.processSSOCallback(req.originalUrl); | |
| res.redirect(clientRedirect); | |
| } catch (error) { | |
| // Ensure SSO callback always results in a browser redirect, even on failure | |
| // Redirect back to client with a standard error indicator | |
| console.error("SSO callback processing failed:", error); | |
| res.redirect("/?error=sso_failed"); | |
| } |
| if (!domains || domains.length <= 0) { | ||
| throw new NotFoundError("Domain or SSO configuration not found"); | ||
| } |
There was a problem hiding this comment.
resolveRoleFromOTP throws NotFoundError (404) for cases like "no domains configured" and "invalid OTP". These are validation/configuration problems rather than missing resources, and 404 will be confusing for clients (and for the shared error responses). Consider using ValidationError for an invalid OTP (400) and a distinct server/config error (500) when TOTP/SSO configuration is missing.
| it('throws when the response is not ok', async () => { | ||
| vi.mocked(makeRequest).mockResolvedValue(makeResponse(false) as unknown as Response) | ||
|
|
||
| await expect(useAuthStore().register('bob', 'bob@example.com', 'pass')).rejects.toThrow( | ||
| 'Registration failed', | ||
| vi.mocked(makeRequest).mockResolvedValue( | ||
| makeResponse(false, { | ||
| type: 'AuthError', | ||
| message: 'User already exists', | ||
| }) as unknown as Response, | ||
| ) | ||
| await expect( | ||
| useAuthStore().register('bob', 'bob@example.com', 'pass'), | ||
| ).resolves.toBeUndefined() |
There was a problem hiding this comment.
This test case name still says "throws when the response is not ok", but the updated expectations now assert the action resolves without throwing. Rename the test to reflect the new contract (early return / no state mutation) to avoid misleading future readers.
| schema: | ||
| $ref: "#/components/schemas/Account" | ||
| "401": | ||
| description: Unauthorized | ||
| $ref: "error.yaml#/components/responses/Unauthorized" |
There was a problem hiding this comment.
Same indentation issue for the 401 response under /me: $ref is indented too far, which can invalidate the OpenAPI YAML structure. Ensure $ref is at the correct level directly under the "401" response object.
| res.status(500).json({ message: "Error creating administrator account", error: error.message }); | ||
| if (!enterpriseDomain) { | ||
| throw new NotFoundError(`Enterprise domain with name "${companyName}" not found`); | ||
| } |
There was a problem hiding this comment.
provideEnterpriseAdministratorAccount creates an account and looks up the enterprise domain, but on the success path it never sends a response (no res.status(...).json(...), res.send(), or next()). This will leave the HTTP request hanging. Add a success response (and any missing domain membership wiring) or remove the unfinished endpoint until it is implemented.
| } | |
| } | |
| const token = await generateStandardToken({ | |
| accountId: createdAccount._id.toString(), | |
| accountName: createdAccount.name, | |
| } as StandardTokenPayload); | |
| res.status(201).json({ | |
| message: `Administrator account "${createdAccount.name}" for company "${companyName}" created successfully`, | |
| token, | |
| account: { | |
| accountName: createdAccount.name, | |
| }, | |
| }); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 62 out of 64 changed files in this pull request and generated 12 comments.
Comments suppressed due to low confidence (1)
server/notification-service/src/services/notificationService.ts:19
- The notification loop runs an
asyncfunction insidesetIntervalwithout any error handling. With the new behavior (e.g.,sendPushToAllthrowing when there are no subscriptions, Redis publish failures, etc.), this can create unhandled promise rejections and potentially crash the process or spam logs. Wrap the interval body in a try/catch (and/or ensuresendPushToAllreturns gracefully for expected conditions) so the loop is resilient.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import jwt, { type JwtPayload } from "jsonwebtoken"; | ||
|
|
There was a problem hiding this comment.
JwtPayload is imported but not used. Removing it will keep the module tidy and avoid confusion about expected return types from verifyToken.
| "401": | ||
| description: Unauthorized | ||
| $ref: "error.yaml#/components/responses/Unauthorized" | ||
|
|
There was a problem hiding this comment.
Same indentation issue for the "401" response under /me: $ref is over-indented, making the OpenAPI YAML invalid.
|
|
||
| req.account = TokenService.verifyToken(token); | ||
| next(); |
There was a problem hiding this comment.
TokenService.verifyToken can throw (expired/invalid JWT). With the current code that error will bubble into the global handler as a 500, which is incorrect for auth failures. Wrap verification in a try/catch and rethrow an UnauthorizedError (or similar) so invalid/expired cookies return 401.
| const account = await Account.findOne({ name: accountName }); | ||
|
|
||
| if (!account) { | ||
| throw new Error("wrong account name"); | ||
| throw new NotFoundError("Account with this name doesn't exist"); | ||
| } |
There was a problem hiding this comment.
For login, returning a distinct NotFoundError (404) when the account doesn’t exist diverges from the OpenAPI contract (401) and makes user enumeration easier. Consider using a uniform UnauthorizedError for invalid credentials instead.
| if (!domains || domains.length <= 0) { | ||
| throw new NotFoundError("Domain or SSO configuration not found"); | ||
| } |
There was a problem hiding this comment.
If no domains/TOTP secrets are configured, this is a server-side configuration/state problem rather than a missing resource requested by the client. Returning NotFoundError (404) here is misleading; consider using a 500-class error (e.g., InternalError).
| expect(domain.subdomains).toHaveLength(0); | ||
|
|
||
| const subdomainNames = await getDomainSubdomains(mockDomain.name); | ||
| expect(subdomainNames).toEqual( | ||
| expect.arrayContaining([ | ||
| `studio.${mockDomain.name}`, | ||
| `docenti.${mockDomain.name}`, | ||
| ]), | ||
| ); | ||
| expect(subdomainNames).toEqual(expect.arrayContaining([])); | ||
| }); |
There was a problem hiding this comment.
This test no longer creates any subdomains, and the assertion arrayContaining([]) will always pass. This weakens coverage for the getDomainSubdomains graph lookup behavior. Consider restoring creation/attachment of real subdomain documents and asserting that their names are returned.
| throw new NotFoundError("Invalid OTP provided"); | ||
| }; |
There was a problem hiding this comment.
An invalid OTP is not a “not found” resource. Returning NotFoundError (404) for bad OTPs is misleading for clients; consider a 400/401-class error (e.g., ValidationError/UnauthorizedError).
| required: | ||
| - type | ||
| - code | ||
| - message | ||
| properties: |
There was a problem hiding this comment.
Error schema requires a code field (string), but the server error middleware currently returns only { type, message } and uses numeric codes as HTTP status. Either add a machine-readable code field to error responses, or update this schema to match actual responses (and don’t require code if it’s not returned).
| import swaggerUi from 'swagger-ui-express'; | ||
| import YAML from "yamljs"; |
There was a problem hiding this comment.
After removing Swagger setup/routes from this service, swagger-ui-express and yamljs are now unused imports. Consider removing them to avoid loading unnecessary dependencies at runtime.
| const match = await bcrypt.compare(password, account.password); | ||
| if (!match) { | ||
| throw new Error("wrong password"); | ||
| throw new ValidationError("Invalid password"); | ||
| } |
There was a problem hiding this comment.
Invalid passwords currently throw ValidationError (400). For authentication endpoints this should typically be UnauthorizedError (401) to match the API contract and avoid leaking which part of the credentials was wrong.
490cf9a to
e2776d7
Compare
|
🎉 This PR is included in version 2.6.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Replace throw-on-HTTP-error patterns with early returns throughout the client composables and Pinia stores, aligning error handling with a consistent log-and-return strategy instead of propagating exceptions to callers for expected API failures.
Changes
Close #136
Enhance #133