Patterns from building an offline password manager in Python/PyQt6. The problem sounds simple: show a logo next to each credential. The implementation is less simple.
Logo sources fail. They time out, return 404, or come back with HTML instead of an image. If your UI waits on any of that, the app feels broken.
All fetching runs in a QThread. The main thread never touches the network. QML renders initials immediately and updates when a logo arrives — not the other way around.
class LogoFetchWorker(QThread):
logo_ready = pyqtSignal(str, str) # domain, cache_path
fetch_failed = pyqtSignal(str) # domainWatch out for: if you emit signals from a worker thread directly into QML without going through a controller slot, you'll get cross-thread warnings and occasional crashes. Route everything through the controller.
No single logo API is reliable for every domain. Clearbit is great until it isn't. Google favicons exist for almost everything but are sometimes tiny. DuckDuckGo fills gaps.
Try them in order, but don't trust a 200 response. Validate the content before caching it.
sources = [
f"https://logo.clearbit.com/{self.domain}?size=128",
f"https://www.google.com/s2/favicons?sz=128&domain={self.domain}",
f"https://icons.duckduckgo.com/ip3/{self.domain}.ico"
]
# Don't trust Content-Type alone — check magic bytes
magic = content[:12].lower()
is_image = (
magic.startswith(b'\x89png') or
magic.startswith(b'\xff\xd8\xff') or # JPEG
magic.startswith(b'\x00\x00\x01\x00') or # ICO
b'webp' in magic or
b'<svg' in magic
)
if len(content) < 500 or not is_image:
continueWatch out for: servers that return a valid-looking image content type with an HTML error page body. The < 500 bytes check catches most of these before the magic byte check even runs.
Writing directly to the cache file means a crash or interruption mid-write produces a corrupt file that looks valid on the next read. Every subsequent launch tries to load it, fails silently, and refetches — or worse, renders garbage.
Write to a temp file first, then rename.
temp_file = cache_file + ".tmp"
with open(temp_file, 'wb') as f:
f.write(content)
if os.path.exists(cache_file):
os.remove(cache_file)
os.rename(temp_file, cache_file)Watch out for: os.rename is atomic on Unix but not always on Windows across drives. If your cache and temp directories are on different volumes, use shutil.move instead.
Without these, a real session gets noisy fast.
Memory cache — disk reads on every lookup add up. Cache resolved paths in memory for the session.
Pending fetch deduplication — if two credential tiles for github.com both miss cache at the same time, you don't want two concurrent fetches for the same domain. Track in-flight requests and skip if one is already running.
Failed domain tracking — if Clearbit, Google, and DuckDuckGo all fail for a domain, mark it as failed and don't retry for the rest of the session. Initials are fine. Hammering three APIs repeatedly is not.
if domain in self.failed_domains:
return None
if domain in self.pending_fetches:
return None # already in flight
self.pending_fetches.add(domain)Watch out for: failed_domains should be session-only, not persisted. A domain that fails today might have a logo tomorrow.