Network Requests and Remote Data in QGIS Plugins
Networking from a QGIS plugin without freezing the interface: QgsNetworkAccessManager and QgsBlockingNetworkRequest, authentication configurations, retry and…
The moment a plugin talks to something over a network it inherits a set of problems that local code never had: proxies, credentials, certificates, timeouts, and a user interface that must stay responsive while none of those resolve. QGIS provides its own networking layer for exactly this reason, and a plugin that reaches past it to requests gets none of the configuration the user already supplied. This page, part of the Plugin Development & UI Integration guide, covers QgsNetworkAccessManager and its blocking companion, authentication configurations, retry and timeout policy, and adding a remote layer without freezing the window.
Prerequisites Checklist
- QGIS 3.28 LTR or newer.
QgsBlockingNetworkRequestis available from 3.6 and is the recommended synchronous path. - A service to call. Any HTTP endpoint returning JSON is enough for the examples; a WFS or OGC API endpoint exercises the layer path as well.
- Python 3.9+ for the type hints below.
- Familiarity with task execution. Long requests belong off the main thread; see asynchronous task execution with QgsTask.
- A place to store credentials. Never in settings — see plugin settings and configuration management for why, and the authentication section below for what to do instead.
Why Not Just Use requests?
Because the user has already told QGIS how to reach the network, and requests cannot hear it. A corporate proxy configured in the QGIS options, a client certificate stored in the authentication database, an exclusion list for internal hosts — all of that lives in the QGIS network stack. A plugin that opens its own connection works perfectly on the developer’s laptop and fails on every managed desktop in an organisation, with an error the user has no way to act on.
The rule that follows is short: use QgsBlockingNetworkRequest when the call is fast and you are already off the main thread, and QgsNetworkAccessManager when you are on it.
Step-by-Step Implementation
Step 1 — Build the request
A QNetworkRequest carries the URL and the headers. Set a user agent that identifies the plugin; service operators use it to tell a runaway script from a person, and a request with no agent is the first thing a rate limiter blocks.
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
USER_AGENT = b"MyQgisPlugin/1.2 (+https://example.org/myplugin)"
def build_request(url: str) -> QNetworkRequest:
"""Return a request carrying the plugin's identity and a JSON accept header."""
request = QNetworkRequest(QUrl(url))
request.setRawHeader(b"User-Agent", USER_AGENT)
request.setRawHeader(b"Accept", b"application/json")
request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True)
return request
Step 2 — Attach stored credentials
If the service needs authentication, the secret should live in the QGIS authentication database and your code should only ever hold the configuration id. updateNetworkRequest() applies whatever that configuration holds — basic credentials, a bearer token, a client certificate — without the plugin knowing which.
from qgis.core import QgsApplication
from qgis.PyQt.QtNetwork import QNetworkRequest
def authenticate(request: QNetworkRequest, auth_cfg_id: str) -> None:
"""Apply a stored authentication configuration to `request`."""
if not auth_cfg_id:
return
manager = QgsApplication.authManager()
if not manager.updateNetworkRequest(request, auth_cfg_id):
raise RuntimeError("could not apply auth config %r" % auth_cfg_id)
Step 3 — Make a synchronous request safely
Inside a QgsTask — that is, already off the main thread — a blocking call is the simplest thing that works. QgsBlockingNetworkRequest runs its own event loop internally, so it does not deadlock the way a naive blocking call on the main thread would.
import json
from qgis.core import QgsBlockingNetworkRequest
def fetch_json(url: str, auth_cfg_id: str = "", timeout_ms: int = 15000) -> dict:
"""Fetch and parse a JSON document. Call this from a worker thread, never the GUI thread."""
request = build_request(url)
authenticate(request, auth_cfg_id)
blocking = QgsBlockingNetworkRequest()
blocking.setAuthCfg(auth_cfg_id)
error = blocking.get(request, forceRefresh=True)
if error != QgsBlockingNetworkRequest.NoError:
raise ConnectionError("request failed: %s" % blocking.errorMessage())
reply = blocking.reply()
status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if status is not None and status >= 400:
raise ConnectionError("HTTP %s from %s" % (status, url))
return json.loads(bytes(reply.content()).decode("utf-8"))
Step 4 — Make an asynchronous request from the interface
On the main thread, issue the request and return immediately. The reply arrives as a signal, and the handler runs on the main thread, which means it may safely touch widgets and the project.
import json
from qgis.core import QgsNetworkAccessManager
from qgis.PyQt.QtNetwork import QNetworkReply
class ServiceClient:
"""Issues non-blocking requests and reports results through plain callbacks."""
def __init__(self):
self._replies: set[QNetworkReply] = set() # keeps replies alive until finished
def get_json(self, url: str, on_ok, on_error) -> None:
reply = QgsNetworkAccessManager.instance().get(build_request(url))
self._replies.add(reply)
def finished():
self._replies.discard(reply)
try:
if reply.error() != QNetworkReply.NoError:
on_error(reply.errorString())
return
on_ok(json.loads(bytes(reply.readAll()).decode("utf-8")))
finally:
reply.deleteLater()
reply.finished.connect(finished)
def abort_all(self) -> None:
"""Cancel every request in flight — call this from the plugin's unload()."""
for reply in list(self._replies):
reply.abort()
self._replies.clear()
Two details in that class are not decoration. The _replies set holds a Python reference to each reply, without which it can be collected before the signal arrives. And abort_all() exists because a reply that completes after the plugin has been unloaded calls into a dead object.
Advanced Patterns
Retry only what is worth retrying
Retrying a 404 wastes the user’s time and the server’s. Classify the failure first, and back off between attempts so a struggling service is not hammered by every QGIS instance in the building at once.
import time
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
def fetch_with_retry(url: str, attempts: int = 3, base_delay: float = 0.75) -> dict:
"""Fetch JSON, retrying transport and server errors with exponential backoff."""
last = None
for attempt in range(attempts):
try:
return fetch_json(url)
except ConnectionError as exc:
last = exc
status = next((s for s in RETRYABLE_STATUS if str(s) in str(exc)), None)
transient = status is not None or "timeout" in str(exc).lower()
if not transient or attempt == attempts - 1:
raise
time.sleep(base_delay * (2 ** attempt))
raise last # unreachable, but keeps the contract explicit
Respect Retry-After when the service sends it. A server that tells you when to come back has given you better information than any backoff formula.
Adding a remote layer without blocking
For OGC services, the provider does the networking for you — but constructing the layer still performs a synchronous capabilities request, which on a slow service freezes the window. Do the construction inside a task and register the finished layer on the main thread.
from qgis.core import QgsProject, QgsTask, QgsVectorLayer
class LoadWfsTask(QgsTask):
"""Builds a WFS layer off the main thread and registers it when finished."""
def __init__(self, uri: str, name: str):
super().__init__("Loading %s" % name, QgsTask.CanCancel)
self._uri = uri
self._name = name
self._layer: QgsVectorLayer | None = None
def run(self) -> bool:
layer = QgsVectorLayer(self._uri, self._name, "WFS")
if not layer.isValid():
return False
self._layer = layer
return not self.isCanceled()
def finished(self, ok: bool) -> None:
if ok and self._layer is not None:
QgsProject.instance().addMapLayer(self._layer) # main thread only
Caching what does not change
QgsNetworkAccessManager honours HTTP cache headers through Qt’s disk cache, so a service that sets Cache-Control correctly costs nothing on the second call. For services that do not, a small local cache keyed on the URL — with an explicit expiry your plugin controls — avoids turning every canvas refresh into a request.
Be conservative about what you cache. A capabilities document is stable for hours; a feature query the user is actively editing is stale the moment they save.
Being a good client of somebody else’s service
Most remote endpoints a plugin talks to are operated by somebody with a budget and a rate limit. A plugin installed by a few thousand users can turn a polite service into an outage without any individual user doing anything unreasonable, simply because every canvas refresh triggers a call.
Three habits avoid that. Identify yourself honestly in the user agent, including a contact URL, so an operator with a problem can reach you rather than blocking the whole user base. Respect what the service tells you — an HTTP 429 with a Retry-After header is an explicit instruction, and ignoring it is how a temporary limit becomes a permanent block. And make the number of requests proportional to what the user actually asked for: coalesce a burst of canvas refreshes into a single call, and never poll on a timer when a user action would do.
The same habits make the plugin better for the person using it. A client that batches, caches and backs off feels faster than one that fires a request per redraw, because most of those requests were answering a question nobody asked.
Pitfalls and Debugging
-
Blocking the main thread. A synchronous request on the GUI thread freezes QGIS. On a slow network it freezes it for long enough that the operating system offers to kill the application.
-
The reply garbage collected early. If nothing holds a Python reference to the reply, it can be collected before
finishedfires and the callback never runs. Hold it until the handler completes. -
Forgetting
deleteLater(). Replies areQObjects owned by the access manager. Leaking one per request is slow poison in a plugin that polls. -
Requests outliving the plugin. A reply that completes after
unload()calls a dead handler. Abort everything in flight during teardown. -
Bypassing the QGIS network stack. Code using
requestsignores the user’s proxy and authentication configuration, and will be reported as “your plugin does not work on our network”. -
No timeout. A request with no timeout can hang until the operating system gives up, which on some platforms is measured in minutes. Set one explicitly.
-
Secrets in settings or source. Credentials belong in
QgsAuthManager; anything else ends up in a backup, a screenshot or a support ticket.
Frequently Asked Questions
Can I use the requests library in a plugin?
You can, and for a self-contained script talking to a public endpoint it is fine. In a plugin distributed to other people it is a liability: it ignores the proxy the user configured, cannot see the certificates in their authentication database, and adds a dependency that QGIS does not ship on every platform.
Use QgsBlockingNetworkRequest from a worker thread instead. The API is barely longer, and the resulting plugin works on managed networks without any support from you.
How do I show progress for a download?
Connect to the reply’s downloadProgress(received, total) signal and forward the numbers to a progress bar or, from inside a task, to setProgress(). Note that total is -1 when the server sends no content length, which is common for streamed responses — handle that case by showing an indeterminate indicator rather than dividing by -1.
What timeout should I set?
Short enough that a user notices the failure rather than the wait, and long enough that a slow but working service still succeeds. For an interactive call triggered by a click, somewhere around ten to fifteen seconds is usually right; the user is watching, and a spinner that runs longer than that reads as broken regardless of what is happening underneath.
For a background job with no one watching, a longer timeout is defensible, but it should still exist. The failure mode of no timeout at all is a request that hangs until the operating system gives up, which on some platforms is several minutes — long enough that a scheduled job overruns its window and the next run finds the lock still held.
Where should the service URL live?
In QgsSettings, under the plugin’s own section, with a sensible default. Hard-coding it makes the plugin unusable behind an internal mirror, and putting it in the project file means every project carries a copy that goes stale independently.
If the plugin needs different endpoints for different projects — a per-project data service, for instance — that is the one case where the project file is right, and the user setting becomes the fallback.
How do I test networking code?
Do not test against the live service. Extract the parsing and error-classification logic into functions that take bytes and return values, and test those directly; they are where the bugs actually are. For the transport itself, a MagicMock standing in for the reply object covers the signal wiring, and a handful of recorded payloads covers the parsing.
The pytest-qgis guide covers the fixture side of this.
Why does my request work in the console but not in the plugin?
Most often because the console call ran on the main thread with an event loop already spinning, and the plugin call did not — a blocking request from a worker thread with no event loop returns immediately with an error rather than waiting.
QgsBlockingNetworkRequest exists to handle exactly this: it runs its own loop internally, so it behaves the same in both places.
Conclusion
Networking in a plugin is mostly about respecting two boundaries. The first is the QGIS network stack, which already knows about the user’s proxy, certificates and credentials — go through it and those problems are somebody else’s. The second is the main thread, which must never wait for a server: use the asynchronous manager on the interface, and the blocking request only from inside a task. Add a bounded retry policy for the errors worth retrying, abort what is in flight when the plugin unloads, and keep every secret in the authentication database.
Related
- Plugin Development & UI Integration — the parent guide covering the plugin architecture this fits into
- Asynchronous Task Execution with QgsTask — where a blocking request belongs
- Plugin Settings and Configuration Management — storing an endpoint, and storing an authentication configuration id
- Plugin Lifecycle and Resource Management — aborting requests during teardown
- Vector and Raster Data Access Patterns — reading remote data through a provider instead