atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

release: v0.3.0-alpha.23

propagate the underlying std.http.Client.fetch error from
HttpTransport.fetch and DidResolver.resolve instead of collapsing every
transport-layer failure (DNS, connect, TLS) to error.RequestFailed /
error.DidResolutionFailed.

motivation: zlay 2026-04-08 incident — host_authority resolver pool hit
~99% rejection rate and the only error visible to the validator was
error.DidResolutionFailed because both layers had been swallowed. we had
to ship a workaround (keep_alive=false on the pool) without ever
identifying the actual transport error. this fix unblocks the next
investigation.

regression test: src/internal/identity/did_resolver.zig asserts the
returned error name is not the catch-all sentinel for a connect-refused
case (did:web:127.0.0.1 → 127.0.0.1:443 unlistened).

soft-breaking: the inferred error set on resolve/fetch widens. callers
using `try` or `catch |err| { ... }` are unaffected. callers using an
exhaustive switch on the prior narrow set would need to handle the
additional variants — none in zat itself, and zlay's validator uses the
catch-binding pattern.

+38 -4
+1
CHANGELOG.md
··· 5 5 - **breaking**: zig 0.16 — all networking APIs take `io: std.Io` as first parameter 6 6 - **breaking**: streaming clients use `subscribe(handler)` pattern instead of `connect()` + `next()` loop 7 7 - **breaking**: websocket.zig bumped — Io-native server accept loop, client write lock, TLS stream support 8 + - **fix**: `DidResolver.resolve` and `HttpTransport.fetch` propagate the underlying `std.http.Client.fetch` error instead of collapsing every transport-layer failure (DNS, connect, TLS) to `error.DidResolutionFailed` / `error.RequestFailed` — callers can distinguish failure modes via `@errorName(err)`. soft-breaking: the inferred error set widens 8 9 - **feat**: `Io.Timestamp` replaces libc `gettimeofday` in JWT/OAuth 9 10 - **feat**: `io.sleep()` replaces libc `nanosleep` in reconnect backoff (cancellation-aware) 10 11 - **docs**: [devlog 008](devlog/008-the-io-migration.md) — the 0.16 migration
+1 -1
build.zig.zon
··· 1 1 .{ 2 2 .name = .zat, 3 - .version = "0.3.0-alpha.22", 3 + .version = "0.3.0-alpha.23", 4 4 .fingerprint = 0x8da9db57ee82fbe4, 5 5 .minimum_zig_version = "0.16.0-dev.3070+b22eb176b", 6 6 .dependencies = .{
+34 -1
src/internal/identity/did_resolver.zig
··· 95 95 96 96 /// fetch and parse a did document from url 97 97 fn fetchDidDocument(self: *DidResolver, url: []const u8) !DidDocument { 98 - const result = self.transport.fetch(.{ .url = url }) catch return error.DidResolutionFailed; 98 + const result = try self.transport.fetch(.{ .url = url }); 99 99 defer self.allocator.free(result.body); 100 100 101 101 if (result.status != .ok) { ··· 139 139 defer doc.deinit(); 140 140 141 141 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id); 142 + } 143 + 144 + test "regression: transport errors propagate distinct kinds" { 145 + // before this fix, transport.fetch had `catch return error.RequestFailed` 146 + // and fetchDidDocument had `catch return error.DidResolutionFailed`, so 147 + // every transport-layer failure (DNS, TCP, TLS) collapsed to one 148 + // indistinguishable error and callers had no way to see what was wrong. 149 + // this regression test asserts the underlying error kind survives the 150 + // resolver layer for at least one common transport failure mode. 151 + // 152 + // history: zlay 2026-04-08, where the host_authority pool failed at 100% 153 + // and we had no production telemetry on which transport error fired 154 + // because both layers had been swallowed. see relay docs/zlay-external- 155 + // review-2026-04-09.md. 156 + var resolver = DidResolver.init(std.Options.debug_io, std.testing.allocator); 157 + defer resolver.deinit(); 158 + 159 + // 127.0.0.1:443 is almost certainly not listening on a test machine. 160 + // did:web:127.0.0.1 → https://127.0.0.1/.well-known/did.json → connect refused. 161 + const did = Did.parse("did:web:127.0.0.1") orelse return error.SkipZigTest; 162 + if (resolver.resolve(did)) |doc| { 163 + // someone is actually serving a DID doc on 127.0.0.1:443 — skip rather 164 + // than fail, since the assertion below assumes a transport failure 165 + var d = doc; 166 + d.deinit(); 167 + return error.SkipZigTest; 168 + } else |err| { 169 + // exact error name varies by platform (ConnectionRefused on linux/darwin, 170 + // possibly different elsewhere). just assert it's not the catch-all that 171 + // the pre-fix code returned for everything. 172 + try std.testing.expect(err != error.DidResolutionFailed); 173 + try std.testing.expect(err != error.RequestFailed); 174 + } 142 175 } 143 176 144 177 test "did:web url construction" {
+2 -2
src/internal/xrpc/transport.zig
··· 59 59 } 60 60 } 61 61 62 - const result = self.http_client.fetch(.{ 62 + const result = try self.http_client.fetch(.{ 63 63 .location = .{ .url = options.url }, 64 64 .response_writer = &aw.writer, 65 65 .method = options.method, ··· 67 67 .headers = headers, 68 68 .extra_headers = extra_buf[0..extra_count], 69 69 .keep_alive = self.keep_alive, 70 - }) catch return error.RequestFailed; 70 + }); 71 71 72 72 return .{ 73 73 .status = result.status,