Skip to content

fix: standardize API error handling across services and composables#137

Merged
NickGhignatti merged 4 commits intomasterfrom
feat/errors-standardization
Mar 28, 2026
Merged

fix: standardize API error handling across services and composables#137
NickGhignatti merged 4 commits intomasterfrom
feat/errors-standardization

Conversation

@NickGhignatti
Copy link
Copy Markdown
Owner

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

  • useAuthStore: login and register now return early on non-ok responses instead of throwing, keeping error handling internal to the action
  • Rename usePush export to useWebPushNotifications for consistency
  • Updated tests which expected to throw errors
  • Create a standard openapi file for the errors
  • Performance update on backend microservices

Close #136
Enhance #133

@NickGhignatti NickGhignatti self-assigned this Mar 28, 2026
Copilot AI review requested due to automatic review settings March 28, 2026 21:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 BaseError classes + globalErrorHandler middleware in multiple services, and refactor controllers/services to throw typed errors instead of ad-hoc Error/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., sendPushToAll now throws on no subscriptions and publishNotification no 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.

Comment on lines 33 to +35
app.use("/", router);
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use(globalErrorHandler);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +17
return res.status(err.code).json({
type: err.type,
message: err.message,
});
}

return res.status(500).json({
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines 39 to +47
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`);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
properties:
error:
type: string
$ref: "error.yaml#/components/responses/BadRequest"
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$ref: "error.yaml#/components/responses/BadRequest"
$ref: "error.yaml#/components/responses/BadRequest"
"409":
$ref: "error.yaml#/components/responses/Conflict"

Copilot uses AI. Check for mistakes.
}))

vi.mock('@/helpers/roles.ts', () => ({
vi.mock('@/helpers/role.ts', () => ({
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
vi.mock('@/helpers/role.ts', () => ({
vi.mock('@/helpers/roles.ts', () => ({

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
// Pass full URL to service to handle state/code extraction
const clientRedirect = await AuthService.processSSOCallback(req.originalUrl);
res.redirect(clientRedirect);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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");
}

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +13
if (!domains || domains.length <= 0) {
throw new NotFoundError("Domain or SSO configuration not found");
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 187 to +196
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()
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 213 to +216
schema:
$ref: "#/components/schemas/Account"
"401":
description: Unauthorized
$ref: "error.yaml#/components/responses/Unauthorized"
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
res.status(500).json({ message: "Error creating administrator account", error: error.message });
if (!enterpriseDomain) {
throw new NotFoundError(`Enterprise domain with name "${companyName}" not found`);
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
}
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,
},
});

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 28, 2026 22:15
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 async function inside setInterval without any error handling. With the new behavior (e.g., sendPushToAll throwing 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 ensure sendPushToAll returns gracefully for expected conditions) so the loop is resilient.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to 2
import jwt, { type JwtPayload } from "jsonwebtoken";

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JwtPayload is imported but not used. Removing it will keep the module tidy and avoid confusion about expected return types from verifyToken.

Copilot uses AI. Check for mistakes.
Comment on lines 215 to 217
"401":
description: Unauthorized
$ref: "error.yaml#/components/responses/Unauthorized"

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same indentation issue for the "401" response under /me: $ref is over-indented, making the OpenAPI YAML invalid.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +31

req.account = TokenService.verifyToken(token);
next();
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 41 to 45
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");
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +13
if (!domains || domains.length <= 0) {
throw new NotFoundError("Domain or SSO configuration not found");
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +59 to 63
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([]));
});
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to 30
throw new NotFoundError("Invalid OTP provided");
};
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +15
required:
- type
- code
- message
properties:
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 3 to 4
import swaggerUi from 'swagger-ui-express';
import YAML from "yamljs";
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 47 to 50
const match = await bcrypt.compare(password, account.password);
if (!match) {
throw new Error("wrong password");
throw new ValidationError("Invalid password");
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@NickGhignatti NickGhignatti force-pushed the feat/errors-standardization branch from 490cf9a to e2776d7 Compare March 28, 2026 22:28
@NickGhignatti NickGhignatti merged commit a76436b into master Mar 28, 2026
13 checks passed
@NickGhignatti NickGhignatti deleted the feat/errors-standardization branch March 28, 2026 22:30
@NickGhignatti
Copy link
Copy Markdown
Owner Author

🎉 This PR is included in version 2.6.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error catching and logging

2 participants