Skip to content

Separate shim and runner API errors from connection errors - #4251

Merged
un-def merged 1 commit into
masterfrom
pr_runner_shim_clients_http_errors
Sep 4, 2026
Merged

Separate shim and runner API errors from connection errors#4251
un-def merged 1 commit into
masterfrom
pr_runner_shim_clients_http_errors

Conversation

@un-def

@un-def un-def commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

runner_ssh_tunnel caught DstackError next to
requests.RequestException, so an error reported by the peer's API was indistinguishable from a connectivity failure. get_task() on an unknown id answered 404 and surfaced as the same False the decorator returns when the SSH tunnel is down. RunnerClient made it worse by letting bare requests.exceptions.HTTPError escape, which is a RequestException subclass.

Wrap runner HTTP errors as RunnerHTTPError, mirroring ShimClient, and share the wrapper implementation between the two clients. The tunnel now catches connection-level errors only, so anything escaping a client is either transport (requests.RequestException) or an answer from the peer (ShimError, RunnerError). SSHError is dropped from the catches as well: the tunnel is opened outside the guarded block, so it cannot be raised by the wrapped function.

Each call site now decides what an API error means for the job instead of inheriting a silent False. Two of them change behavior:

  • RunnerClient.healthcheck() no longer masks HTTP errors. A peer answering with an error status is not a runner that has yet to start and is not expected to become one, so it counts as unreachable (_RunnerAvailability.UNREACHABLE) rather than unavailable.
  • _process_running() no longer reports a LogStorageError as a disconnect, which used to terminate the job as "Instance is unreachable" while the instance was healthy and only the log storage was down. runner_timestamp is not advanced, so the same logs and job state events are pulled again instead of being lost.

The remaining sites keep their previous outcome and log the cause. _get_gpu_driver() and _maybe_install_components() gain catch-alls so optional metadata and opportunistic component installation cannot fail an instance check.

Known gap, to be addressed separately: pydantic.ValidationError is a ValueError, so a response body that fails to validate still escapes. That is pre-existing -- neither DstackError nor RequestException covered it either.

`runner_ssh_tunnel` caught `DstackError` next to
`requests.RequestException`, so an error reported by the peer's API was
indistinguishable from a connectivity failure. `get_task()` on an
unknown id answered 404 and surfaced as the same `False` the decorator
returns when the SSH tunnel is down. `RunnerClient` made it worse by
letting bare `requests.exceptions.HTTPError` escape, which is a
`RequestException` subclass.

Wrap runner HTTP errors as `RunnerHTTPError`, mirroring `ShimClient`,
and share the wrapper implementation between the two clients. The
tunnel now catches connection-level errors only, so anything escaping a
client is either transport (`requests.RequestException`) or an answer
from the peer (`ShimError`, `RunnerError`). `SSHError` is dropped from
the catches as well: the tunnel is opened outside the guarded block, so
it cannot be raised by the wrapped function.

Each call site now decides what an API error means for the job instead
of inheriting a silent `False`. Two of them change behavior:

* `RunnerClient.healthcheck()` no longer masks HTTP errors. A peer
  answering with an error status is not a runner that has yet to start
  and is not expected to become one, so it counts as unreachable
  (`_RunnerAvailability.UNREACHABLE`) rather than unavailable.
* `_process_running()` no longer reports a `LogStorageError` as a
  disconnect, which used to terminate the job as "Instance is
  unreachable" while the instance was healthy and only the log storage
  was down. `runner_timestamp` is not advanced, so the same logs and
  job state events are pulled again instead of being lost.

The remaining sites keep their previous outcome and log the cause.
`_get_gpu_driver()` and `_maybe_install_components()` gain catch-alls
so optional metadata and opportunistic component installation cannot
fail an instance check.

Known gap, to be addressed separately: `pydantic.ValidationError` is a
`ValueError`, so a response body that fails to validate still escapes.
That is pre-existing -- neither `DstackError` nor `RequestException`
covered it either.
@un-def
un-def requested a review from r4victor September 2, 2026 15:56
@un-def
un-def merged commit b6d3aea into master Sep 4, 2026
27 checks passed
@un-def
un-def deleted the pr_runner_shim_clients_http_errors branch September 4, 2026 08:29
un-def added a commit that referenced this pull request Sep 4, 2026
Follow-up to #4251, which separated API errors from connection errors
but left three loose ends.

`ShimHTTPError`/`RunnerHTTPError` were misnamed. We raise them only for
status codes, while "HTTP error" suggests anything about the protocol,
including transport. Rename them under a parent that says what the
family means -- the peer answered, the answer is unusable, and repeating
the request is not expected to help:

    ShimError
    |-- ShimAPIVersionError            # our bug, stays loud
    `-- ShimResponseError
        |-- ShimResponseStatusError    # 4xx/5xx as API error codes
        `-- ShimResponseBodyError      # the body cannot be read

Call sites catch the `*ResponseError` parent: none of them cares which
leaf it was.

Build the errors from the `Response` instead of wrapping the one from
`raise_for_status()`. Its message carried a reason phrase that Go
derives from the status code alone, a client/server split that 4xx vs
5xx already says, and a URL whose authority is always localhost or a
percent-encoded socket path -- but not the body, which is where shim and
runner put the actual message.

Before:

    404 Client Error: Not Found for url:
    http+unix://%2Ftmp%2F.../api/tasks/abc

After:

    GET /api/tasks/abc: 404: Task not found

Parse response bodies with pydantic instead of `Response.json()`.
Besides saving a decode and an intermediate dict, this fixes a
misclassification: `Response.json()` raises `requests.JSONDecodeError`,
which is a `RequestException`, so a peer sending garbage was reported
as a transport failure and retried. Both malformed JSON and a schema
mismatch now raise `ValidationError`, wrapped as `*ResponseBodyError`.
Nothing inside a client method raises `RequestException` any more except
genuine transport, which is what `runner_ssh_tunnel` already documents.

This also closes the `pydantic.ValidationError` leak noted in #4251:
an unparsable response body no longer escapes the pipeline tasks.
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.

1 participant