#!/usr/bin/env python3 """Batch align error responses in GoChat handlers to Chatwoot's error code convention. Chatwoot conventions: - Record not found → 404 - Validation/create/update/delete failures → 422 UnprocessableEntity - Auth failures → 401 Unauthorized - Permission failures → 403 Forbidden - Request format/bind errors → 400 BadRequest - Conflict/duplicate → 409 Conflict - Internal server errors should be rare (only for unexpected DB/infra failures) Current GoChat pattern: most errors use 500 InternalServerError for business logic failures. Target: replace 500 with appropriate 422/404/401/403 based on error message content. """ import re import os import sys # Error message → target HTTP status mapping # Pattern: if the gin.H{"error": "..."} message matches a keyword, use the mapped status MESSAGE_STATUS_MAP = { # 404 patterns 'not found': 'http.StatusNotFound', 'does not exist': 'http.StatusNotFound', # 401 patterns 'unauthorized': 'http.StatusUnauthorized', 'authentication': 'http.StatusUnauthorized', 'invalid token': 'http.StatusUnauthorized', # 403 patterns 'forbidden': 'http.StatusForbidden', 'not allowed': 'http.StatusForbidden', 'permission': 'http.StatusForbidden', 'access denied': 'http.StatusForbidden', 'does not belong': 'http.StatusForbidden', # "does not belong to this account" # 409 patterns 'already exists': 'http.StatusConflict', 'duplicate': 'http.StatusConflict', # 422 patterns (default for business logic failures) 'failed to': 'http.StatusUnprocessableEntity', # "failed to create/update/delete/list" 'invalid': 'http.StatusUnprocessableEntity', 'cannot': 'http.StatusUnprocessableEntity', 'must': 'http.StatusUnprocessableEntity', 'required': 'http.StatusUnprocessableEntity', 'missing': 'http.StatusUnprocessableEntity', } def classify_error_status(error_msg: str) -> str: """Classify an error message into the appropriate HTTP status code constant.""" lower_msg = error_msg.lower() # Check in priority order — specific patterns first for pattern, status in MESSAGE_STATUS_MAP.items(): if pattern in lower_msg: return status # Default: 422 for business logic failures that were previously 500 return 'http.StatusUnprocessableEntity' def process_file(filepath: str) -> dict: """Process a single Go file, replacing 500 errors with appropriate status codes.""" with open(filepath, 'r') as f: content = f.read() original_content = content changes = [] # Pattern: c.JSON(http.StatusInternalServerError, gin.H{"error": "..."}) # Replace with appropriate status code based on error message pattern = re.compile( r'c\.JSON\(http\.StatusInternalServerError,\s*gin\.H\{' r'"error":\s*"([^"]+)"' r'\}\)' ) def replacer(match): error_msg = match.group(1) new_status = classify_error_status(error_msg) old = match.group(0) new = f'c.JSON({new_status}, gin.H{{"error": "{error_msg}"}})' changes.append({ 'line': content[:match.start()].count('\n') + 1, 'old': old, 'new': new, 'status': new_status.replace('http.Status', ''), }) return new content = pattern.sub(replacer, content) # Also handle: c.JSON(http.StatusInternalServerError, response.APIResponse{...}) pattern2 = re.compile( r'c\.JSON\(http\.StatusInternalServerError,\s*response\.APIResponse\{[^}]+\}\)' ) # For these, just replace status code — can't easily classify the message content2 = re.sub( r'c\.JSON\(http\.StatusInternalServerError,\s*response\.APIResponse', 'c.JSON(http.StatusUnprocessableEntity, response.APIResponse', content ) if content2 != content: changes.append({'line': 0, 'old': '500 APIResponse', 'new': '422 APIResponse', 'status': 'UnprocessableEntity'}) content = content2 if content != original_content: with open(filepath, 'w') as f: f.write(content) return {'file': filepath, 'changes': changes, 'total_changes': len(changes)} def main(): handler_dir = '/home/yanghao05/Workspace/gochat/internal/handler' # Find all handler Go files (exclude tests) go_files = [] for root, dirs, files in os.walk(handler_dir): for f in files: if f.endswith('.go') and not f.endswith('_test.go'): go_files.append(os.path.join(root, f)) total_changes = 0 status_distribution = {} for filepath in go_files: result = process_file(filepath) if result['total_changes'] > 0: total_changes += result['total_changes'] for change in result['changes']: status = change['status'] status_distribution[status] = status_distribution.get(status, 0) + 1 print(f" {filepath}:{change['line']} → {status}: {change['new']}") print(f"\n=== Summary ===") print(f"Files processed: {len(go_files)}") print(f"Total changes: {total_changes}") print(f"Status distribution:") for status, count in sorted(status_distribution.items()): print(f" {status}: {count}") # Also handle 400 → 422 for "failed to" business logic errors # Pattern: c.JSON(http.StatusBadRequest, gin.H{"error": "failed to..."}) print(f"\n=== Phase 2: 400 → 422 for business logic errors ===") for filepath in go_files: with open(filepath, 'r') as f: content = f.read() original = content # Replace 400 with 422 for "failed to" errors (business logic, not request format) pattern = re.compile( r'c\.JSON\(http\.StatusBadRequest,\s*gin\.H\{' r'"error":\s*"([^"]+)"' r'\}\)' ) changes_400 = [] def replacer400(match): error_msg = match.group(1) lower = error_msg.lower() # Only replace if it's a business logic failure, not a request format error if 'failed to' in lower or 'cannot' in lower or 'must' in lower: new = f'c.JSON(http.StatusUnprocessableEntity, gin.H{{"error": "{error_msg}"}})' changes_400.append({'msg': error_msg, 'status': 'UnprocessableEntity'}) return new return match.group(0) content = pattern.sub(replacer400, content) if content != original: with open(filepath, 'w') as f: f.write(content) for c in changes_400: print(f" {filepath}: 400→422: {c['msg']}") total_changes += len(changes_400) if __name__ == '__main__': main()