Skip to content

Commit 63f5daa

Browse files
committed
fix(api): map degenerate GetResult to a clean 500 before writing status
Addresses the P1 follow-up on #59: returning an error from ProjectOrDegraded.MarshalJSON does not "surface" the contract violation — envelope.WriteJSON discards the encode error after the 200 status and a partial JSON frame have already been flushed, leaving the client with a truncated, unparseable 200 (worse than the previous null). Validate the invariant upstream instead: newGetProjectResponse now returns an error when the GetResult sets neither Project nor Degraded, and the get handler maps that to a 500 envelope before any status/body is written. MarshalJSON keeps the error branch only as an unreachable last-resort backstop, with the comment corrected to say so. Adds TestProjectsAPI_GetEmptyResultIs500 to lock the clean-500 behavior. Refs #59.
1 parent 49c7883 commit 63f5daa

3 files changed

Lines changed: 56 additions & 10 deletions

File tree

backend/internal/httpd/controllers/dto.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,37 @@ func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) {
5656
case p.Project != nil:
5757
return json.Marshal(p.Project)
5858
default:
59-
// Neither variant set — the spec declares `project` as a required
60-
// oneOf[Project, Degraded], so emitting `null` would silently breach
61-
// the contract. Fail loudly instead: a GetResult with both pointers
62-
// nil is a Manager bug, and the encode error surfaces it rather than
63-
// shipping an invalid body.
64-
return nil, errors.New("controllers: ProjectOrDegraded has neither Project nor Degraded set")
59+
// Unreachable in practice: the handler validates the GetResult via
60+
// newGetProjectResponse and writes a 500 before committing the 200
61+
// status, so this never encodes. Kept as a last-resort backstop —
62+
// erroring is still better than emitting a contract-breaking `null`,
63+
// though by here the status is already sent, so the real guard is
64+
// upstream.
65+
return nil, errEmptyProjectOrDegraded
6566
}
6667
}
6768

69+
// errEmptyProjectOrDegraded marks a GetResult that set neither variant — a
70+
// Manager-contract violation. newGetProjectResponse returns it so the handler
71+
// can map it to a 500 before any response bytes are written.
72+
var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set")
73+
6874
// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the
6975
// oneOf for this field; it is not used at runtime.
7076
func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} {
7177
return []interface{}{project.Project{}, project.Degraded{}}
7278
}
7379

7480
// newGetProjectResponse maps the internal GetResult onto the wire envelope —
75-
// the explicit project→httpd boundary the result type exists for.
76-
func newGetProjectResponse(res project.GetResult) GetProjectResponse {
81+
// the explicit project→httpd boundary the result type exists for. It errors
82+
// when the result sets neither variant, so the handler can return a clean 500
83+
// BEFORE writing the 200 status rather than flushing a truncated body.
84+
func newGetProjectResponse(res project.GetResult) (GetProjectResponse, error) {
85+
if res.Project == nil && res.Degraded == nil {
86+
return GetProjectResponse{}, errEmptyProjectOrDegraded
87+
}
7788
return GetProjectResponse{
7889
Status: res.Status,
7990
Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded},
80-
}
91+
}, nil
8192
}

backend/internal/httpd/controllers/projects.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,14 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) {
101101
writeProjectError(w, r, err, http.StatusInternalServerError)
102102
return
103103
}
104-
envelope.WriteJSON(w, http.StatusOK, newGetProjectResponse(got))
104+
resp, err := newGetProjectResponse(got)
105+
if err != nil {
106+
// GetResult set neither variant — a Manager-contract violation. Map it
107+
// to a 500 here, before any status/body is written.
108+
writeProjectError(w, r, err, http.StatusInternalServerError)
109+
return
110+
}
111+
envelope.WriteJSON(w, http.StatusOK, resp)
105112
}
106113

107114
func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) {

backend/internal/httpd/controllers/projects_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package controllers_test
22

33
import (
4+
"context"
45
"encoding/json"
56
"io"
67
"log/slog"
@@ -13,10 +14,37 @@ import (
1314
"testing"
1415

1516
"github.com/aoagents/agent-orchestrator/backend/internal/config"
17+
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
1618
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
1719
"github.com/aoagents/agent-orchestrator/backend/internal/project"
1820
)
1921

22+
// emptyGetManager returns a GetResult that sets neither Project nor Degraded —
23+
// a Manager-contract violation — so the test can prove the handler answers a
24+
// clean 500 before writing the 200 status, rather than flushing a truncated
25+
// body when the discriminated union fails to marshal. Other methods are
26+
// unused; the embedded nil interface would panic if one were called.
27+
type emptyGetManager struct{ project.Manager }
28+
29+
func (emptyGetManager) Get(context.Context, domain.ProjectID) (project.GetResult, error) {
30+
return project.GetResult{}, nil
31+
}
32+
33+
// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union
34+
// invariant: a degenerate GetResult must surface as a parseable 500 envelope,
35+
// not a 200 with truncated JSON.
36+
func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) {
37+
log := slog.New(slog.NewTextHandler(io.Discard, nil))
38+
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
39+
Projects: emptyGetManager{},
40+
}))
41+
t.Cleanup(srv.Close)
42+
43+
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "")
44+
assertJSON(t, headers)
45+
assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR")
46+
}
47+
2048
func newTestServer(t *testing.T) *httptest.Server {
2149
t.Helper()
2250
log := slog.New(slog.NewTextHandler(io.Discard, nil))

0 commit comments

Comments
 (0)