Skip to content

Commit f8879f8

Browse files
cfsmp3claude
andcommitted
fix: Show user-friendly messages for GCP resource exhaustion errors
Fixes #901 When GCP fails to create a VM (e.g., due to zone resource exhaustion), the error was logged but the test status showed a raw error dict that was not user-friendly. Changes: - Added parse_gcp_error() helper function to extract meaningful messages from GCP API error responses - Added GCP_ERROR_MESSAGES dict mapping known error codes to user-friendly messages (ZONE_RESOURCE_POOL_EXHAUSTED, QUOTA_EXCEEDED, TIMEOUT, etc.) - Updated start_test() to use parse_gcp_error() when VM creation fails - For unknown errors, shows the error code and a truncated message Now users see messages like: "GCP resources temporarily unavailable in the configured zone. The test will be retried automatically when resources become available." Instead of raw dicts like: "{'errors': [{'code': 'ZONE_RESOURCE_POOL_EXHAUSTED', ...}]}" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 09dc049 commit f8879f8

File tree

2 files changed

+193
-2
lines changed

2 files changed

+193
-2
lines changed

mod_ci/controllers.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,67 @@ def safe_db_commit(db, operation_description: str = "database operation") -> boo
122122
return False
123123

124124

125+
# User-friendly messages for known GCP error codes
126+
GCP_ERROR_MESSAGES = {
127+
'ZONE_RESOURCE_POOL_EXHAUSTED': (
128+
"GCP resources temporarily unavailable in the configured zone. "
129+
"The test will be retried automatically when resources become available."
130+
),
131+
'QUOTA_EXCEEDED': (
132+
"GCP quota limit reached. Please wait for other tests to complete "
133+
"or contact the administrator."
134+
),
135+
'RESOURCE_NOT_FOUND': "Required GCP resource not found. Please contact the administrator.",
136+
'RESOURCE_ALREADY_EXISTS': "A VM with this name already exists. Please contact the administrator.",
137+
'TIMEOUT': "GCP operation timed out. The test will be retried automatically.",
138+
}
139+
140+
141+
def parse_gcp_error(result: Dict) -> str:
142+
"""
143+
Parse a GCP API error response and return a user-friendly message.
144+
145+
GCP errors have the structure:
146+
{
147+
'error': {
148+
'errors': [{'code': 'ERROR_CODE', 'message': '...'}]
149+
}
150+
}
151+
152+
:param result: The GCP API response dictionary
153+
:return: A user-friendly error message
154+
"""
155+
if not isinstance(result, dict):
156+
return f"Unknown error: {result}"
157+
158+
error = result.get('error')
159+
if error is None:
160+
return "Unknown error (no error details provided)"
161+
162+
if not isinstance(error, dict):
163+
return f"Unknown error: {error}"
164+
165+
errors = error.get('errors', [])
166+
if not errors:
167+
return f"Unknown error: {error}"
168+
169+
# Get the first error (usually the most relevant)
170+
first_error = errors[0] if isinstance(errors, list) and len(errors) > 0 else {}
171+
error_code = first_error.get('code', 'UNKNOWN')
172+
error_message = first_error.get('message', 'No details provided')
173+
174+
# Check if we have a user-friendly message for this error code
175+
if error_code in GCP_ERROR_MESSAGES:
176+
return GCP_ERROR_MESSAGES[error_code]
177+
178+
# For unknown errors, return a cleaned-up version of the original message
179+
# Truncate very long messages
180+
if len(error_message) > 200:
181+
error_message = error_message[:200] + "..."
182+
183+
return f"{error_code}: {error_message}"
184+
185+
125186
mod_ci = Blueprint('ci', __name__)
126187

127188

