Resolving identities
The AT Protocol has two identifiers. A handle is a DNS name that can change; a DID is a stable identifier that cannot. Records, sessions and tokens all use DIDs, so most of what you do with identity is turning a handle into a DID, and a DID into the document that says where the account lives and which key it signs with.
The identity rules themselves are the protocol’s, not this SDK’s. See atproto.com/specs/handle and atproto.com/specs/did.
IdResolver
IdResolver bundles the two resolvers and shares one timeout, one PLC directory URL and one cache between them. handle resolves handles to DIDs; did resolves DIDs to documents.
from atproto import IdResolver
resolver = IdResolver()
did = resolver.handle.resolve('test.marshal.dev')
did_doc = resolver.did.resolve(did)
print(did)
print(did_doc.get_pds_endpoint())
from atproto import AsyncIdResolver
resolver = AsyncIdResolver()
did = await resolver.handle.resolve('test.marshal.dev')
did_doc = await resolver.did.resolve(did)
print(did)
print(did_doc.get_pds_endpoint())
The PLC directory defaults to https://plc.directory and requests time out after 3 seconds. Both are constructor arguments, along with cache:
resolver = IdResolver(plc_url='https://plc.example.com', timeout=10.0, cache=cache)
Everything below is reachable through the two properties. You can also construct HandleResolver and DidResolver directly if you only need one of them.
Resolving handles
resolve tries DNS first, then HTTP, and returns the first DID it gets. It returns None when neither method produces one: a handle that does not exist, a domain that does not answer, and a network failure are all the same None.
ensure_resolve is the same lookup but raises DidNotFoundError instead of returning None. Use it when a missing handle is an error you want to propagate rather than a case you want to branch on.
did = resolver.handle.resolve('unknown.example.com') # None
did = resolver.handle.ensure_resolve('unknown.example.com') # raises DidNotFoundError
The two methods behind them are public, so you can pick one when you know which the account uses:
- resolve_dns
Queries the
TXTrecords of_atproto.<handle>and returns the value of the first one starting withdid=.- resolve_http
Fetches
https://<handle>/.well-known/atproto-didand returns the first line, if it starts withdid:.
Both swallow their own failures and return None, so a DNS timeout falls through to the HTTP attempt rather than raising.
Note
HandleResolver accepts a backup_nameservers argument, but it is not wired up yet: DNS resolution always uses the system resolver.
Warning
A handle resolved this way is not proof of ownership. The DID document is authoritative. An account is only really alice.example.com if the document its DID resolves to lists that handle in alsoKnownAs. Resolve the DID back and compare when it matters.
Resolving DIDs
resolve returns a DidDocument, or None if the DID does not exist. It validates what it fetched: the document must parse, and its id must equal the DID you asked for, or PoorlyFormattedDidDocumentError is raised. As with handles, ensure_resolve raises DidNotFoundError rather than returning None.
resolve_without_validation returns the raw JSON as a dict and skips every check above, including the cache. Reach for it when you want to see what a directory actually served, such as debugging a document the validator rejects.
force_refresh=True bypasses the cache for a single call and rewrites the entry with what it fetched:
did_doc = resolver.did.resolve(did, force_refresh=True)
PLC and did:web
The method segment of the DID picks the resolver:
did:plc:...Fetched from the PLC directory as
<plc_url>/<did>. A404means the DID does not exist and resolves toNone; any other HTTP failure raisesDidPlcResolverError.did:web:...Fetched from
https://<host>/.well-known/did.json, where the host is the rest of the DID. Failures raiseDidWebResolverError.
Anything else raises UnsupportedDidMethodError, and a string that is not a DID at all raises PoorlyFormattedDidError.
Attention
did:web paths are not supported. did:web:example.com resolves; did:web:example.com:user:alice raises UnsupportedDidWebPathError. Only host-level documents are accepted, which is what the AT Protocol allows.
Pulling atproto data out of a document
A DID document is a generic W3C structure. The three fields that matter for the AT Protocol (signing key, handle and PDS) are extracted by AtprotoData:
from atproto_identity.did.atproto_data import AtprotoData
data = AtprotoData.from_did_doc(did_doc)
print(data.did, data.handle, data.pds, data.signing_key)
Any of signing_key, handle and pds can be None when the document does not carry it. ensure_atproto_document does the same extraction and raises AtprotoDataParseError if any of the three is missing, so what it returns is fully populated. ensure_atproto_key checks only the signing key and returns it as a did:key string.
The resolver has both as one-step methods, which resolve the DID and extract in a single call:
data = resolver.did.resolve_atproto_data(did) # AtprotoData, fully populated
key = resolver.did.resolve_atproto_key(did) # 'did:key:zQ3s...'
resolve_atproto_key returns its argument unchanged if you hand it something that is already a did:key, so it is safe to call on an issuer that may be either.
This is the call a service makes to verify an inbound request: the signing key of the DID that issued a service-auth JWT is what you check the signature against. See Building a feed generator.
Caching
Every resolution is an HTTP request. A DID document changes rarely, so a service that resolves the same DIDs repeatedly, on every feed request and every verified token, should cache them.
Pass a cache to the resolver and it is used automatically:
from atproto import DidInMemoryCache, IdResolver
cache = DidInMemoryCache()
resolver = IdResolver(cache=cache)
did_doc = resolver.did.resolve('did:web:feed.atproto.blue') # network
did_doc = resolver.did.resolve('did:web:feed.atproto.blue') # cache
cache.clear()
did_doc = resolver.did.resolve('did:web:feed.atproto.blue') # network again
Use AsyncDidInMemoryCache with AsyncIdResolver; the two must match, since one awaits its cache and the other does not.
stale_ttl and max_ttl
A cache entry has two ages, both constructor arguments on every cache:
stale_ttlDefault 1 hour. Past it the entry is still served, and a refresh is kicked off first, so the caller gets a document without waiting for the network, and the next caller gets the fresh one.
max_ttlDefault 1 day. Past it the entry is not served at all; the resolver fetches, and stores what it fetched.
cache = DidInMemoryCache(stale_ttl=60 * 5, max_ttl=60 * 60)
A resolution that returns nothing deletes the entry, so an account that disappears does not stay cached until max_ttl.
Writing your own cache
DidInMemoryCache is a dict and dies with the process. For anything with more than one worker, subclass DidBaseCache, or AsyncDidBaseCache, and implement the five methods. Here is the shape of a Redis-backed one:
import json
import typing as t
from datetime import datetime, timezone
from atproto_core.did_doc import DidDocument
from atproto_identity.cache.base_cache import DidBaseCache, GetDocCallback
from atproto_identity.cache.models import CachedDidResult
class DidRedisCache(DidBaseCache):
def __init__(self, redis, **kwargs: t.Any) -> None:
super().__init__(**kwargs)
self._redis = redis
def get(self, did: str) -> t.Optional[CachedDidResult]:
raw = self._redis.hgetall(did)
if not raw:
return None
updated_at = datetime.fromisoformat(raw['updated_at'])
age = (datetime.now(timezone.utc) - updated_at).total_seconds()
return CachedDidResult(
did=did,
document=DidDocument.from_dict(json.loads(raw['document'])),
updated_at=updated_at,
stale=age > self.stale_ttl,
expired=age > self.max_ttl,
)
def set(self, did: str, document: DidDocument) -> None:
self._redis.hset(
did,
mapping={
'document': document.model_dump_json(by_alias=True),
'updated_at': datetime.now(timezone.utc).isoformat(),
},
)
self._redis.expire(did, self.max_ttl)
def refresh(self, did: str, get_doc_callback: GetDocCallback) -> None:
document = get_doc_callback()
if document:
self.set(did, document)
def delete(self, did: str) -> None:
self._redis.delete(did)
def clear(self) -> None:
raise NotImplementedError
Three things the resolver relies on:
getdecidesstaleandexpireditself, by comparingupdated_atagainststale_ttlandmax_ttl. The resolver only reads the flags. ReturningNonemeans “not cached”.refreshis called when an entry has gone stale but not expired. The callback it is handed performs the network resolution and may returnNone; store only a document. This is the hook a shared cache uses to make sure one worker refreshes rather than all of them. Take a lock around the callback.deleteis called for a DID that failed to resolve, whether or not it was cached, so it must tolerate a key that is not there.
Exceptions
Everything below is raised from atproto_identity.exceptions and inherits from AtProtocolError, so one except AtProtocolError catches the lot.
Exception |
Raised when |
|---|---|
|
|
|
the string is not a well-formed DID |
|
the DID method is neither |
|
a |
|
the document did not parse, or its |
|
the PLC directory request failed |
|
the |
|
the document is missing a signing key, handle or PDS |