Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions sdk/go/dstack/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"net/http"
"os"
"strings"
"unicode/utf8"
)

// sdkVersion is reported in the User-Agent so an agent-side log can tell which
Expand Down Expand Up @@ -123,6 +124,58 @@ func (t *transport) getEndpoint() string {
return socketPaths[0]
}

// How much of a server response an error may quote, in characters.
//
// An agent with no route for the path answers with an HTML page, and pasting a
// whole page into an error helps nobody. Rust uses the same number for the same
// reason (`MAX_ERROR_BODY_CHARS` in the Rust SDK); JS uses 300.
//
// Characters rather than bytes so the bound cannot land inside a multi-byte
// sequence. The byte length that follows from it is larger, which is fine for
// something whose only job is to stop an error message running away.
const maxErrorBodyChars = 512

// How much of a failed response to read before giving up on making sense of it.
//
// Larger than the quote bound because the quote is taken from the `error` field
// *inside* the body, and a body cut off mid-string is no longer JSON -- so
// reading exactly 512 characters would turn every large prpc error into a raw
// truncated blob. 64 KiB covers any error an agent produces while still
// refusing to buffer an unbounded page, which is more than Rust does: it reads
// the whole body and bounds only the message.
const maxErrorBodyRead = 64 * 1024

func truncate(text string) string {
runes := []rune(text)
if len(runes) <= maxErrorBodyChars {
return text
}
return string(runes[:maxErrorBodyChars]) + "..."
}

// serverErrorText reports what the server said, as far as it can be made out.
//
// A prpc handler that refuses answers `{"error": "..."}` with a 4xx, and that
// field is the only part worth showing. A request that never reached a handler
// -- a `/v1` call against a pre-0.6 agent -- comes back as an HTML error page
// instead, and then the raw body is the only clue there is.
func serverErrorText(body []byte) string {
if !utf8.Valid(body) {
return "(non-utf8 response body)"
}
text := strings.TrimSpace(string(body))
if text == "" {
return "(empty response body)"
}
var probe struct {
Error *string `json:"error"`
}
if err := json.Unmarshal([]byte(text), &probe); err == nil && probe.Error != nil {
return truncate(*probe.Error)
}
return truncate(text)
}

// Sends an RPC request to the dstack service.
func (t *transport) sendRPCRequest(ctx context.Context, path string, payload interface{}) ([]byte, error) {
jsonData, err := json.Marshal(payload)
Expand All @@ -144,8 +197,11 @@ func (t *transport) sendRPCRequest(ctx context.Context, path string, payload int
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
// LimitReader, not ReadAll: the only use for these bytes is an error
// message, and buffering a whole HTML page to quote 512 characters of
// it is work with no purpose.
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyRead))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, serverErrorText(body))
}

body, err := io.ReadAll(resp.Body)
Expand Down
39 changes: 39 additions & 0 deletions sdk/go/dstack/transport_bounds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
//
// SPDX-License-Identifier: Apache-2.0

package dstack

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestErrorQuotesBoundedServerText(t *testing.T) {
truncated := strings.Repeat("E", maxErrorBodyChars) + "..."
tests := []struct {
name, body, want string
}{
{"short body", "<!DOCTYPE html>404", "<!DOCTYPE html>404"},
{"huge body", strings.Repeat("E", 20_000), truncated},
{"prpc error", `{"error":"algorithm is not supported"}`, "algorithm is not supported"},
{"huge prpc error", `{"error":"` + strings.Repeat("E", 8_000) + `"}`, truncated},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()

_, err := NewDstackClientV1(WithEndpoint(server.URL)).Version(context.Background())
if want := "HTTP 400: " + tt.want; err == nil || err.Error() != want {
t.Errorf("got %.120v, want %.120s", err, want)
}
})
}
}
16 changes: 15 additions & 1 deletion sdk/python/src/dstack_sdk/dstack_client_v0.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,20 @@ class BaseClient:
pass


#: How much of a server response an exception may quote, in characters.
#:
#: An agent with no route for the path answers with an HTML page, and pasting a
#: whole page into an exception helps nobody. Rust uses the same number for the
#: same reason (``MAX_ERROR_BODY_CHARS``); JS uses 300.
MAX_ERROR_BODY_CHARS = 512


def _truncate(text: str) -> str:
if len(text) <= MAX_ERROR_BODY_CHARS:
return text
return text[:MAX_ERROR_BODY_CHARS] + "..."


def raise_for_status(response: httpx.Response) -> None:
"""Raise on an error status, carrying the guest agent's message.

Expand All @@ -286,7 +300,7 @@ def raise_for_status(response: httpx.Response) -> None:
if not message:
raise
raise httpx.HTTPStatusError(
f"{exc.args[0]}\nguest agent said: {message}",
f"{exc.args[0]}\nguest agent said: {_truncate(message)}",
request=exc.request,
response=response,
) from None
Expand Down
27 changes: 27 additions & 0 deletions sdk/python/tests/test_error_bounds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
#
# SPDX-License-Identifier: Apache-2.0

import httpx
import pytest

from dstack_sdk.dstack_client_v0 import MAX_ERROR_BODY_CHARS
from dstack_sdk.dstack_client_v0 import raise_for_status

TRUNCATED = "E" * MAX_ERROR_BODY_CHARS + "..."


@pytest.mark.parametrize(
("body", "said"),
[
("<!DOCTYPE html>404", "<!DOCTYPE html>404"),
("E" * 20_000, TRUNCATED),
('{"error":"algorithm is not supported"}', "algorithm is not supported"),
('{"error":"' + "E" * 8_000 + '"}', TRUNCATED),
],
)
def test_error_quotes_bounded_server_text(body, said):
request = httpx.Request("POST", "http://localhost/v1/Version")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
raise_for_status(httpx.Response(400, content=body.encode(), request=request))
assert str(excinfo.value).endswith(f"\nguest agent said: {said}")
Loading