Skip to content

Show why a deploy was refused instead of "Deploy completed (no result payload)" - #2817

Merged
kriszyp merged 4 commits into
mainfrom
fix-sse-operation-prestream-error
Sep 27, 2026
Merged

kriszyp merged 4 commits into
mainfrom
fix-sse-operation-prestream-error

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

harper deploy asks 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 no done or error event and printed error: 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

  1. The CLI design changed after planning review. The plan read the last unnamed frame from any stream that ended without 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 labeled text/event-stream) readable, where the plan would have printed the synthetic message. Codex rated the rest of the plan sound.
  2. Requirement. The task preferred a server-side fix if other SSE clients hit the same thing, so I checked. Studio's streamOperation treats a non-OK or non-event-stream response as SSEUnsupportedError and falls back to a buffered request, which returns the JSON error, so it is not misreported. The CLI uses SSE only for deploy_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.
  3. Scope of the server change. It is one condition in the operations server's preSerialization hook: an error-status reply that would negotiate text/event-stream goes out as JSON, even when the Accept header lists only SSE, since an error is not a stream. A route that set text/event-stream itself keeps it, and a stream payload never reaches the hook, so the progress stream's in-band event: error at 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.
  4. A successful stream that ends without done now 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 call registration_info. An empty done still wins over an earlier unnamed frame.
  5. Declined review findings. Gemini claimed the failed-status branch could leave an IncomingMessage undrained and that a negotiated type could carry parameters. Both are contradicted by the code: the SSE branch drains the stream before unwrapping, and findBestSerializer splits parameters off before matching; Codex re-checked both the same way.

Changes

Docs: none needed. The docs describe the deploy and get_deployment event 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.ts gains Cell 6 (listed in the header): the real built CLI deploys, with auth_username/auth_password so ambient credentials cannot stand in, as a role whose allowlist omits deploy_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 base 30f8ff616 it fails with exactly the reported error: message: Deploy completed (no result payload).; on the branch the suite passes 6/6.

Unit, each half proved on its own:

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:bin 254 passing; test:unit:server 996 passing, with 10 failures that are the same machine-specific ones base shows (/tmp vs /private/tmp preload paths, process-group reclaim, a uWS 413); lint:required, check:design-docs, prettier --check and tsc pass. I did not run test:unit:main or test:integration:all locally: on this machine test:unit:main needs a sentinel HOME to keep suites that clear ROOTPATH off the installed Harper's config, and this session's worktree isolation refuses to set HOME. 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

dawsontoth and others added 3 commits September 25, 2026 13:04
`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>
@dawsontoth dawsontoth added this to the v5.3 milestone Sep 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread server/serverHelpers/contentTypes.ts
@dawsontoth
dawsontoth marked this pull request as ready for review September 25, 2026 19:31
@claude

claude Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp merged commit cc243e6 into main Sep 27, 2026
56 checks passed
@kriszyp
kriszyp deleted the fix-sse-operation-prestream-error branch September 27, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants