Skip to content

execute: async lane returns 502 for a client-input rejection; sync lane returns the agent's 4xx #862

Description

@hypergraphmind

Summary

POST /api/v1/execute/{target} returns HTTP 502 Bad Gateway for every failed execution
that took the async-acknowledgement lane
, including a client-input rejection the agent
raised on purpose. The synchronous lane, for the identical failure, correctly returns the
agent's 4xx. So the status a caller sees is decided by an internal transport detail rather
than by what went wrong, and a client that keys on status — the normal thing to do — reads
its own bad request as an upstream outage.

Reproduction (measured 2026-08-02, control plane 1.0.0, Python SDK 0.1.91)

A reasoner that validates its own input and raises on a bad field:

POST /api/v1/execute/hga-reasoner.reason
{"input":{"text":"probe","rules":[{"about":"http://x/A","classified_as":"http://x/B","not_a_field":1}]}}

HTTP/1.1 502 Bad Gateway
{"execution_id":"exec_20260802_141424_2b7kcbrl","run_id":"run_20260802_141424_9889tyoc",
 "status":"failed",
 "error_message":"invalid_input: RuleSpec rejected 1 field in `rules[0]` — not_a_field: Extra inputs are not permitted",
 "duration_ms":0,"finished_at":"2026-08-02T14:14:24Z"}

The same node, refused by the SDK's own pre-dispatch field check, returns the right status:

POST /api/v1/execute/hga-reasoner.reason   {"input":{}}

HTTP/1.1 422 Unprocessable Entity
{"error":"agent error (422): {\"detail\":\"Missing required field: text\"}",
 "error_category":"agent_error","error_details":{"detail":"Missing required field: text"},
 "status":"failed"}

Both are client-input errors against a perfectly healthy upstream. Only the second one says so.

Root cause

Three things compose. All line numbers are main as of this report.

1. The async-completion branch does not classify at all.
control-plane/internal/handlers/execute.go:276-294 — after an agent answers 202 and the
callback lands, any ExecutionStatusFailed is written out with a hardcoded status:

if exec.Status == types.ExecutionStatusFailed {
    ...
    ctx.JSON(http.StatusBadGateway, response)   // execute.go:293
    return
}

Its sibling, writeExecutionError (execute.go:1979-1984), gets this right for the
synchronous lane and even says so in a comment:

// Propagate 4xx status codes from the agent (client-facing errors);
// use 502 Bad Gateway for 5xx (upstream server failure).
httpStatus := http.StatusBadGateway
if ce.statusCode >= 400 && ce.statusCode < 500 {
    httpStatus = ce.statusCode
}

2. The async lane cannot carry a status even if the branch wanted one.
sdk/python/agentfield/agent.py:2475-2486 builds the failure callback from
str(exc) + getattr(exc, "error_details", None). An HTTPException(status_code=422)
raised inside the reasoner is flattened to its str(); the code is gone before the
callback is posted. (The agent takes this lane whenever X-Execution-ID is present and
agentfield_server is set — agent.py:1932 — which is every control-plane-routed call.)

3. error_details is dropped by the callback handler.
The SDK sends it; executionStatusUpdateRequest (execute.go:116-124) has no field for
it, so it is silently discarded. Only result survives a failed callback
(execute.go:498-504,556-559), which is why ReasonerFailed (#697) works and the generic
path does not.

The net effect: a reasoner has no way to return a client-facing status through the
async lane. The one place that classifies is the pre-dispatch check at agent.py:1886,
which by construction only ever sees missing/None top-level fields — anything a reasoner
validates itself is already past it. Notably _execute_reasoner_endpoint at
agent.py:2265-2270 converts a Pydantic ValidationError into _HandlerInputError
with a comment saying the intent is a safe 422, and then raises it at a point where the
only except _HandlerInputError block (agent.py:1881) has already been passed.

Impact

For any agent that validates its own input — which is the recommended shape, since the
runtime's pre-dispatch check only covers top-level presence — every contract rejection is
indistinguishable from an outage at the status layer. Concretely, on a schema change the
consumer's UI says "the agent is unavailable" instead of "this request uses a field the
agent no longer accepts."
The first sends someone to check pods; the second sends them to
the request.

Suggested fix

Smallest change that fixes the observable behaviour: give the async-completion branch the
same classification the sync branch already has, sourced from the callback.

  1. Add error_status (or reuse error_details) to executionStatusUpdateRequest and
    persist it on the execution record.
  2. In the SDK's _execute_async_with_callback failure path, populate it from
    HTTPException.status_code / ExecuteError.status_code when the raised exception
    carries one.
  3. At execute.go:276-294, use that status when it is 4xx, keeping 502 as the default.

A useful independent improvement: restoring error_details to the callback struct would
let any agent return a structured, machine-readable refusal body without having to raise
ReasonerFailed purely for its result side-effect.

Happy to open a PR for any part of this if the shape above is agreeable.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions