Skip to content

Introduce reusable errors to use across APIs#2445

Open
snehagunta wants to merge 3 commits intoRedHatInsights:masterfrom
snehagunta:RHCLOUD-44949-reorganize-errors
Open

Introduce reusable errors to use across APIs#2445
snehagunta wants to merge 3 commits intoRedHatInsights:masterfrom
snehagunta:RHCLOUD-44949-reorganize-errors

Conversation

@snehagunta
Copy link
Copy Markdown
Collaborator

@snehagunta snehagunta commented Feb 7, 2026

Link(s) to Jira

Description of Intent of Change(s)

Creating some generic errors that can be used for most cases instead of specific errors

Local Testing

How can the feature be exercised?
How can the bug be exploited and fix confirmed?
Is any special local setup required?

Checklist

  • if API spec changes are required, is the spec updated?
  • are there any pre/post merge actions required? if so, document here.
  • are theses changes covered by unit tests?
  • if warranted, are documentation changes accounted for?
  • does this require migration changes?
    • if yes, are they backwards compatible?
  • is there known, direct impact to dependent teams/components?
    • if yes, how will this be handled?

Secure Coding Practices Checklist Link

Secure Coding Practices Checklist

  • Input Validation
  • Output Encoding
  • Authentication and Password Management
  • Session Management
  • Access Control
  • Cryptographic Practices
  • Error Handling and Logging
  • Data Protection
  • Communication Security
  • System Configuration
  • Database Security
  • File Management
  • Memory Management
  • General Coding Practices

Summary by Sourcery

Introduce a shared DomainError hierarchy for management-layer errors and wire it into role and permission APIs so they are surfaced as RFC 7807-style problem responses.

New Features:

  • Add a reusable DomainError base class and common domain exceptions (validation, not-found, conflict, database) with HTTP-oriented metadata for API error responses.

Enhancements:

  • Refactor role and permission domain logic to use the new shared validation and conflict exceptions instead of ad-hoc, service-specific error types.
  • Update the v2 API exception handler to convert DomainError instances into structured problem+json responses, including field-level error details when available.
  • Simplify the role request serializer to delegate error translation to the centralized exception handler.
  • Tidy Typespec API documentation examples with improved indentation and formatting.

Documentation:

  • Refresh API Typespec examples for roles and role bindings to match current formatting and structure.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Feb 7, 2026

Reviewer's Guide

Introduces a shared DomainError hierarchy for reusable, RFC7807-style errors and refactors role/permission services, serializers, and models to use these domain exceptions, while wiring them into the v2 API exception handler and fixing minor API spec formatting issues.

Sequence diagram for Role create flow with DomainError handling

sequenceDiagram
    actor Client
    participant DRFView as DRF_View
    participant RoleV2RequestSerializer as RoleRequestSerializer
    participant RoleV2Service as RoleService
    participant PermissionService as PermissionService
    participant CustomRoleV2 as RoleModel
    participant ExceptionHandler as custom_exception_handler_v2

    Client->>DRFView: POST /v2/roles
    DRFView->>RoleRequestSerializer: instantiate(data)
    DRFView->>RoleRequestSerializer: is_valid()
    DRFView->>RoleRequestSerializer: save()
    RoleRequestSerializer->>RoleService: create(name, description, permission_data, tenant)

    alt missing_required_fields
        RoleService-->>RoleService: validate description, permissions
        RoleService-->>RoleService: MissingRequiredFieldError raised
        RoleService-->>DRFView: MissingRequiredFieldError propagates
        DRFView->>ExceptionHandler: custom_exception_handler_v2(exc, context)
        ExceptionHandler-->>ExceptionHandler: build Problem Details body
        ExceptionHandler-->>Client: 400 response
    else invalid_permissions
        RoleService->>PermissionService: resolve(permission_data)
        PermissionService-->>PermissionService: InvalidFieldError or MissingRequiredFieldError raised
        PermissionService-->>RoleService: exception propagates
        RoleService-->>RoleService: wrap as InvalidFieldError(field=permissions)
        RoleService-->>DRFView: InvalidFieldError propagates
        DRFView->>ExceptionHandler: custom_exception_handler_v2(exc, context)
        ExceptionHandler-->>Client: 400 Problem Details with errors[field]
    else name_conflict
        RoleService->>RoleModel: save()
        RoleModel-->>RoleService: ValidationError or IntegrityError
        RoleService-->>RoleService: raise AlreadyExistsError(resource_type=role)
        RoleService-->>DRFView: AlreadyExistsError propagates
        DRFView->>ExceptionHandler: custom_exception_handler_v2(exc, context)
        ExceptionHandler-->>Client: 409 Problem Details
    else unexpected_db_error
        RoleService->>RoleModel: save()
        RoleModel-->>RoleService: IntegrityError
        RoleService-->>RoleService: raise DatabaseError(operation_context=Create Role)
        RoleService-->>DRFView: DatabaseError propagates
        DRFView->>ExceptionHandler: custom_exception_handler_v2(exc, context)
        ExceptionHandler-->>Client: 500 Problem Details
    end
