Files
gochat/scripts/align_error_codes.py
T
2026-06-04 15:44:48 +08:00

182 lines
6.7 KiB
Python

#!/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()