8000 chore(refactor): Error Refactoring Guidelines (for Issues) by dorneanu · Pull Request #697 · cloudoperators/heureka · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

chore(refactor): Error Refactoring Guidelines 8000 (for Issues) #697

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

dorneanu
Copy link
Collaborator
@dorneanu 8000 dorneanu commented May 16, 2025

Description

Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change.

What type of PR is this? (check all applicable)

  • 🍕 Feature
  • 🐛 Bug Fix
  • 📝 Documentation Update
  • 🎨 Style
  • 🧑‍💻 Code Refactor
  • 🔥 Performance Improvements
  • ✅ Test
  • 🤖 Build
  • 🔁 CI
  • 📦 Chore (Release)
  • ⏩ Revert

Related Tickets & Documents

Remove if not applicable

Added tests?

  • 👍 yes
  • 🙅 no, because they aren't needed
  • 🙋 no, because I need help
  • Separate ticket for tests # (issue/pr)

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

Added to documentation?

  • 📜 README.md
  • 🤝 Documentation pages updated
  • 🙅 no documentation needed
  • (if applicable) generated OpenAPI docs for CRD changes

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

@dorneanu
Copy link
Collaborator Author
dorneanu commented May 20, 2025

So, a quick recap:

Error Types

1. Raw Database Errors

  • SQL errors
  • No rows found
  • Constraint violations
  • Connection issues

2. Application Errors

type Error struct {
    // Code is one of the error codes defined (e.g., NotFound, AlreadyExists)
    Code Code

    // Entity is the type of entity being operated on (e.g., "Issue")
    Entity string

    // ID is the ID of the entity
    ID string

    // Op is the operation being performed (e.
8000
g., "issueHandler.GetIssue")
    Op string

    // Message is a human-readable description of the error
    Message string

    // Err is the underlying error that triggered this one
    Err error

    // Fields contains additional structured metadata about the error
    Fields map[string]interface{}
}

3. API-Specific Errors (GraphQL Example)

type GraphQLError struct {
    Message    string                 `json:"message"`
    Extensions map[string]interface{} `json:"extensions,omitempty"`
}

Database Layer (Infrastructure)

Principle: Return raw errors without translation.

Example Implementation

// Database interface method
func (s *SqlDatabase) AddComponentVersionToIssue(issueId int64, componentVersionId int64) error {
    // Check if issue exists
    _, err := s.GetIssue(issueId)
    if err != nil {
        return err // Pass through the raw error
    }

    query := `
        INSERT INTO ComponentVersionIssue (
            componentversionissue_issue_id,
            componentversionissue_component_version_id
        ) VALUES (
            :issue_id,
            :component_version_id
        )
    `

    args := map[string]interface{}{
        "issue_id":             issueId,
        "component_version_id": componentVersionId,
    }

    _, err = performExec(s, query, args, l)

    return err // Return raw error
}

Key Points

  • Return raw errors directly
  • No error wrapping or translation
  • No knowledge of application error types
  • May define domain-specific database errors (e.g., DuplicateEntryDatabaseError)

Application Layer (Business Logic)

Principle: Translate database errors to rich application errors with context.

Error Translation Function

// FromDatabaseError translates database errors to application errors
func FromDatabaseError(op string, entity string, id string, err error) error {
    if err == nil {
        return nil
    }

    // Check for no rows error
    if errors.Is(err, sql.ErrNoRows) {
        return appErrors.NotFoundError(op, entity, id)
    }

    // Check for duplicate entry error
    var dupErr *database.DuplicateEntryDatabaseError
    if errors.As(err, &dupErr) {
        return appErrors.AlreadyExistsError(op, entity, id)
    }

    // Check for specific MySQL/MariaDB errors
    if strings.Contains(err.Error(), "Error 1062") || // Duplicate entry
        strings.Contains(err.Error(), "Duplicate entry") {
        return appErrors.AlreadyExistsError(op, entity, id)
    }

    // Check for foreign key constraint violations
    if strings.Contains(strings.ToLower(err.Error()), "foreign key constraint") {
        return appErrors.InvalidArgumentError(op, entity, "invalid reference")
    }

    // Default to internal error
    return appErrors.InternalError(op, entity, id, err)
}

Usage in Application Handler

func (is *issueHandler) AddComponentVersionToIssue(issueId, componentVersionId int64) (*entity.Issue, error) {
    const op = "issueHandler.AddComponentVersionToIssue"

    fields := logrus.Fields{
        "event": AddComponentVersionToIssueEventName,
        "id":    issueId,
        "componentVersionId": componentVersionId,
    }

    // Input validation
    if issueId <= 0 {
        err := appErrors.InvalidArgumentError(op, "Issue", "issue id must be positive")
        is.logError(err, fields)
        return nil, err
    }

    if componentVersionId <= 0 {
        err := appErrors.InvalidArgumentError(op, "ComponentVersion", "component version id must be positive")
        is.logError(err, fields)
        return nil, err
    }

    // Check if issue exists
    _, err := is.GetIssue(issueId)
    if err != nil {
        // Error already logged in GetIssue
        return nil, err
    }

    err = is.database.AddComponentVersionToIssue(issueId, componentVersionId)

    if err != nil {
        entityID := fmt.Sprintf("issue %d, component version %d", issueId, componentVersionId)
        appErr := shared.FromDatabaseError(op, "ComponentVersionIssue", entityID, err)
        is.logError(appErr, fields)
        return nil, appErr
    }

    is.eventRegistry.PushEvent(&AddComponentVersionToIssueEvent{
        IssueID:            issueId,
        ComponentVersionID: componentVersionId,
    })

    return is.GetIssue(issueId)
}

