webapi: xhr/fetch report status reason from server

If the server gives a status reason, report it as-is. Only default to the
zig code->reason map when one isn't given.

Also, don't force a 407 status code when auth_challenge is present.
This commit is contained in:
Karl Seguin committed 2026-09-28 19:01:39 +08:00
1 parent 58357fea3e
commit f7fbc2f34f
6 files changed
+133 -6

No files matched your search

+40
View File
@@ -194,6 +194,46 @@
}
</script>
<script id=xhr_reason_phrase type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
req.onload = () => { state.resolve() };
req.open('GET', 'http://127.0.0.1:9582/xhr/reason');
req.send();
await state.done(() => {
testing.expectEqual(503, req.status);
testing.expectEqual('HOUSTON WE HAVE A', req.statusText);
});
}
</script>
<script id=xhr_reason_phrase_sync>
{
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr/reason', false);
req.send();
testing.expectEqual(503, req.status);
testing.expectEqual('HOUSTON WE HAVE A', req.statusText);
}
</script>
<script id=xhr_401_challenge type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
req.onload = () => { state.resolve() };
req.open('GET', 'http://127.0.0.1:9582/xhr/401');
req.send();
await state.done(() => {
testing.expectEqual(401, req.status);
testing.expectEqual('Unauthorized', req.statusText);
});
}
</script>
<script id=xhr_abort type=module>
{
const state = await testing.async();
+4 -1
View File
@@ -234,7 +234,10 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
res._status = status;
res._status_text = std.http.Status.phrase(@enumFromInt(status)) orelse "";
res._status_text = if (transfer.statusText()) |st|
try arena.allocator().dupe(u8, st)
else
std.http.Status.phrase(@enumFromInt(status)) orelse "";
res._url = try arena.dupeZ(u8, transfer.req.url);
res._is_redirected = transfer.redirectCount().? > 0;
+11
View File
@@ -66,6 +66,7 @@ _async: bool = true,
_response: ?Response = null,
_response_data: std.ArrayList(u8) = .empty,
_response_status: u16 = 0,
_response_status_text: ?[]const u8 = null,
_response_len: ?usize = 0,
_response_url: [:0]const u8 = "",
_override_mime: ?Mime = null,
@@ -230,6 +231,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8, async
self._response_xml = null;
self._response_data.clearRetainingCapacity();
self._response_status = 0;
self._response_status_text = null;
self._response_len = 0;
self._response_url = "";
self._response_mime = null;
@@ -397,6 +399,9 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v
defer self.releaseSelfRef();
self._response_status = resp.status;
if (resp.status_text.get()) |st| {
self._response_status_text = try self._arena.dupe(u8, st);
}
self._response_url = self._url;
self._response_len = resp.body.items.len;
@@ -510,6 +515,9 @@ pub fn getStatus(self: *const XMLHttpRequest) u16 {
}
fn getStatusText(self: *const XMLHttpRequest) []const u8 {
if (self._response_status_text) |st| {
return st;
}
return std.http.Status.phrase(@enumFromInt(self._response_status)) orelse "";
}
@@ -660,6 +668,9 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
self._response_status = transfer.responseStatus().?;
if (transfer.statusText()) |st| {
self._response_status_text = try self._arena.dupe(u8, st);
}
if (transfer.getContentLength()) |cl| {
self._response_len = cl;
}
+25 -5
View File
@@ -1434,6 +1434,7 @@ const SyncContext = struct {
} = .in_progress,
status: u16 = 0,
status_text: http.StatusText = .{},
body: std.ArrayList(u8),
// Acquired on the first byte we have to buffer, so a bodyless response
@@ -1444,6 +1445,7 @@ const SyncContext = struct {
const self: *SyncContext = @ptrCast(@alignCast(transfer.req.ctx));
lp.assert(transfer.responseStatus() != null, "HttpClient.SyncRequest.headerCallback", .{ .value = transfer.responseStatus() });
self.status = transfer.responseStatus().?;
self.status_text = transfer.res.status_text;
const body_len = transfer.bodyLen();
if (body_len > 0) {
try self.body.ensureTotalCapacityPrecise(try self.bodyAllocator(body_len), body_len);
@@ -2112,6 +2114,7 @@ pub const Request = struct {
const SyncResponse = struct {
status: u16,
body: std.ArrayList(u8),
status_text: http.StatusText,
// Owns `body`. Null when the response had nothing to buffer. Callers that
// keep `body` past this call take the arena instead of releasing it.
@@ -2671,6 +2674,7 @@ pub const Transfer = struct {
.in_progress => @panic("Impossible to be in progress here."),
.done, .shutdown => return .{
.status = sync_ctx.status,
.status_text = sync_ctx.status_text,
.body = sync_ctx.body,
.arena = sync_ctx.arena,
},
@@ -3478,10 +3482,11 @@ pub const Transfer = struct {
const url = try conn.getEffectiveUrl();
const status: u16 = if (self._auth_challenge != null)
407
else
try conn.getResponseCode();
var status = try conn.getResponseCode();
if (status == 0 and self._auth_challenge != null) {
// A proxy that refuses the CONNECT gives us no status code
status = try conn.getConnectCode();
}
self.res.header = .{
.url = url,
@@ -3831,7 +3836,14 @@ pub const Transfer = struct {
std.debug.assert(chunk_count == 1);
}
if (announcesBody(buffer[0..chunk_len]) == false) {
const line = buffer[0..chunk_len];
if (std.mem.startsWith(u8, line, "HTTP/")) {
const conn: *http.Connection = @ptrCast(@alignCast(data));
conn.transport.http.res.status_text = .fromStatusLine(line);
return chunk_len;
}
if (announcesBody(line) == false) {
return chunk_len;
}
@@ -4010,6 +4022,10 @@ pub const Transfer = struct {
return rh.status;
}
pub fn statusText(self: *const Transfer) ?[]const u8 {
return self.res.status_text.get();
}
pub fn contentType(self: *Transfer) ?[]const u8 {
if (self.res.header) |*rh| {
return rh.contentType();
@@ -4251,6 +4267,10 @@ const ResourceTiming = struct {
const Response = struct {
header: ?http.ResponseHead = null,
// From the most recent status line curl reported (a CONNECT, a 1xx or
// an auth challenge can precede the final one).
status_text: http.StatusText = .{},
// Full response headers, materialized into the transfer arena at
// completion (or set directly by cache / synthetic / fulfill). Names are
// lowercased.
+37
View File
@@ -228,6 +228,32 @@ pub const AuthChallenge = struct {
}
};
// The actual reason phrase from the server, verbatim. HTTP/2 has none, so "".
pub const StatusText = struct {
pub const MAX_LEN = 128;
_len: ?u8 = null,
_buf: [MAX_LEN]u8 = undefined,
pub fn fromStatusLine(line: []const u8) StatusText {
const trimmed = std.mem.trimEnd(u8, line, "\r\n");
// HTTP-version SP status-code SP [ reason-phrase ]
const sp1 = std.mem.indexOfScalar(u8, trimmed, ' ') orelse return .{ ._len = 0 };
const sp2 = std.mem.indexOfScalarPos(u8, trimmed, sp1 + 1, ' ') orelse return .{ ._len = 0 };
const phrase = trimmed[sp2 + 1 ..];
const len = @min(phrase.len, MAX_LEN);
var st: StatusText = .{ ._len = @intCast(len) };
@memcpy(st._buf[0..len], phrase[0..len]);
return st;
}
pub fn get(self: *const StatusText) ?[]const u8 {
const len = self._len orelse return null;
return self._buf[0..len];
}
};
pub const ResponseHead = struct {
// Matches Mime.parse's 255-byte cap
pub const MAX_CONTENT_TYPE_LEN = 255;
@@ -1041,6 +1067,17 @@ test "Header.param" {
try testing.expect((Header{ .name = "Content-Disposition", .value = "attachment; filename=\"\"" }).param("filename") == null);
}
test "StatusText.fromStatusLine" {
try testing.expect((StatusText{}).get() == null);
try testing.expectEqualSlices(u8, "OK", StatusText.fromStatusLine("HTTP/1.1 200 OK\r\n").get().?);
try testing.expectEqualSlices(u8, "HOUSTON WE HAVE A", StatusText.fromStatusLine("HTTP/1.1 503 HOUSTON WE HAVE A\r\n").get().?);
try testing.expectEqualSlices(u8, "lowercase", StatusText.fromStatusLine("HTTP/1.0 502 lowercase\r\n").get().?);
// curl's synthesized HTTP/2 status line has no phrase
try testing.expectEqualSlices(u8, "", StatusText.fromStatusLine("HTTP/2 200 \r\n").get().?);
try testing.expectEqualSlices(u8, "", StatusText.fromStatusLine("HTTP/1.1 200\r\n").get().?);
try testing.expectEqual(StatusText.MAX_LEN, StatusText.fromStatusLine("HTTP/1.1 200 " ++ "x" ** 200).get().?.len);
}
test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" {
testing.silenceLog(&.{.http});
+16
View File
@@ -884,6 +884,22 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
return req.respond("", .{ .status = @enumFromInt(code) });
}
if (std.mem.eql(u8, path, "/xhr/reason")) {
return req.respond("", .{
.status = .service_unavailable,
.reason = "HOUSTON WE HAVE A",
});
}
if (std.mem.eql(u8, path, "/xhr/401")) {
return req.respond("No", .{
.status = .unauthorized,
.extra_headers = &.{
.{ .name = "WWW-Authenticate", .value = "Basic realm=\"test\"" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/500")) {
return req.respond("Internal Server Error", .{
.status = .internal_server_error,