play-faults fix: read the body as bytes so a cut body is a transport failure

reqwest's json() reports a body cut mid-stream as a decode error, so the
is_decode branch in send_fault misclassed it BadReply. The body is now read
with bytes() through send_fault (timeout / connect / no complete reply), and
only a whole body that fails serde_json is classed by bad_reply().

Not yet built at this commit; verified by the following job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 22:35:13 +02:00
commit 5a876bf615

View file

@ -197,16 +197,18 @@ impl LocalInferencePort {
}
.classed(class));
}
response.json::<LocalChatResponse>().await.map_err(send_fault)
let body = response.bytes().await.map_err(send_fault)?;
serde_json::from_slice(&body).map_err(|_| bad_reply())
}
}
/// The one classifier of a send that failed before a status arrived or while
/// its body was read. A timeout is not retried, because the endpoint may still
/// be generating. A body that arrived whole but does not decode is `BadReply`,
/// an integrity violation. Anything else means no complete reply came (a
/// refused or closed connection, a body cut off mid-stream), so retrying is
/// safe. The fault carries the kind and never the error's own text:
/// The classifier of every transport failure of a send: one that failed before
/// a status arrived, or while the reply body was read. A timeout is not
/// retried, because the endpoint may still be generating. Anything else means
/// no complete reply came (a refused or closed connection, a body cut off
/// mid-stream), so retrying is safe. A body that arrived whole but is not the
/// declared shape is not a transport failure: `bad_reply` below classes it.
/// The fault carries the kind and never the error's own text:
/// `reqwest::Error`'s `Display` ends with the request URL, so formatting it
/// would put the endpoint into every log line and card that renders the
/// detail. Tests pin this for each class.
@ -214,11 +216,6 @@ fn send_fault(error: reqwest::Error) -> InferenceFault {
if error.is_timeout() {
InferenceFault::new("the local inference request timed out")
.classed(InferenceFaultClass::Timeout)
} else if error.is_decode() {
InferenceFault::integrity_violation(
"the local inference endpoint's reply was not the declared shape",
)
.classed(InferenceFaultClass::BadReply)
} else if error.is_connect() {
InferenceFault::retryable("the local inference endpoint refused the connection")
.classed(InferenceFaultClass::Connect)
@ -228,6 +225,15 @@ fn send_fault(error: reqwest::Error) -> InferenceFault {
}
}
/// A complete reply body that is not the declared shape: an integrity
/// violation, never retried. The decode error's text is not carried.
fn bad_reply() -> InferenceFault {
InferenceFault::integrity_violation(
"the local inference endpoint's reply was not the declared shape",
)
.classed(InferenceFaultClass::BadReply)
}
/// Lowers one prepared request to the OpenAI chat-completions body: the
/// system message is the request's instructions, every input item maps to
/// one message in order, and every offered tool is declared `strict`. No