Repository navigation
Show why a deploy was refused instead of "Deploy completed (no result payload)" - #2817
Merged
Merged
Conversation
`harper deploy` asks for text/event-stream. When the server refused the deploy before its progress stream began (401 from authentication, 403 from the role allowlist, 400 from validation), the operations server's content negotiation still picked the event-stream serializer and wrote the error body as one unnamed frame. The CLI found no `done` or `error` event and printed "error: message: Deploy completed (no result payload).", so the refusal itself never reached the user. Server: the Fastify preSerialization hook answers an error-status reply that would negotiate text/event-stream as JSON. Replies that set their own content type are untouched, including the progress stream and its in-band `event: error` at status 200. CLI: a failed status is never parsed as a stream; its body is read whole, and an unnamed frame (older servers) is unwrapped, while a body with no frames (a proxy's error page) is printed as it came. A successful stream that ends without `done` or `error` returns its last unnamed frame instead of the synthetic message, and a `done` event, even an empty one, still wins over it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups: the JSON override now skips a reply whose route set text/event-stream itself (an object sent with its own serializer does reach the negotiation hook); the refused-deploy integration cell pins its principal with auth_username/auth_password so ambient CLI credentials cannot replace it; and the new CLI suite no longer separates the token-redaction comment from its describe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Media types are case-insensitive, so a route that set Text/Event-Stream itself is still exempt from the JSON override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request improves the handling of requests that are refused before their progress stream starts, particularly for deploy_component operations. It ensures that such errors are returned as JSON instead of an event stream, and updates the CLI to correctly unwrap unnamed frames sent by older servers in these scenarios. Comprehensive unit and integration tests, along with design documentation, have been added to support these changes. Feedback on the changes suggests simplifying a redundant content-type check in the preSerialization hook of contentTypes.ts since the hook already returns early if the header is set.
dawsontoth
marked this pull request as ready for review
September 25, 2026 19:31
github-actions
Bot
requested review from
Ethan-Arrowood,
cb1kenobi,
heskew and
kriszyp
September 25, 2026 19:32
Contributor
|
Reviewed; no blockers found. |
kriszyp
approved these changes
Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
harper deployasks for an event stream so it can show deploy progress. When the server refused the request before that stream started — 401 from authentication, 403 from the role allowlist, 400 from validation — the operations server's content negotiation still chose the event-stream serializer and wrote the error body as one unnamed frame. The CLI found nodoneorerrorevent and printederror: message: Deploy completed (no result payload)., so the reason for the refusal never reached the user. The server now answers such errors as JSON, and the CLI no longer parses a failed status as a stream and unwraps the frame older servers send.Found on a customer's CI deploy against a cluster on 5.3.0-beta.2, where the role allowlist refused
deploy_component(the refusal itself is #2175, fixed in #2809); the CI log showed only the "no result payload" line.For the human reviewer
done/error. Gemini proposed never stream-parsing a failed status at all: read the body whole, unwrap an unnamed frame if there is one, and otherwise print it as it came. Adopted, because it also keeps a proxy's frameless error page (a 502 labeledtext/event-stream) readable, where the plan would have printed the synthetic message. Codex rated the rest of the plan sound.streamOperationtreats a non-OK or non-event-stream response asSSEUnsupportedErrorand falls back to a buffered request, which returns the JSON error, so it is not misreported. The CLI uses SSE only fordeploy_component. I did both halves anyway: the CLI half is what fixes the reported case, since 5.3.0-beta.2 and every released server answer this way and the CLI version is independent of the cluster's; the server half makes the response truthful for anything else reading it (curl, custom CI scripts). Either half can be dropped on its own.preSerializationhook: an error-status reply that would negotiatetext/event-streamgoes out as JSON, even when the Accept header lists only SSE, since an error is not a stream. A route that settext/event-streamitself keeps it, and a stream payload never reaches the hook, so the progress stream's in-bandevent: errorat status 200 is untouched. The hook is shared with the legacy custom-functions server (fastifyRoutes.ts), whose untyped errors to an SSE-preferring client become JSON too.donenow returns its last unnamed frame instead of the synthetic message. That only happens when the CLI's version probe cannot route an older server to its legacy deploy path, for example when the role may not callregistration_info. An emptydonestill wins over an earlier unnamed frame.IncomingMessageundrained and that a negotiated type could carry parameters. Both are contradicted by the code: the SSE branch drains the stream before unwrapping, andfindBestSerializersplits parameters off before matching; Codex re-checked both the same way.Changes
server/serverHelpers/contentTypes.ts: thepreSerializationhook answers an error-status reply that would negotiatetext/event-streamas JSON, unless the route set that type itself (compared case-insensitively).bin/cliOperations.ts: a failed status is read whole instead of streamed; an unnamed frame is unwrapped, and a frameless body is printed as it came, through the existing error exit. On a successful stream, the last unnamed frame becomes the result when nodoneorerrorarrived.server/DESIGN.md: the rule, next to the REST layer's matching one for streaming startup errors.Docs: none needed. The docs describe the deploy and
get_deploymentevent streams, not how a request refused before one starts is answered, so nothing documented changes.Verification
End-to-end route: extended an existing integration test.
integrationTests/server/qa579-cli-exit-codes.test.tsgains Cell 6 (listed in the header): the real built CLI deploys, withauth_username/auth_passwordso ambient credentials cannot stand in, as a role whose allowlist omitsdeploy_component, against a real instance. It asserts a non-zero exit, the allowlist refusal in the output, no "no result payload" line, and no component directory. On base30f8ff616it fails with exactly the reportederror: message: Deploy completed (no result payload).; on the branch the suite passes 6/6.Unit, each half proved on its own:
unitTests/server/serverHelpers/contentTypes.test.js: a real Fastify app with the ops server's negotiation hook andserverErrorHandler. 400, 401, 403 and 500 errors, and one raised from a request hook, now come back as JSON with their status and body; these five fail on base. Controls: a successful reply still negotiates an event stream, a stream that set its own type (with anevent: error) and an error with an explicit type are untouched, a route that chosetext/event-stream(either case) with its own serializer keeps it, and CBOR stays CBOR, including when it outranks SSE in the Accept header.unitTests/bin/cliOperations.test.js: the CLI against a loopback server writing what older servers send, with the real request path. A 403 as an unnamed frame prints the refusal and exits 1; a 502 HTML page labeled as an event stream prints the page; a successful stream with unnamed frames returns the last one, and one that is not JSON as text. These four fail on base. Controls: an emptydonestill wins over an earlier unnamed frame, and a stream that breaks after a frame still exits 1.Other runs on the branch: the SSE-related integration suites (
deploy-multipart-stream,deploy-tracking-events,qa702-sse-event-data,sse-throw-midstream,stream-error-contract,operations-server, with QA-579) 65/65;test:unit:bin254 passing;test:unit:server996 passing, with 10 failures that are the same machine-specific ones base shows (/tmpvs/private/tmppreload paths, process-group reclaim, a uWS 413);lint:required,check:design-docs,prettier --checkandtscpass. I did not runtest:unit:mainortest:integration:alllocally: on this machinetest:unit:mainneeds a sentinelHOMEto keep suites that clearROOTPATHoff the installed Harper's config, and this session's worktree isolation refuses to setHOME. CI runs both.Complexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; blocked=cursor-composer(no-receipt),domain(quota); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=2; full=1 @ 2fb1b7d
Human-Review-Need: 4 @ a982e3c