Loading

Class diagram for new DomainError hierarchy and usage

classDiagram
    class DomainError {
        <<exception>>
        int status_code
        str title
        str operation_context
        DomainError(message: str, operation_context: str)
    }

    class MissingRequiredFieldError {
        <<exception>>
        str field
        MissingRequiredFieldError(field: str, operation_context: str)
    }

    class InvalidFieldError {
        <<exception>>
        str field
        Any rejected_value
        InvalidFieldError(field: str, message: str, operation_context: str, rejected_value: Any)
    }

    class NotFoundError {
        <<exception>>
        str resource_type
        str resource_id
        NotFoundError(resource_type: str, resource_id: str, operation_context: str)
    }

    class AlreadyExistsError {
        <<exception>>
        str resource_type
        str identifier
        AlreadyExistsError(resource_type: str, identifier: str, operation_context: str)
    }

    class InUseError {
        <<exception>>
        str resource_type
        int reference_count
        InUseError(resource_type: str, reference_count: int, operation_context: str)
    }

    class ImmutableError {
        <<exception>>
        str resource_type
        str reason
        ImmutableError(resource_type: str, reason: str, operation_context: str)
    }

    class DatabaseError {
        <<exception>>
        DatabaseError(operation_context: str)
    }

    DomainError <|-- MissingRequiredFieldError
    DomainError <|-- InvalidFieldError
    DomainError <|-- NotFoundError
    DomainError <|-- AlreadyExistsError
    DomainError <|-- InUseError
    DomainError <|-- ImmutableError
    DomainError <|-- DatabaseError

    class PermissionValue {
        +from_v2_dict(data: dict) PermissionValue
    }

    class RoleV2Service {
        +create(name: str, description: str, permission_data: list, tenant: Tenant) CustomRoleV2
    }

    class CustomRoleV2 {
        +clean() void
        +save() void
    }

    class PermissionService {
        +resolve(permission_data: list) list
    }

    class Tenant

    RoleV2Service --> PermissionService : uses
    RoleV2Service --> CustomRoleV2 : creates
    RoleV2Service --> Tenant : scoped_by
    PermissionValue ..> InvalidFieldError : raises
    PermissionValue ..> MissingRequiredFieldError : raises
    CustomRoleV2 ..> MissingRequiredFieldError : raises
    RoleV2Service ..> MissingRequiredFieldError : raises
    RoleV2Service ..> InvalidFieldError : raises
    RoleV2Service ..> AlreadyExistsError : raises
    RoleV2Service ..> DatabaseError : raises
Loading

File-Level Changes

Change Details Files
Introduce a reusable DomainError hierarchy for domain-layer exceptions, replacing ad-hoc errors like RequiredFieldError and role-specific exceptions.
  • Add DomainError base class with HTTP status_code, title, and optional operation_context metadata.
  • Implement specific subclasses for common API error conditions: MissingRequiredFieldError, InvalidFieldError, NotFoundError, AlreadyExistsError, InUseError, ImmutableError, and DatabaseError.
  • Ensure each subclass formats a clear, user-facing message while storing structured context (e.g., field, resource_type, identifier, reference_count).
rbac/management/exceptions.py
Refactor role service to use generic DomainError exceptions and propagate richer context for role creation failures.
  • Update RoleV2Service to import and raise AlreadyExistsError, DatabaseError, InvalidFieldError, and MissingRequiredFieldError instead of RequiredFieldError and role-specific exceptions.
  • Introduce OPERATION_CREATE_ROLE constant and pass it as operation_context when raising domain errors for create-role operations.
  • Map permission-layer MissingRequiredFieldError/InvalidFieldError to a role-level InvalidFieldError on the permissions field, and convert missing permissions into InvalidFieldError instead of PermissionsNotFoundError.
  • Normalize uniqueness and database failures into AlreadyExistsError or DatabaseError with appropriate messages and context.
