Skip to content
Closed
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
71 changes: 58 additions & 13 deletions Sources/ATResolve/ATResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,67 @@ public struct ATResolver<Provider: ResponseProviding> {
}

public func didForDomain(_ name: String) async throws -> String? {
// I don't understand exactly why, but this triggers a timeout. When I do it with `dig` it returns right away...
if name.hasSuffix(".bsky.social") {
return await withTaskGroup(of: Optional<String>.self) { group in
let provider = provider
group.addTask {
await Self.checkWellKnown(handle: name, provider: provider)
}

group.addTask {
await Self.checkDNS(handle: name)
}

let first = await group.next()
if let first {
return first
}

return await group.next() ?? nil
}
}

static func checkWellKnown(handle: String, provider: Provider) async -> String? {
do {
let dataResult = try await provider.data(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if the Provider here would allow it, but you could set a maximum response body size to like 1kb, and stop reading the response after that, since anything larger and you know it's not a valid response (it's likely HTML)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be possible to make an appropriate provider function that does this, if the underlying request system allows it?

for: .init(
host: handle,
path: "/.well-known/atproto-did",
method: .get,
headers: ["Accept": "text/plain;charset=UTF-8"],
queryItems: []
)
)
let result = String(data: dataResult, encoding: .utf8)

if let result {
//workaround if we get erroneous 200 code but body return is e.g.
//"404 error"
guard result.hasPrefix("did:") else {
return nil
}
}
return result
} catch {
return nil
}

let resolver = try AsyncDNSResolver()

let txtRecords = try await resolver.queryTXT(name: "_atproto." + name)

let didRecord = txtRecords.first { record in
record.txt.hasPrefix("did=")
}

static func checkDNS(handle: String) async -> String? {
do {
// Only check Cloudflare and Google DNS servers
var dnsOptions = CAresDNSResolver.Options.default
dnsOptions.servers = ["1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4"]
Comment on lines +90 to +92

@anna-germ anna-germ Dec 22, 2025

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resolves the DNS timeout issue, but we can also adjust dnsOptions.timeoutMillis and dnsOptions.attempts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could make this a variable on the resolver, and allow overriding it, but using these defaults — since they're major DNS server providers. We may want to provide IPv6 options here too.

Another option is to not use AsyncDNSResolver and instead just use DNS over HTTPS: https://github.com/bluesky-social/atproto/blob/9dac8b0c600520ecb0066ac104787b27668dea47/packages/internal/handle-resolver/src/atproto-doh-handle-resolver.ts#L37

which would be somewhat more secure than using standard DNS (which is cleartext). That would also then allow this to be fully cancellable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds like a really cool option. Perhaps that could be another thing in the chain of checks this system does?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, with a bit of reorganisation we could hsve different handle resolvers, DNS, HTTP and DNS over HTTPS, and then folks could choose the right methods for them?

i suspect DoH would be superior here even though response times might be slightly higher than DNS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds great to me! What I was trying to go for was a "all discrete options public API" so you can get the behaviors you need, if you have advanced requirements. And then also something pre-configured so there's an easy (but potentially suboptimal) thing for people that aren't interested in learning how it all works.

let resolver = try AsyncDNSResolver(options: dnsOptions)
let txtRecords = try await resolver.queryTXT(
name: "_atproto." + handle
)
let didRecord = txtRecords.first { record in
record.txt.hasPrefix("did=")
}
return didRecord?.txt.components(separatedBy: "=").last

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could technically have issues, as some did methods can include query strings, so you probably want to just split on = and then drop the first component, then join the rest.

} catch {
return nil
}

return didRecord?.txt.components(separatedBy: "=").last
}

public func didForHandle(_ handle: String) async throws -> String? {
Expand Down Expand Up @@ -95,8 +142,6 @@ public struct ATResolver<Provider: ResponseProviding> {
}
}

extension ATResolver: Sendable where Provider: Sendable {}

#if canImport(Foundation)
import Foundation

Expand Down
1 change: 1 addition & 0 deletions Sources/ATResolve/Networking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extension URLSession: ResponseProviding {
throw URLError(.badURL)
}
var urlRequest = URLRequest(url: url)
urlRequest.timeoutInterval = 3
urlRequest.httpMethod = request.method.rawValue
for (key, value) in request.headers {
urlRequest.addValue(value, forHTTPHeaderField: key)
Expand Down
2 changes: 1 addition & 1 deletion Sources/ATResolve/ResponseProviding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public struct Request: Sendable {
public let queryItems: [(String, String?)]
}

public protocol ResponseProviding {
public protocol ResponseProviding: Sendable {

@anna-germ anna-germ Dec 12, 2025

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mark pointed out that you have a conditional conformance of ATResolve being Sendable if ResponseProviding is Sendable -- I'm making new problems for you by now requiring ResponseProviding to always be sendable. This is our best attempt at making the task groups work, but would defer to your expertise!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very generally speaking, Sendable protocols making the lives of the users of the protocol (that's us here) easier, while making the conformer's lives (that's the libraries clients) harder. In this case, however, I think it is pretty reasonable. So I think it's fine, we just need to remove the conditional conformance.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func data(for: Request) async throws -> Data
}

Expand Down
28 changes: 28 additions & 0 deletions Tests/ATResolveTests/ATResolveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ struct ATResolveTests {

#expect(profile != nil)
}

@Test
func timedTestWellKnownTimeout() async throws {
// The /.well-known endpoint times out for @thisismissem.social
// It should time out after 3 seconds, so this test should be ~3 seconds
try await timedTest {
let resolver = ATResolver(provider: URLSession.shared)
let profile = try await resolver.resolveHandle("thisismissem.social")
#expect(profile?.did == "did:plc:5w4eqcxzw5jv5qfnmzxcakfy")
}
}

@Test
func timedTestDNSTimeout() async throws {
// DNS should time out for any .bsky.social handle
try await timedTest {
let resolver = ATResolver(provider: URLSession.shared)
let profile = try await resolver.resolveHandle("cjrdev.bsky.social")
#expect(profile?.did == "did:plc:wlef3srsa3hlyzj2hy6yncrh")
}
}

@Test func decodeWithCustomProvider() async throws {
struct CustomProvider: ResponseProviding {
Expand All @@ -60,4 +81,11 @@ struct ATResolveTests {

#expect(response.pds?.serviceEndpoint == "https://milkcap.us-west.host.bsky.network")
}

private func timedTest(_ test: () async throws -> ()) async throws {
let start = CFAbsoluteTimeGetCurrent()
try await test()
let diff = CFAbsoluteTimeGetCurrent() - start
print("This test took \(diff) seconds")
}
}
Loading