Skip to content

Commit 803e8b3

Browse files
committed
fix(api): unify public error payloads
1 parent 65c4aa7 commit 803e8b3

15 files changed

Lines changed: 1021 additions & 176 deletions

api/create_admission.go

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,17 +195,47 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
195195
switch response.Status {
196196
case "", extensionStatusResolved:
197197
case extensionStatusUnresolved:
198-
return newAdmissionError(http.StatusConflict, "preset inputs are unresolved", map[string]any{"error": "preset_create_unresolved"}, errors.New("preset inputs are unresolved"))
198+
return newAdmissionError(
199+
http.StatusConflict,
200+
"preset inputs are unresolved",
201+
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
202+
errors.New("preset inputs are unresolved"),
203+
)
199204
case extensionStatusForbidden:
200-
return newAdmissionError(http.StatusForbidden, "preset create resolution is forbidden", map[string]any{"error": "preset_create_forbidden"}, errors.New("preset create resolution is forbidden"))
205+
return newAdmissionError(
206+
http.StatusForbidden,
207+
"preset create resolution is forbidden",
208+
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
209+
errors.New("preset create resolution is forbidden"),
210+
)
201211
case extensionStatusAmbiguous:
202-
return newAdmissionError(http.StatusConflict, "preset inputs are ambiguous", map[string]any{"error": "preset_create_ambiguous"}, errors.New("preset inputs are ambiguous"))
212+
return newAdmissionError(
213+
http.StatusConflict,
214+
"preset inputs are ambiguous",
215+
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
216+
errors.New("preset inputs are ambiguous"),
217+
)
203218
case extensionStatusInvalid:
204-
return newAdmissionError(http.StatusBadRequest, "preset inputs are invalid", map[string]any{"error": "preset_create_invalid"}, errors.New("preset inputs are invalid"))
219+
return newAdmissionError(
220+
http.StatusBadRequest,
221+
"preset inputs are invalid",
222+
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
223+
errors.New("preset inputs are invalid"),
224+
)
205225
case extensionStatusUnavailable:
206-
return newAdmissionError(http.StatusServiceUnavailable, "preset create resolution is unavailable", map[string]any{"error": "preset_create_unavailable"}, errors.New("preset create resolution is unavailable"))
226+
return newAdmissionError(
227+
http.StatusServiceUnavailable,
228+
"preset create resolution is unavailable",
229+
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
230+
errors.New("preset create resolution is unavailable"),
231+
)
207232
default:
208-
return newAdmissionError(http.StatusServiceUnavailable, "preset create resolution returned an unsupported status", nil, fmt.Errorf("unsupported preset create status %q", response.Status))
233+
return newAdmissionError(
234+
http.StatusServiceUnavailable,
235+
"preset create resolution returned an unsupported status",
236+
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
237+
fmt.Errorf("unsupported preset create status %q", response.Status),
238+
)
209239
}
210240
}
211241
if selectedClass != nil {
@@ -224,6 +254,69 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
224254
return nil
225255
}
226256

