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. QgsBlockingNetworkRequest is 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.

Three ways a plugin can make a request A grid of three request approaches — the Python requests library, QgsBlockingNetworkRequest and the asynchronous QgsNetworkAccessManager — showing which honour the QGIS proxy and authentication settings, and whether each blocks the interface. QGIS proxy + auth blocks the UI? requests / urllib no yes QgsBlockingNetworkRequest yes yes, but safely QgsNetworkAccessManager yes no

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.

python
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.

python
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.

python
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.

An asynchronous request, call by call A sequence diagram of a non-blocking request: the plugin builds a QNetworkRequest, the network access manager sends it and returns a reply object immediately, the finished signal arrives later on the main thread, and the plugin reads the payload and deletes the reply. plugin access manager reply get(request) creates reply returned at once finished signal readAll() deleteLater()
python
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.

What a blocked interface costs A bar chart of how long the QGIS window is frozen for four request patterns: a fast API call made synchronously, a slow one made synchronously, twenty synchronous calls in a loop, and the same twenty made asynchronously. 1 fast call, sync 0.3 s frozen 1 slow call, sync 8 s frozen 20 calls in a loop 24 s frozen 20 calls, async never frozen Time the QGIS window is unresponsive. Anything above roughly two seconds is long enough for the operating system to offer to kill the application.

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.

What to do with a failed request A decision tree over failure classes: retry transient network and server errors with backoff, do not retry client errors, and prompt for credentials only when the response is an authentication challenge. Why did the request fail? timeout / 5xx retry with backoff bounded attempts 4xx do not retry the request is wrong 401 / 403 authentication use a stored config
python
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.

python
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 finished fires and the callback never runs. Hold it until the handler completes.

  • Forgetting deleteLater(). Replies are QObjects 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 requests ignores 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.