Fetching JSON from an API Without Freezing QGIS
Call an HTTP API from a QGIS plugin without blocking the window: QgsNetworkAccessManager, holding the reply, distinguishing transport errors from HTTP status…
TL;DR: issue the request through QgsNetworkAccessManager.instance().get(), keep a reference to the reply, and read the payload in the finished signal handler — the call returns immediately, so the interface never blocks, and the handler runs on the main thread where it is safe to touch widgets. This page is part of the network requests and remote data in plugins guide.
Complete Runnable Code
"""A small non-blocking JSON client for a QGIS plugin."""
import json
from qgis.core import QgsNetworkAccessManager
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest
USER_AGENT = b"MyQgisPlugin/1.0 (+https://example.org/myplugin)"
TIMEOUT_MS = 15000
class JsonClient:
"""Issues JSON requests and reports results through callbacks.
Holds each reply until its signal has fired, so nothing is collected
mid-flight, and aborts everything outstanding when the plugin unloads.
"""
def __init__(self):
self._inflight: set[QNetworkReply] = set()
def get(self, url: str, on_ok, on_error) -> None:
request = QNetworkRequest(QUrl(url))
request.setRawHeader(b"User-Agent", USER_AGENT)
request.setRawHeader(b"Accept", b"application/json")
request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True)
request.setTransferTimeout(TIMEOUT_MS) # never hang indefinitely
reply = QgsNetworkAccessManager.instance().get(request)
self._inflight.add(reply)
def finished():
self._inflight.discard(reply)
try:
if reply.error() != QNetworkReply.NoError:
on_error(reply.errorString())
return
status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if status is not None and status >= 400:
on_error("HTTP %s from %s" % (status, url))
return
try:
on_ok(json.loads(bytes(reply.readAll()).decode("utf-8")))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
on_error("unreadable response: %s" % exc)
finally:
reply.deleteLater()
reply.finished.connect(finished)
def abort_all(self) -> None:
"""Cancel every outstanding request — call from the plugin's unload()."""
for reply in list(self._inflight):
reply.abort()
self._inflight.clear()
Architecture Breakdown
get() returns immediately
The call hands the request to Qt’s transfer machinery and returns a QNetworkReply straight away. Nothing has been transferred yet; the object is a handle to a transfer that will complete later. That is the entire reason the interface stays responsive — control returns to the event loop, which keeps painting and handling clicks while the bytes arrive.
The consequence people trip over is that reading the reply immediately after get() returns nothing. There is no data yet, and there will not be until finished fires.
Keeping the reply alive
Qt owns the reply on the C++ side, but the Python wrapper is refcounted like any other object. If the only reference was a local variable in the method that issued the request, it can be collected before the transfer completes, and the callback never runs — intermittently, and more often on slower connections, which makes it a memorable bug to diagnose.
The _inflight set in the example is that reference. Discarding it inside the handler keeps the set from growing, and deleteLater() releases the C++ object once Qt is finished with it.
Classifying the failure
There are four distinct ways this call fails and they surface in different places.
reply.error() covers transport problems — timeouts, DNS, refused connections. An HTTP error status is not a transport error: the request succeeded and the server said no, so error() may well be NoError while the status attribute is 404. Checking only one of the two is how a plugin ends up trying to parse an error page as JSON.
Choosing the Right API for the Call
QGIS offers two request APIs and a third option that avoids the question entirely, and picking between them is decided almost completely by which thread the call starts on.
The asynchronous manager is right whenever the call originates on the GUI thread, which in a plugin means anything triggered by a button, a menu item or a canvas signal. Inside a QgsTask the calculus changes: the worker thread is allowed to block, and QgsBlockingNetworkRequest gives you a straight-line function that returns a payload, which is far easier to write correctly than a callback chain.
The third branch is worth checking before writing any of it. If the remote data is a WFS, WMS or OGC API endpoint, the QGIS provider already speaks that protocol, handles paging and caching, and gives you a layer rather than a dictionary.
Wiring It Into a Plugin
class MyPlugin:
def __init__(self, iface):
self.iface = iface
self.client = JsonClient()
def fetch_status(self) -> None:
"""Triggered from a toolbar action; returns before the response arrives."""
self.iface.messageBar().pushInfo("My Plugin", "Fetching…")
self.client.get(
"https://example.org/api/status",
on_ok=lambda data: self.iface.messageBar().pushSuccess(
"My Plugin", "Service reports %s" % data.get("state", "unknown")),
on_error=lambda message: self.iface.messageBar().pushWarning(
"My Plugin", "Could not reach the service: %s" % message))
def unload(self) -> None:
self.client.abort_all()
Both callbacks touch the message bar, which is only safe because finished is delivered on the main thread. That guarantee is what makes the callback style workable in a GUI plugin at all.
Production Best Practices
- Set a transfer timeout. Without one a stalled connection can hang for minutes.
- Hold a reference to every reply until its signal has fired.
- Check
error()and the HTTP status. They are different failures. - Call
deleteLater(), notdel; the object belongs to Qt. - Abort outstanding requests in
unload(), or a late callback will call into a dead plugin. - Never parse a response you have not status-checked. An error page is valid HTML and invalid JSON, and the parse error hides the real problem.
Frequently Asked Questions
Why does my callback never fire?
The two usual causes are a collected reply and a connection made after the signal had already been emitted. The first is fixed by holding the reference; the second happens when a cached response completes synchronously, before connect() runs — connect first, then keep the reference, as the example does.
If neither applies, check that the event loop is actually running. In a script executed from the Python console the loop is live; in a standalone script it may not be, and callbacks are delivered only when it spins.
How do I do a POST instead?
QgsNetworkAccessManager.instance().post(request, payload) takes the body as a QByteArray. Set the Content-Type header explicitly — application/json for a JSON body — because Qt will not infer it, and a service receiving JSON labelled as form data usually rejects it with a status your code then has to interpret.
Can I make several requests at once?
Yes, and the class above already supports it: each get() produces an independent reply, and the set holds all of them. What you should not do is fire an unbounded number — a loop issuing one request per feature will exhaust connections and irritate the service. Batch where the API allows it, and otherwise limit concurrency to a handful.
How do I show progress?
Connect to the reply’s downloadProgress(received, total) signal. Note that total is -1 when the server sends no content length, which is common for streamed or compressed responses — show an indeterminate indicator in that case rather than computing a percentage from a negative number.
Should I cache responses?
For anything that changes slowly, yes. QgsNetworkAccessManager honours HTTP cache headers through Qt’s disk cache, so a well-behaved service costs nothing on a repeat call. When the service sets no cache headers, a small dictionary keyed on the URL with an expiry you control avoids turning every canvas refresh into a request.
What about services that need authentication?
Attach a stored authentication configuration to the request before issuing it, as described in storing credentials with QgsAuthManager. The configuration id is the only thing your plugin needs to hold, and the manager applies whatever the credential actually is.
Related
- Network Requests and Remote Data in Plugins — the parent guide covering the QGIS network stack
- Loading a WFS Layer in a Background Task — when the remote data should become a layer instead
- Asynchronous Task Execution with QgsTask — where a blocking request is the simpler choice