Implementation plan · OpenHands enterprise

Surface API error detail in Slack

Prompt When the API returns an error, the Slack integration swallows it and says "Uh oh! There was an unexpected error starting the job :(". An agent debugging this gets nothing. Preserve the HTTP status, the error detail and the error id, and render them so a machine can parse them and a human can act on them.
Files changed
5
Integration fixed
Slack
Tests added
2
Runtime verified
CI only
01

Milestones

Four slices. Slice 1 is shared and independent; slices 2–3 are the Slack wiring; slice 4 is the test that keeps it honest.

Slice 1

Shared exception class

New APIConversationError carrying status_code, error_detail and error_id. to_slack_message() renders YAML front matter plus a human-readable line.

integrations/exceptions.pynew file
Slice 2

Preserve the API error in the view

send_message_to_v1_conversation() stops catching Exception. It catches httpx.HTTPStatusError, reads detail and error_id out of the response body, and raises APIConversationError.

integrations/slack/slack_view.pyline 422
Slice 3

Structured handler in the manager

An except APIConversationError branch sits ahead of the catch-all in start_job() and calls to_slack_message() instead of the "Uh oh!" string.

integrations/slack/slack_manager.pyline 849
Slice 4

Tests

One test asserts HTTP 500 becomes APIConversationError with the right fields. One asserts the front-matter output shape. Both run in the existing suite.

tests/unit/integrations/slackpytest
02

Data flow

The error already travels the whole path — the top row is what we discard today, the bottom row is what we keep. Nothing new crosses a boundary; we just stop throwing it away.

Slack error propagation Same API failure, two handling paths 01 / Error source 02 / View layer 03 / Manager layer 04 / Output Agent Server · HTTP 500 · 01 / Error source · detail in body Agent Server HTTP 500 detail in body Catch-all · slack_view.py · 02 / View layer · discards error Catch-all slack_view.py discards error Catch-all · slack_manager.py · 03 / Manager layer · no match Catch-all slack_manager.py no match Uh oh! · opaque · 04 / Output · no detail Uh oh! opaque no detail Agent Server · HTTP 500 · 01 / Error source · detail + id Agent Server HTTP 500 detail + id Typed catch · slack_view.py · 02 / View layer · parses body Typed catch slack_view.py parses body Typed handler · slack_manager.py · 03 / Manager layer · renders Typed handler slack_manager.py renders Front matter · parseable · 04 / Output · agent-readable Front matter parseable agent-readable discards error swallowed no match falls through catch-all zero context HTTP 500 detail + error_id typed raise context preserved renders front matter YAML Legend preserved path discarded path

The error is identical in both rows. The only difference is whether we read the response body before we re-raise.

03

Key code

Two pieces. The exception carries the facts; the view is where the facts were being dropped.

integrations/exceptions.py
class APIConversationError(Exception):
    def __init__(self, status_code: int,
                 error_detail: str,
                 error_id: str | None = None):
        self.status_code = status_code
        self.error_detail = error_detail
        self.error_id = error_id
        super().__init__(
            f'API error {status_code}: {error_detail}')

    def to_slack_message(self) -> str:
        lines = ['---',
                 'error_type: api_error',
                 f'http_status: {self.status_code}']
        if self.error_id:
            lines.append(f'error_id: {self.error_id}')
        lines += ['---', '', self.error_detail]
        return '\n'.join(lines)
integrations/slack/slack_view.py
# before — the real error was discarded here
# except Exception as e:
#     raise Exception(
#         'Failed to send message to v1 conversation') from e

except httpx.HTTPStatusError as e:
    error_detail = str(e)
    error_id = None
    try:
        body = e.response.json()
        error_detail = body.get('detail', str(e))
        error_id = body.get('error_id')
    except Exception:
        pass
    raise APIConversationError(
        status_code=e.response.status_code,
        error_detail=error_detail,
        error_id=error_id,
    ) from e
04

Risks & mitigations

Risk
Sev
Mitigation
Upstream repo does not accept external pull requests, so the patch cannot land as a PR.
HIGH
Publish the branch on the fork and file a public issue on OpenHands/OpenHands carrying the diff and the reproduction. A maintainer applies it.
The same swallow exists in five other integrations (github, gitlab, bitbucket, bitbucket_dc, azure_devops).
MED
Scope this change to Slack; note the other five in the issue body as a mechanical follow-up — same three lines each.
Full test suite needs the enterprise dependency tree (litellm, openhands-sdk), which is not installable locally.
MED
Syntax verified locally; the two new tests are written to run in the existing suite on CI. Say "syntax verified, runtime CI" in the issue — never call it verified.
Front matter is agent-friendly but unfamiliar to a human reading raw Slack.
LOW
The detail line sits below the block and reads plainly on its own; the front matter is additive, not a replacement for prose.
05

Open questions

Should the other five integrations be fixed in the same change?
The call is Slack-only for the first change. The other five share the exact pattern, so a single follow-up can sweep them — but each needs its own view-layer edit and its own test.
Decide with · maintainer, before follow-up
Which issue should this link to?
The motivating bug is a plugin schema crash, not #14480 (Slack resolver, missing critic key) or enterprise #296 (stale org key). Neither is this. File a new issue and reference the two as prior art.
Decide with · you, before filing