Key Points

  • Translate database errors to application errors
  • Add context (operation, entity, ID)
  • Handle input validation with proper error types
  • Log errors with contextual information

API Layer (Presentation)

Principle: Pass through application errors to be translated to API-specific format.

Example Resolver

func (r *mutationResolver) AddComponentVersionToIssue(ctx context.Context, issueID string, componentVersionID string) (*model.Issue, error) {
    // Parse input IDs
    issueIdInt, err := baseResolver.ParseCursor(&issueID)
    if err != nil {
        return nil, err // Just pass through the error
    }

    componentVersionIdInt, err := baseResolver.ParseCursor(&componentVersionID)
    if err != nil {
        return nil, err // Just pass through the error
    }

    // Call application layer
    issue, err := r.App.AddComponentVersionToIssue(*issueIdInt, *componentVersionIdInt)
    if err != nil {
        return nil, err // Just pass through the error
    }

    // Transform to API model
    i := model.NewIssue(issue)
    return &i, nil
}

Error Presenter (for GraphQL)

func ErrorPresenter(ctx context.Context, err error) *gqlerror.Error {
    // Try to extract application error
    var appErr *appErrors.Error
    if errors.As(err, &appErr) {
        // Map the error code to a GraphQL error code
        code := mapErrorCodeToGraphQL(appErr.Code)

        // Create the GraphQL error with appropriate metadata
        gqlErr := &gqlerror.Error{
            Message: formatErrorMessage(appErr),
            Extensions: map[string]interface{}{
                "code": code,
            },
        }

        // Add entity information if available
        if appErr.Entity != "" {
            gqlErr.Extensions["entity"] = appErr.Entity
        }

        // Add ID information if available
        if appErr.ID != "" {
            gqlErr.Extensions["id"] = appErr.ID
        }

        // Add fields if they exist and this is not an internal error
        if appErr.Code != appErrors.Internal && appErr.Fields != nil && len(appErr.Fields) > 0 {
            gqlErr.Extensions["fields"] = appErr.Fields
        }

        return gqlErr
    }

    // For any other error, use the default behavior
    return graphql.DefaultErrorPresenter(ctx, err)
}

Error Code Mapping

func mapErrorCodeToGraphQL(code appErrors.Code) string {
    switch code {
    case appErrors.InvalidArgument:
        return "BAD_REQUEST"
    case appErrors.NotFound:
        return "NOT_FOUND"
    case appErrors.AlreadyExists:
        return "CONFLICT"
    case appErrors.PermissionDenied:
        return "FORBIDDEN"
    case appErrors.Unauthenticated:
        return "UNAUTHENTICATED"
    case appErrors.ResourceExhausted:
        return "TOO_MANY_REQUESTS"
    case appErrors.FailedPrecondition:
        return "PRECONDITION_FAILED"
    default:
        return "INTERNAL"
    }
}

Key Points

  • Resolvers don't do error translation
  • Pass application errors directly to the error presenter
  • Error presenter handles conversion to API-specific format
  • Sanitize sensitive information in error messages
  • Use consistent error codes across the API

@dorneanu
Copy link
Collaborator Author

This is how it could look like:

Complete Error Flow Example

Scenario: Adding a ComponentVersion to a non-existent Issue

1. Database Layer (Raw Error)

// In SqlDatabase.AddComponentVersionToIssue
_, err := s.GetIssue(issueId)
if err != nil {
    return err  // Returns "record not found" error
}

2. Application Layer (Application Error)

// In issueHandler.AddComponentVersionToIssue
err = is.database.AddComponentVersionToIssue(issueId, componentVersionId)
if err != nil {
    entityID := fmt.Sprintf("issue %d, component version %d", issueId, componentVersionId)
    appErr := shared.FromDatabaseError(op, "ComponentVersionIssue", entityID, err)
    // "record not found" gets translated to NotFoundError
    is.logError(appErr, fields)
    return nil, appErr  // Returns application error
}

3. API Layer (GraphQL Error)

// In mutationResolver.AddComponentVersionToIssue
issue, err := r.App.AddComponentVersionToIssue(*issueIdInt, *componentVersionIdInt)
if err != nil {
    return nil, err  // Passes through the application error
}

4. Error Presenter

// In ErrorPresenter
var appErr *appErrors.Error
if errors.As(err, &appErr) {
    // NotFoundError gets mapped to "NOT_FOUND" code and formatted message
    return &gqlerror.Error{
        Message: "ComponentVersionIssue with ID 'issue 123, component version 456' not found",
        Extensions: map[string]interface{}{
            "code": "NOT_FOUND",
            "entity": "ComponentVersionIssue",
            "id": "issue 123, component version 456",
        },
    }
}

5. API Response

{
  "errors": [
    {
      "message": "ComponentVersionIssue with ID 'issue 123, component version 456' not found",
      "extensions": {
        "code": "NOT_FOUND",
        "entity": "ComponentVersionIssue",
        "id": "issue 123, component version 456"
      }
    }
  ],
  "data": {
    "addComponentVersionToIssue": null
  }
}

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.

1 participant
0