Exceptions
Overview
Exceptions are the errors occur during app execution.
Exception Handling is how the app manages these errors and the flow control.
Mahaam Exceptions
Mahaam defined 5 type of exceptions:
UnauthorizedException
: Identity violation -401 http status
ForbiddenException
: Role violation -403 http status
InputException
: Invalid input -400 http status
NotFoundException
: Resource not found -404 http status
LogicException
: Business logic violation -409 http status
Example
// In TaskService.cs
public Guid Create(Guid planId, string title)
{
var count = App.TaskRepo.GetCount(planId);
if (count >= 100) throw new LogicException("max_is_100", "Max is 100");
//... rest of create logic
}
// In TaskService.java
@Override
@Transactional
public UUID create(UUID planId, String title) {
var count = taskRepo.getCount(planId);
if (count >= 100) {
throw new LogicException("max_is_100", "Max is 100");
}
//... rest of create logic
}
// In task_service.go
func (s *taskService) Create(planID UUID, title string) UUID {
count := s.taskRepo.GetCount(planID)
if count >= 100 {
panic(models.LogicErr("maximum number of tasks is 100", "max_is_100"))
}
//... rest of create logic
}
// In tasks.service.ts
async create(planId: string, title: string): Promise<string> {
return await DB.withTrx(async (trx) => {
const count = await this.tasksRepo.getCount(trx, planId);
if (count >= 100) throw new LogicError('max_is_100', 'Max is 100');
//... rest of create logic
});
}
# In task_service.py
def create(self, plan_id: UUID, title: str) -> UUID:
with db.DB.transaction_scope() as conn:
count = self.task_repo.get_count(plan_id, conn)
if count >= 100:
raise LogicException("max_is_100", "Max is 100")
#... rest of create logic
Handling
Exception handling in software is generally scoped per library. This means:
- When a request calls a library (e.g., lib X), the library attempts to handle the logic internally.
- If an exception occurs within the library, the internal flow breaks and control is passed up to the top-level interface of the library.
- The top-level interface is responsible for returning a meaningful response to the caller.
Exceptions should be captured and logged, either at the source or, at the top level.
In Mahaam, any layer (repo, service, or controller) can throw an exception. Mahaam does not log or handle exceptions at their origin, instead when exception happens, it breaks the request flow, till reach the top level Exception Handler Middleware where it is logged and handled.
Purpose
This approach enhances code readability and maintainability by:
- Centralizing logging and response formatting.
- Making the codebase cleaner and easier to debug.
Example
[HttpPatch]
[Route("{id}/unshare")]
public IActionResult Unshare(Guid id, [FromForm] string email)
{
Rule.Required(id, "id");
Rule.Required(email, "email");
App.PlanService.Unshare(id, email);
return StatusCode(Http.Ok);
}
@PatchMapping("/{id}/unshare")
public ResponseEntity<Void> unshare(@PathVariable UUID id, @RequestParam String email) {
Rule.required(id, "id");
Rule.required(email, "email");
planService.unshare(id, email);
return ResponseEntity.ok().build();
}
func (h *PlanHandler) Unshare(c *gin.Context) {
id := c.Param("id")
email := c.PostForm("email")
Rule.Required(id, "id")
Rule.Required(email, "email")
h.planService.Unshare(id, email)
c.Status(http.StatusOK)
}
@Patch(':id/unshare')
async unshare(@Param('id') id: string, @Body('email') email: string) {
Rule.required(id, 'id');
Rule.required(email, 'email');
this.planService.unshare(id, email);
return { status: HttpStatus.OK };
}
@router.patch("/{id}/unshare")
def unshare(id: str, email: str = Form(...)):
Rule.required(id, "id")
Rule.required(email, "email")
plan_service.unshare(id, email)
return {"status": HTTPStatus.OK}
Rule.Required
can throw an exception if the input is invalid.PlanService.Unshare
may also throw exceptions due to business logic or repository errors.
Exception Handler Middleware
Any exception raised in the Repo, Service, or Controller layers will propagate upward and be caught by the Exception Handler Middleware, which in role:
- Detect exception type
- Log exception details
- Return a proper response
private static async Task<string> HandleException(HttpContext context, Exception e)
{
var response = Json.Serialize(e.Message);
var code = Http.ServerError;
if (e is AppException)
{
var appException = e as AppException;
var key = appException!.Key;
code = appException.HttpCode;
if (!string.IsNullOrEmpty(key))
{
var res = new { key, error = e.Message };
response = Json.Serialize(res);
}
}
Log.Error(e.ToString());
context.Response.StatusCode = code;
context.Response.ContentType = Http.json;
await context.Response.WriteAsync(response);
return response;
}
@Provider
class ExceptionFilter implements ExceptionMapper<Throwable> {
@Override
public Response toResponse(Throwable exception) {
String response = Json.toString(exception.getMessage());
int statusCode = Http.ServerError;
exception.printStackTrace();
Log.error(exception.toString());
if (exception instanceof AppException) {
AppException appException = (AppException) exception;
statusCode = appException.getHttpCode();
String key = appException.getKey();
if (key != null && !key.isEmpty()) {
ErrorResponse errorResponse = new ErrorResponse(key, exception.getMessage());
response = Json.toString(errorResponse);
}
}
return Response.status(statusCode).entity(response).type(Http.JsonMedia).build();
}
}
func RecoveryMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Log the panic
trafficID, ok := c.Value("trafficID").(uuid.UUID)
if !ok || trafficID == uuid.Nil {
trafficID = uuid.Nil
}
if e, ok := err.(*models.HttpErr); ok {
logs.Error(trafficID, e.Error())
if e.Key == "" {
c.JSON(e.Code, e.Message)
} else {
c.JSON(e.Code, gin.H{"error": e.Message, "key": e.Key})
}
c.Abort()
return
} else {
logs.Error(trafficID, err)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
c.Abort()
}
}()
c.Next()
}
}
return next.handle().pipe(
catchError((err: unknown) => {
Log.error(err instanceof Error ? err.toString() : String(err));
const ctx = context.switchToHttp();
response = ctx.getResponse<Response>();
responseBody = JSON.stringify(err instanceof Error ? err.message : String(err));
let code = HttpStatus.INTERNAL_SERVER_ERROR;
if (err instanceof AppError) {
const appException = err as AppError;
const key = appException.key;
code = appException.httpCode;
if (key) {
const responseObj = { key, error: err.message };
responseBody = JSON.stringify(responseObj);
}
}
response.status(code).contentType('application/json');
response.send(responseBody);
return throwError(() => err);
}),
);
def handle_exception( e: Exception, traffic_id: uuid.UUID) -> tuple[str, int]:
response_status = http.SERVER_ERROR
res_body = json.dumps(str(e))
if isinstance(e, AppException):
response_status = e.http_code
if e.key:
res_body = json.dumps(
{"key": e.key, "error": str(e)})
log.Log.error(str(e), traffic_id=traffic_id)
return res_body, response_status
Go case
Go traditionally avoids exceptions in favor of returning errors. However, mahaam-api-go
follows the same exception handling pattern used in other Mahaam backends like mahaam-api-cs
, mahaam-api-java
, etc.
From the official Go blog on defer, panic, and recover:
For a real-world example of panic and recover, see the json package from the Go standard library. It encodes an interface with a set of recursive functions. If an error occurs when traversing the value, panic is called to unwind the stack to the top-level function call, which recovers from the panic and returns an appropriate error value...
This principle is applied similarly in Mahaam’s Go codebase, panics are used internally and recovered at the top level to transform them into structured error responses to the caller.