rbac/management/role/v2_service.py
Simplify the RoleV2 serializer by letting DomainError-based exceptions be handled centrally instead of mapping domain errors to serializer.ValidationError.
  • Remove ERROR_MAPPING and per-exception handling in RoleV2RequestSerializer.create.
  • Call the RoleV2Service.create method directly and rely on the global exception handler to convert DomainError instances into HTTP/problem responses.
  • Align docstrings/comments with the new generic Role API naming.
rbac/management/role/v2_serializer.py
Align permission domain exceptions with the new DomainError hierarchy and update permission model validations accordingly.
  • Replace PermissionError/InvalidPermissionDataError with re-exports of InvalidFieldError and MissingRequiredFieldError in permission exceptions module for use by permission-related code.
  • Update PermissionValue.from_v2_dict to raise InvalidFieldError or MissingRequiredFieldError with appropriate field names when permission data is malformed or missing required fields.
rbac/management/permission/exceptions.py
rbac/management/permission/model.py
Integrate DomainError handling into the v2 API exception handler to emit RFC7807-style responses with optional field-level error details.
  • Extend custom_exception_handler_v2 to detect DomainError instances and build a problem+json response containing status, title, and detail (including operation_context prefix when present).
  • Include an errors array with field/message pairs for exceptions that expose a field attribute, enabling structured validation error reporting.
rbac/api/common/exception_handler.py
Update role model validation to use the new MissingRequiredFieldError instead of the old RequiredFieldError.
  • Change RoleV2.clean to raise MissingRequiredFieldError when name is blank or missing, ensuring consistency with other required-field validations.
rbac/management/role/v2_model.py
Tidy TypeSpec API documentation examples for role bindings and roles.
  • Fix indentation and alignment of example request/response blocks for RoleBindings and Roles namespaces to improve readability and consistency in the generated API documentation.
rbac/docs/source/specs/typespec/main.tsp

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In api/common/exception_handler.py, DomainError is referenced in custom_exception_handler_v2 but not imported in the file, which will cause a NameError at runtime—add the appropriate import from management.exceptions.
  • The indentation changes in docs/source/specs/typespec/main.tsp (e.g., around resource: and id: fields in examples) look inconsistent with the surrounding code and may reduce readability; consider re-running your formatter or aligning indentation with the existing style.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `api/common/exception_handler.py`, `DomainError` is referenced in `custom_exception_handler_v2` but not imported in the file, which will cause a NameError at runtime—add the appropriate import from `management.exceptions`.
- The indentation changes in `docs/source/specs/typespec/main.tsp` (e.g., around `resource:` and `id:` fields in examples) look inconsistent with the surrounding code and may reduce readability; consider re-running your formatter or aligning indentation with the existing style.

## Individual Comments

### Comment 1
<location> `rbac/management/role/v2_service.py:72-79` </location>
<code_context>
             requested = {PermissionValue.from_v2_dict(p).v1_string() for p in permission_data}
-        except InvalidPermissionDataError as e:
-            raise InvalidRolePermissionsError(str(e))
+        except (InvalidFieldError, MissingRequiredFieldError) as e:
+            # Convert permission-level exceptions to role context with "permissions" as the field
+            field = getattr(e, "field", None)
+            message = str(e) if field == "permissions" else f"Permission field error: {e}"
+            raise InvalidFieldError("permissions", message, OPERATION_CREATE_ROLE)

         found = {p.permission for p in permissions}
</code_context>

<issue_to_address>
**suggestion:** Preserve original exception context when remapping permission errors to a role-level InvalidFieldError.

Catching `InvalidFieldError`/`MissingRequiredFieldError` and re-raising a new `InvalidFieldError("permissions", ...)` discards the original exception context (stack trace, `rejected_value`, etc.), which makes root-cause debugging harder. Please chain the original exception (e.g. `raise InvalidFieldError("permissions", message, OPERATION_CREATE_ROLE) from e`) and/or copy over `rejected_value` from `e` while still normalizing the field to `"permissions"`.