257+
func presetCreatePublicError(status extensionResolverStatus, requestID, presetID string) publicError {
258+
subject := map[string]string{}
259+
if trimmedPresetID := strings.TrimSpace(presetID); trimmedPresetID != "" {
260+
subject["presetId"] = trimmedPresetID
261+
}
262+
switch status {
263+
case extensionStatusUnresolved:
264+
return createPublicError(
265+
publicErrorCodeIdentityUnresolved,
266+
"This request could not be linked to the required preset inputs yet.",
267+
false,
268+
requestID,
269+
subject,
270+
nil,
271+
)
272+
case extensionStatusForbidden:
273+
return createPublicError(
274+
publicErrorCodePolicyForbidden,
275+
"This request is not allowed to use the preset create resolver.",
276+
false,
277+
requestID,
278+
subject,
279+
nil,
280+
)
281+
case extensionStatusAmbiguous:
282+
return createPublicError(
283+
publicErrorCodeIdentityAmbiguous,
284+
"This request matched more than one possible preset input state.",
285+
false,
286+
requestID,
287+
subject,
288+
nil,
289+
)
290+
case extensionStatusInvalid:
291+
return createPublicError(
292+
publicErrorCodeResolverInvalid,
293+
"This request included invalid preset inputs.",
294+
false,
295+
requestID,
296+
subject,
297+
nil,
298+
)
299+
case extensionStatusUnavailable:
300+
return createPublicError(
301+
publicErrorCodeResolverUnavailable,
302+
"The preset create resolver is temporarily unavailable.",
303+
true,
304+
requestID,
305+
subject,
306+
nil,
307+
)
308+
default:
309+
return createPublicError(
310+
publicErrorCodeInternalError,
311+
"The preset create resolver returned an unsupported result.",
312+
true,
313+
requestID,
314+
subject,
315+
nil,
316+
)
317+
}
318+
}
319+
227320
type presetCreateMutationResult struct {
228321
serviceAccountResolved bool
229322
runtimePolicyResolved bool

api/create_admission_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,72 @@ func TestCreateSpritzRejectsPresetInputsWithoutMatchingResolver(t *testing.T) {
245245
}
246246
}
247247

248+
func TestCreateSpritzReturnsStructuredPublicErrorForUnresolvedPresetInputs(t *testing.T) {
249+
s := newCreateSpritzTestServer(t)
250+
resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
251+
w.Header().Set("Content-Type", "application/json")
252+
_ = json.NewEncoder(w).Encode(map[string]any{
253+
"status": "unresolved",
254+
})
255+
}))
256+
defer resolver.Close()
257+
configurePresetResolverTestServer(s, resolver.URL, "")
258+
259+
e := echo.New()
260+
secured := e.Group("", s.authMiddleware())
261+
secured.POST("/api/spritzes", s.createSpritz)
262+
263+
body := []byte(`{
264+
"name":"zeno-lake",
265+
"presetId":"zeno",
266+
"presetInputs":{"agentId":"ag-123"},
267+
"requestId":"create-unresolved-1",
268+
"spec":{}
269+
}`)
270+
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
271+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
272+
req.Header.Set("X-Spritz-User-Id", "user-1")
273+
rec := httptest.NewRecorder()
274+
275+
e.ServeHTTP(rec, req)
276+
277+
if rec.Code != http.StatusConflict {
278+
t.Fatalf("expected status 409, got %d: %s", rec.Code, rec.Body.String())
279+
}
280+
281+
var payload map[string]any
282+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
283+
t.Fatalf("failed to decode response json: %v", err)
284+
}
285+
if payload["status"] != "fail" {
286+
t.Fatalf("expected jsend fail status, got %#v", payload["status"])
287+
}
288+
data := payload["data"].(map[string]any)
289+
publicError, ok := data["error"].(map[string]any)
290+
if !ok {
291+
t.Fatalf("expected structured public error, got %#v", data["error"])
292+
}
293+
if publicError["code"] != "identity.unresolved" {
294+
t.Fatalf("expected identity.unresolved code, got %#v", publicError["code"])
295+
}
296+
if publicError["operation"] != "spritz.create" {
297+
t.Fatalf("expected spritz.create operation, got %#v", publicError["operation"])
298+
}
299+
if publicError["requestId"] != "create-unresolved-1" {
300+
t.Fatalf("expected requestId create-unresolved-1, got %#v", publicError["requestId"])
301+
}
302+
if publicError["retryable"] != false {
303+
t.Fatalf("expected retryable false, got %#v", publicError["retryable"])
304+
}
305+
subject, ok := publicError["subject"].(map[string]any)
306+
if !ok {
307+
t.Fatalf("expected subject payload, got %#v", publicError["subject"])
308+
}
309+
if subject["presetId"] != "zeno" {
310+
t.Fatalf("expected presetId zeno, got %#v", subject["presetId"])
311+
}
312+
}
313+
248314
func TestCreateSpritzRejectsMissingRequiredResolvedFieldFromInstanceClass(t *testing.T) {
249315
s := newCreateSpritzTestServer(t)
250316
s.presets = presetCatalog{

0 commit comments

Comments
 (0)