@@ -782,9 +843,9 @@ def start_test(compute, app, db, repository: Repository.Repository, test, bot_to
782843
if not safe_db_commit(db, f"recording GCP instance for test {test.id}"):
783844
log.error(f"Failed to record GCP instance for test {test.id}, but VM was created")
784845
else:
785-
error_msg = result.get('error', 'Unknown error') if isinstance(result, dict) else str(result)
846+
error_msg = parse_gcp_error(result)
786847
log.error(f"Error creating test instance for test {test.id}, result: {result}")
787-
mark_test_failed(db, test, repository, f"Failed to create VM: {error_msg}")
848+
mark_test_failed(db, test, repository, error_msg)
788849

789850

790851
def create_instance(compute, project, zone, test, reportURL) -> Dict:

tests/test_ci/test_controllers.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import unittest
23
from importlib import reload
34
from unittest import mock
45
from unittest.mock import MagicMock
@@ -3092,3 +3093,132 @@ def test_mark_test_failed_includes_target_url(self, mock_progress, mock_update_g
30923093
call_args = mock_update_github.call_args
30933094
self.assertEqual(len(call_args[0]), 5) # 5 positional args including target_url
30943095
self.assertIn("456", call_args[0][4]) # target_url contains test ID
3096+
3097+
3098+
class TestParseGcpError(unittest.TestCase):
3099+
"""Tests for the parse_gcp_error helper function."""
3100+
3101+
def test_parse_gcp_error_zone_resource_exhausted(self):
3102+
"""Test that ZONE_RESOURCE_POOL_EXHAUSTED returns user-friendly message."""
3103+
from mod_ci.controllers import parse_gcp_error
3104+
3105+
result = {
3106+
'status': 'DONE',
3107+
'error': {
3108+
'errors': [{
3109+
'code': 'ZONE_RESOURCE_POOL_EXHAUSTED',
3110+
'message': "The zone 'projects/test/zones/us-central1-a' does not "
3111+
"have enough resources available to fulfill the request."
3112+
}]
3113+
}
3114+
}
3115+
3116+
error_msg = parse_gcp_error(result)
3117+
self.assertIn("GCP resources temporarily unavailable", error_msg)
3118+
self.assertIn("retried automatically", error_msg)
3119+
# Should NOT contain raw technical details
3120+
self.assertNotIn("us-central1-a", error_msg)
3121+
3122+
def test_parse_gcp_error_quota_exceeded(self):
3123+
"""Test that QUOTA_EXCEEDED returns user-friendly message."""
3124+
from mod_ci.controllers import parse_gcp_error
3125+
3126+
result = {
3127+
'error': {
3128+
'errors': [{
3129+
'code': 'QUOTA_EXCEEDED',
3130+
'message': 'Quota exceeded for resource.'
3131+
}]
3132+
}
3133+
}
3134+
3135+
error_msg = parse_gcp_error(result)
3136+
self.assertIn("quota limit reached", error_msg)
3137+
3138+
def test_parse_gcp_error_timeout(self):
3139+
"""Test that TIMEOUT returns user-friendly message."""
3140+
from mod_ci.controllers import parse_gcp_error
3141+
3142+
result = {
3143+
'status': 'TIMEOUT',
3144+
'error': {
3145+
'errors': [{
3146+
'code': 'TIMEOUT',
3147+
'message': 'Operation timed out after 1800 seconds'
3148+
}]
3149+
}
3150+
}
3151+
3152+
error_msg = parse_gcp_error(result)
3153+
self.assertIn("timed out", error_msg)
3154+
self.assertIn("retried automatically", error_msg)
3155+
3156+
def test_parse_gcp_error_unknown_code(self):
3157+
"""Test that unknown error codes return the code and message."""
3158+
from mod_ci.controllers import parse_gcp_error
3159+
3160+
result = {
3161+
'error': {
3162+
'errors': [{
3163+
'code': 'SOME_NEW_ERROR',
3164+
'message': 'Something unexpected happened.'
3165+
}]
3166+
}
3167+
}
3168+
3169+
error_msg = parse_gcp_error(result)
3170+
self.assertIn("SOME_NEW_ERROR", error_msg)
3171+
self.assertIn("Something unexpected happened", error_msg)
3172+
3173+
def test_parse_gcp_error_long_message_truncated(self):
3174+
"""Test that very long error messages are truncated."""
3175+
from mod_ci.controllers import parse_gcp_error
3176+
3177+
long_message = "A" * 300 # 300 characters
3178+
result = {
3179+
'error': {
3180+
'errors': [{
3181+
'code': 'UNKNOWN_ERROR',
3182+
'message': long_message
3183+
}]
3184+
}
3185+
}
3186+
3187+
error_msg = parse_gcp_error(result)
3188+
self.assertLess(len(error_msg), 250) # Should be truncated
3189+
self.assertIn("...", error_msg)
3190+
3191+
def test_parse_gcp_error_no_error_key(self):
3192+
"""Test handling when 'error' key is missing."""
3193+
from mod_ci.controllers import parse_gcp_error
3194+
3195+
result = {'status': 'DONE'}
3196+
3197+
error_msg = parse_gcp_error(result)
3198+
self.assertIn("Unknown error", error_msg)
3199+
3200+
def test_parse_gcp_error_empty_errors_list(self):
3201+
"""Test handling when 'errors' list is empty."""
3202+
from mod_ci.controllers import parse_gcp_error
3203+
3204+
result = {'error': {'errors': []}}
3205+
3206+
error_msg = parse_gcp_error(result)
3207+
self.assertIn("Unknown error", error_msg)
3208+
3209+
def test_parse_gcp_error_not_a_dict(self):
3210+
"""Test handling when result is not a dictionary."""
3211+
from mod_ci.controllers import parse_gcp_error
3212+
3213+
error_msg = parse_gcp_error("some string error")
3214+
self.assertIn("Unknown error", error_msg)
3215+
self.assertIn("some string error", error_msg)
3216+
3217+
def test_parse_gcp_error_error_not_a_dict(self):
3218+
"""Test handling when 'error' value is not a dictionary."""
3219+
from mod_ci.controllers import parse_gcp_error
3220+
3221+
result = {'error': 'just a string'}
3222+
3223+
error_msg = parse_gcp_error(result)
3224+
self.assertIn("Unknown error", error_msg)

0 commit comments

Comments
 (0)