```suggestion
        try:
            permissions = self.permission_service.resolve(permission_data)
            requested = {PermissionValue.from_v2_dict(p).v1_string() for p in permission_data}
        except (InvalidFieldError, MissingRequiredFieldError) as e:
            # Convert permission-level exceptions to role context with "permissions" as the field,
            # while preserving original context and rejected value for debugging.
            field = getattr(e, "field", None)
            rejected_value = getattr(e, "rejected_value", None)
            message = str(e) if field == "permissions" else f"Permission field error: {e}"
            raise InvalidFieldError(
                "permissions",
                message,
                OPERATION_CREATE_ROLE,
                rejected_value=rejected_value,
            ) from e
```
</issue_to_address>

### Comment 2
<location> `rbac/management/role/v2_service.py:75` </location>
<code_context>
             requested = {PermissionValue.from_v2_dict(p).v1_string() for p in permission_data}
-        except InvalidPermissionDataError as e:
-            raise InvalidRolePermissionsError(str(e))
+        except (InvalidFieldError, MissingRequiredFieldError) as e:
+            # Convert permission-level exceptions to role context with "permissions" as the field
+            field = getattr(e, "field", None)
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the error handling in the permission resolution `except (InvalidFieldError, MissingRequiredFieldError) as e:` block by uniformly wrapping permission-layer errors into a single role-level `InvalidFieldError` without conditional introspection.

The `except (InvalidFieldError, MissingRequiredFieldError) as e:` block is doing conditional introspection and re-wrapping domain errors into the same type, which increases complexity without adding much value.

You can keep the behavior of “map permission errors into a role permissions context with operation info” while simplifying the flow and centralizing the responsibility in this layer, e.g.:

```python
try:
    permissions = self.permission_service.resolve(permission_data)
    requested = {PermissionValue.from_v2_dict(p).v1_string() for p in permission_data}
except (InvalidFieldError, MissingRequiredFieldError) as e:
    # Wrap permission-level errors in a single, role-level context
    raise InvalidFieldError(
        "permissions",
        f"Invalid permissions data: {e}",
        OPERATION_CREATE_ROLE,
    ) from e
```

This removes:

- `getattr(e, "field", None)` probing
- Branching on `field == "permissions"`
- Reinterpretation of the underlying message format

while still:

- Converting permission-layer errors into a consistent `"permissions"` role-field error
- Preserving the operation context for the global exception handler.

If you later want even more cohesion, consider pushing the mapping into the global exception handler and letting the permission-layer `InvalidFieldError`/`MissingRequiredFieldError` bubble up unchanged from here.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread rbac/management/role/v2_service.py Outdated
requested = {PermissionValue.from_v2_dict(p).v1_string() for p in permission_data}
except InvalidPermissionDataError as e:
raise InvalidRolePermissionsError(str(e))
except (InvalidFieldError, MissingRequiredFieldError) as e:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider simplifying the error handling in the permission resolution except (InvalidFieldError, MissingRequiredFieldError) as e: block by uniformly wrapping permission-layer errors into a single role-level InvalidFieldError without conditional introspection.

The except (InvalidFieldError, MissingRequiredFieldError) as e: block is doing conditional introspection and re-wrapping domain errors into the same type, which increases complexity without adding much value.

You can keep the behavior of “map permission errors into a role permissions context with operation info” while simplifying the flow and centralizing the responsibility in this layer, e.g.:

try:
    permissions = self.permission_service.resolve(permission_data)
    requested = {PermissionValue.from_v2_dict(p).v1_string() for p in permission_data}
except (InvalidFieldError, MissingRequiredFieldError) as e:
    # Wrap permission-level errors in a single, role-level context
    raise InvalidFieldError(
        "permissions",
        f"Invalid permissions data: {e}",
        OPERATION_CREATE_ROLE,
    ) from e

This removes:

  • getattr(e, "field", None) probing
  • Branching on field == "permissions"
  • Reinterpretation of the underlying message format

while still:

  • Converting permission-layer errors into a consistent "permissions" role-field error
  • Preserving the operation context for the global exception handler.

If you later want even more cohesion, consider pushing the mapping into the global exception handler and letting the permission-layer InvalidFieldError/MissingRequiredFieldError bubble up unchanged from here.

@lpichler
Copy link
Copy Markdown
Contributor

@snehagunta this needs to solve merge conflicts.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants