Skip to content

Commit 5280149

Browse files
jvmvikCopilot
andcommitted
Make persistent connections actually reuse the socket
Re-running the benchmark after the timeout change showed persistent and non-persistent within noise of each other, and the connection pool always empty. Root cause is in std.http.Client.fetch (0.16): when a body is both compressed and chunked, which is serpapi.com's default framing, the decompressor stops at the end of the gzip stream and the terminating "0\r\n\r\n" chunk is never read. Request.deinit then sees the body as unfinished and closes the connection, so keep_alive was silently a no-op and every request paid for a fresh TCP + TLS handshake. fetchStatus now drives the request itself (mirroring fetch) and, after the decompressed body has been streamed, drains the remaining framing through req.reader.interface so the body is marked complete and the connection returns to the pool. zig build bench, 10 sequential searches, three rounds: before: non-persistent 2.1-2.5s, persistent 2.4-2.7s after: non-persistent 2.1-2.4s, persistent 0.5-1.0s (3-4x) A unit test serves two chunked gzip responses on one loopback connection and asserts the pool holds the connection between them; it fails without the drain. Docs updated from the old "2x" claim, which the code had never actually delivered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c014239 commit 5280149

4 files changed

Lines changed: 131 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
- `serpapi.version` is derived from `build.zig.zon` at build time instead
1111
of being duplicated in `src/client.zig`, so the tag cut by `rake release`
1212
and the version reported to serpapi.com cannot drift apart
13+
- Fix: persistent connections are actually reused. `std.http.Client.fetch`
14+
leaves the terminating chunk of a compressed, chunked response unread,
15+
so it judged every serpapi.com body unfinished and closed the socket:
16+
`persistent = true` was silently a no-op and each request paid for a new
17+
TLS handshake. The client now drives the request itself and drains the
18+
framing; `zig build bench` goes from ~2.4s to ~0.5-1.0s for 10 searches
19+
(3-4x), where before persistent and non-persistent were within noise
1320
- Fix: the `timeout` option is enforced. It was accepted and documented
1421
but never applied, so a stalled connection blocked forever. Each request
1522
now races a timer on the client's `std.Io.Threaded` pool and is canceled

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ SerpApi supports Google, Google Maps, Google Shopping, Baidu, Yandex, Yahoo, eBa
1313
Query a vast range of data at scale, including web search results, flight schedules, stock market data, news headlines, and [more](https://serpapi.com).
1414

1515
## Features
16-
* `persistent` → Keep socket connection open to save on SSL handshake / reconnection (2x faster).
16+
* `persistent` → Keep socket connection open to save on SSL handshake / reconnection (3-4x faster on repeated searches).
1717
* `md` → Markdown output, token-efficient and ready to feed to LLMs and AI agents. [Search API — Markdown](#search-api--markdown)
1818
* zero dependency → only the Zig standard library (`std.http`, `std.json`), nothing else to fetch.
1919
* cross platform → Linux, macOS, and Windows.
@@ -317,8 +317,10 @@ var results = try client.searchArchive(search_id);
317317
## Search at scale
318318

319319
With `persistent = true` (the default), the client keeps the TLS connection
320-
to serpapi.com open between requests, which roughly doubles throughput on
321-
repeated searches (measure it yourself with `zig build bench`):
320+
to serpapi.com open between requests, skipping the TCP + TLS handshake on
321+
every search after the first. On repeated searches this is 3-4x faster than
322+
reconnecting (10 sequential searches: ~0.5-1.0s persistent vs ~2.2s not;
323+
measure it yourself with `zig build bench`):
322324

323325
```zig
324326
for (queries) |query| {

‎bench/benchmark.zig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Benchmark: SerpApi client with and without persistent connection.
22
//!
3-
//! Demonstrates that persistent (keep-alive) connections are ~2x quicker
3+
//! Demonstrates that persistent (keep-alive) connections are ~3-4x quicker
44
//! than reconnecting for every request, by timing sequential searches.
55
//! Results are printed and appended to a CSV file:
66
//! serpapi_zig_<zig_version>_<timestamp>.csv

‎src/client.zig‎

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ pub const Client = struct {
7474
params: std.array_list.Managed(Param),
7575
/// HTTP request timeout in seconds, 0 to wait forever [default: 120].
7676
timeout: u32,
77-
/// Keep the connection open between requests (2x faster). [default: true]
77+
/// Keep the connection open between requests (3-4x faster). [default: true]
7878
persistent: bool,
7979
/// Last error payload returned by serpapi.com (owned), see `errorMessage`.
8080
last_error: ?[]u8 = null,
@@ -91,7 +91,7 @@ pub const Client = struct {
9191
/// Covers the whole request, from connecting to the last body byte;
9292
/// an overrun returns `error.Timeout`. 0 disables the limit.
9393
/// * `persistent`: keep the socket open to save on SSL handshake /
94-
/// reconnection (2x faster) [default: true]
94+
/// reconnection (3-4x faster) [default: true]
9595
///
9696
/// Every other field (`api_key`, `engine`, `gl`, ...) becomes a default
9797
/// query parameter applied to every request:
@@ -459,7 +459,7 @@ pub const Client = struct {
459459

460460
/// Outcome of racing the request against the timeout timer.
461461
const Race = union(enum) {
462-
request: std.http.Client.FetchError!std.http.Status,
462+
request: FetchError!std.http.Status,
463463
timer: std.Io.Cancelable!void,
464464
};
465465

@@ -502,13 +502,62 @@ pub const Client = struct {
502502
}
503503
}
504504

505-
fn fetchStatus(http: *std.http.Client, url: []const u8, w: *std.Io.Writer, keep_alive: bool) std.http.Client.FetchError!std.http.Status {
506-
const result = try http.fetch(.{
507-
.location = .{ .url = url },
508-
.response_writer = w,
505+
/// Errors from `fetchStatus`: everything `std.http.Client.fetch` reports,
506+
/// plus body decoding failures surfaced while draining the framing.
507+
const FetchError = std.http.Client.FetchError || std.http.Reader.BodyError || Allocator.Error;
508+
509+
/// GET `url` and stream the decoded body to `w`, returning the status.
510+
///
511+
/// This mirrors `std.http.Client.fetch`, with one addition. When the
512+
/// body is compressed *and* chunked (serpapi.com's default), the
513+
/// decompressor stops at the end of the gzip stream, leaving the
514+
/// terminating `0\r\n\r\n` chunk unread. std's `fetch` then sees the
515+
/// body as unfinished and closes the connection, so `keep_alive` was
516+
/// silently a no-op and every request paid for a new TLS handshake.
517+
/// Draining the framing marks the body complete and lets the connection
518+
/// return to the pool. Measured: ~4x faster on repeated searches.
519+
fn fetchStatus(http: *std.http.Client, url: []const u8, w: *std.Io.Writer, keep_alive: bool) FetchError!std.http.Status {
520+
const uri = try std.Uri.parse(url);
521+
522+
var req = try http.request(.GET, uri, .{
523+
// follow up to 3 redirects, as std.http.Client.fetch does
524+
.redirect_behavior = @enumFromInt(3),
509525
.keep_alive = keep_alive,
510526
});
511-
return result.status;
527+
defer req.deinit();
528+
try req.sendBodiless();
529+
530+
var redirect_buffer: [8 * 1024]u8 = undefined;
531+
var response = try req.receiveHead(&redirect_buffer);
532+
533+
const decompress_buffer: []u8 = switch (response.head.content_encoding) {
534+
.identity => &.{},
535+
.zstd => try http.allocator.alloc(u8, std.compress.zstd.default_window_len),
536+
.deflate, .gzip => try http.allocator.alloc(u8, std.compress.flate.max_window_len),
537+
.compress => return error.UnsupportedCompressionMethod,
538+
};
539+
defer http.allocator.free(decompress_buffer);
540+
541+
var transfer_buffer: [64]u8 = undefined;
542+
var decompress: std.http.Decompress = undefined;
543+
const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
544+
_ = reader.streamRemaining(w) catch |err| switch (err) {
545+
error.ReadFailed => return response.bodyErr() orelse error.ReadFailed,
546+
else => |e| return e,
547+
};
548+
549+
// Consume whatever transfer framing the decompressor left behind so
550+
// `req.deinit` sees a finished body and keeps the connection.
551+
switch (req.reader.state) {
552+
.body_remaining_chunk_len, .body_remaining_content_length => {
553+
_ = req.reader.interface.discardRemaining() catch |err| switch (err) {
554+
error.ReadFailed => return response.bodyErr() orelse error.ReadFailed,
555+
};
556+
},
557+
else => {},
558+
}
559+
560+
return response.head.status;
512561
}
513562

514563
fn getJson(self: *Client, comptime T: type, endpoint: []const u8, params: anytype, extra: []const Param) !std.json.Parsed(T) {
@@ -916,3 +965,64 @@ test "fetch returns before the timeout when the server answers" {
916965
try testing.expectEqualStrings("{\"ok\":true}\n", body.written());
917966
try answer.await(io);
918967
}
968+
969+
test "persistent connection is reused after a chunked gzip response" {
970+
var client = try Client.init(testing.allocator, .{ .timeout = 30 });
971+
defer client.deinit();
972+
const io = client.threaded.io();
973+
974+
const loopback: std.Io.net.IpAddress = try .parseIp4("127.0.0.1", 0);
975+
var server = try loopback.listen(io, .{});
976+
defer server.deinit(io);
977+
978+
// serpapi.com's default framing: Transfer-Encoding: chunked + gzip.
979+
// Two requests are answered on the same accepted connection; the second
980+
// can only succeed if the client kept the first connection alive.
981+
const Server = struct {
982+
fn serve(server_ptr: *std.Io.net.Server, server_io: std.Io, allocator: Allocator) !void {
983+
var stream = try server_ptr.accept(server_io);
984+
defer stream.close(server_io);
985+
var read_buffer: [4096]u8 = undefined;
986+
var reader = stream.reader(server_io, &read_buffer);
987+
var write_buffer: [1024]u8 = undefined;
988+
var writer = stream.writer(server_io, &write_buffer);
989+
990+
var gz: std.Io.Writer.Allocating = .init(allocator);
991+
defer gz.deinit();
992+
try gz.ensureUnusedCapacity(64);
993+
const window = try allocator.alloc(u8, std.compress.flate.max_window_len);
994+
defer allocator.free(window);
995+
var compress: std.compress.flate.Compress = try .init(&gz.writer, window, .gzip, .default);
996+
try compress.writer.writeAll("{\"ok\":true}");
997+
try compress.finish();
998+
try gz.writer.flush();
999+
const payload = gz.written();
1000+
1001+
for (0..2) |_| {
1002+
while (true) {
1003+
const line = try reader.interface.takeDelimiterInclusive('\n');
1004+
if (line.len <= 2) break;
1005+
}
1006+
try writer.interface.writeAll("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n");
1007+
try writer.interface.print("{x}\r\n", .{payload.len});
1008+
try writer.interface.writeAll(payload);
1009+
try writer.interface.writeAll("\r\n0\r\n\r\n");
1010+
try writer.interface.flush();
1011+
}
1012+
}
1013+
};
1014+
var served = try io.concurrent(Server.serve, .{ &server, io, testing.allocator });
1015+
defer _ = served.cancel(io) catch {};
1016+
1017+
const url = try std.fmt.allocPrint(testing.allocator, "http://127.0.0.1:{d}/search", .{server.socket.address.getPort()});
1018+
defer testing.allocator.free(url);
1019+
1020+
for (0..2) |_| {
1021+
var body: std.Io.Writer.Allocating = .init(testing.allocator);
1022+
defer body.deinit();
1023+
try testing.expectEqual(std.http.Status.ok, try client.fetch(url, &body.writer));
1024+
try testing.expectEqualStrings("{\"ok\":true}", body.written());
1025+
try testing.expectEqual(@as(usize, 1), client.http.connection_pool.free_len);
1026+
}
1027+
try served.await(io);
1028+
}

0 commit comments

Comments
 (0)