Skip to content

bigquery: QueryJob.result() waits forever if a jobs.getQueryResults response is lost (no default request timeout) #18857

Open

Description

@0BVer

Context

Client.get_job() got a default request timeout in googleapis/python-bigquery#1935 (for googleapis/python-bigquery#1922), so a lost jobs.get response is retried after 128s. The jobs.getQueryResults call that QueryJob.result() makes while a job is running has no such default. If one of those requests never gets a response, result() blocks until the process is killed. In a production task it kept waiting for more than 4 hours, until we stopped the task manually, although the query job had finished after 1m23s.

When result() is called without timeout, QueryJob._reload_query_results replaces the default sentinel with None, and None reaches requests as "no timeout":

# packages/google-cloud-bigquery/google/cloud/bigquery/job/query.py (3.46.2), _reload_query_results
            # Handle PollingJob._DEFAULT_VALUE.
            if not isinstance(transport_timeout, (float, int)):
                transport_timeout = None

Client._get_query_results already maps a non-numeric timeout to _MIN_GET_QUERY_RESULTS_TIMEOUT (120s) and raises smaller numbers to it, so a numeric default would fit the existing code. The None seems to come from the fix for googleapis/python-bigquery#1459 (typeguard error on the object() sentinel).

This is a client library issue, not an API service issue: the request gets no response, and the client has no read timeout to give up on it.

Expected behavior

A lost jobs.getQueryResults response times out and is retried, as for jobs.get.

Actual behavior

result() waits until the process is killed.

API client name and version

google-cloud-bigquery 3.46.2 (local repro), 3.41.0 (production hang). packages/google-cloud-bigquery on main still has the code above.

OS & version + platform

macOS (arm64) for the local repro, Linux container for the production hang.

Python environment

3.13.1 (local repro), 3.13.13 (production).

Python dependencies

Local repro: google-cloud-bigquery 3.46.2, google-auth 2.62.0, google-api-core 2.43.0, requests 2.34.2, urllib3 2.8.0.

Production: google-cloud-bigquery 3.41.0, google-auth 2.53.0, google-api-core 2.30.3, requests 2.34.2, urllib3 2.7.0.

Reproduction steps: code

The script starts a local server that mimics the REST API and never answers the first request to one endpoint. No GCP access is needed.

repro.py
"""Show that QueryJob.result() waits forever when one polling response is lost.

A local HTTP server mimics the BigQuery REST API. It never answers the first request
to the chosen endpoint, which is what a lost response looks like to the client.
No GCP access is needed.

    python repro.py <default|none|SECONDS> <jobs|queries>

    default | none | SECONDS   job.result() | job.result(timeout=None) | job.result(timeout=SECONDS)
    jobs | queries             silent endpoint: jobs.get | jobs.getQueryResults
    ALARM=<seconds>            give up waiting after this long (default 20)
"""
import json
import os
import signal
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import google.cloud.bigquery
from google.auth.credentials import AnonymousCredentials
from google.cloud import bigquery

timeout_arg, silent = sys.argv[1], sys.argv[2]
alarm = int(os.environ.get("ALARM", "20"))

REF = {"projectId": "p", "jobId": "job1", "location": "US"}
JOB = {
    "kind": "bigquery#job",
    "id": "p:US.job1",
    "jobReference": REF,
    "configuration": {"query": {"query": "SELECT 1", "useLegacySql": False}},
    "statistics": {},
}
requests_seen = {"jobs": 0, "queries": 0}
start = time.time()


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *_):
        pass

    def _send(self, body):
        data = json.dumps(body).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_POST(self):  # jobs.insert
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        self._send({**JOB, "status": {"state": "RUNNING"}})

    def do_GET(self):
        kind = "queries" if "/queries/" in self.path else "jobs"
        requests_seen[kind] += 1
        path = self.path.split("?")[0].split("/v2")[-1]
        if kind == silent and requests_seen[kind] == 1:
            print(f"  t={time.time() - start:5.1f}s GET {path} -> no response", flush=True)
            time.sleep(3600)
            return
        print(f"  t={time.time() - start:5.1f}s GET {path} -> ok", flush=True)
        if kind == "queries":
            self._send({"kind": "bigquery#getQueryResultsResponse", "jobReference": REF,
                        "jobComplete": True, "totalRows": "0", "schema": {"fields": []}})
        else:
            self._send({**JOB, "status": {"state": "DONE"}})


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()

client = bigquery.Client(
    project="p", location="US", credentials=AnonymousCredentials(),
    client_options={"api_endpoint": f"http://127.0.0.1:{server.server_address[1]}"},
)
print(f"google-cloud-bigquery {google.cloud.bigquery.__version__}, "
      f"result({timeout_arg}), no response from {silent}", flush=True)


def give_up(*_):
    print(f"RESULT: still blocked after {alarm}s", flush=True)
    os._exit(0)


signal.signal(signal.SIGALRM, give_up)
signal.alarm(alarm)

try:
    job = client.query("SELECT 1")
    kwargs = {} if timeout_arg == "default" else {"timeout": None if timeout_arg == "none" else float(timeout_arg)}
    job.result(**kwargs)
    print(f"RESULT: completed after {time.time() - start:.1f}s", flush=True)
except Exception as exc:
    print(f"RESULT: {type(exc).__name__} after {time.time() - start:.1f}s: {str(exc)[:100]}", flush=True)
os._exit(0)

Reproduction steps: actual results

Run with 3.46.2, silencing jobs.get and jobs.getQueryResults in turn (the script stops waiting after 150s):

call silent endpoint result
job.result() jobs.get completes after 128.7s (default from googleapis/python-bigquery#1935)
job.result() jobs.getQueryResults still blocked after 150s
job.result(timeout=None) jobs.get still blocked after 150s
job.result(timeout=None) jobs.getQueryResults still blocked after 150s

An explicit timeout=None waiting forever is expected. This report is about the default case.

Reproduction steps: expected results

job.result() completes after one request timeout and a retry when jobs.getQueryResults is the silent endpoint, like the jobs.get row.

Stack trace

Abbreviated, from a blocked production task (3.41.0) at the moment it was stopped manually:

QueryJob.result -> is_job_done
QueryJob._reload_query_results -> Client._get_query_results -> Client._call_api
... google.cloud._http.JSONConnection.api_request
... google.auth.transport.requests.AuthorizedSession.request
... urllib3 ... http.client._read_status -> ssl.SSLSocket.recv_into

Possible fix

Use a numeric default instead of None, the same constant jobs.get uses:

-            if not isinstance(transport_timeout, (float, int)):
-                transport_timeout = None
+            if not isinstance(transport_timeout, (float, int)):
+                transport_timeout = DEFAULT_GET_JOB_TIMEOUT

With this change (plus the import), the silent jobs.getQueryResults case completes after 128s: the request times out, DEFAULT_RETRY retries it, and result() returns. 128s is above both the 10s server-side wait of getQueryResults and the 120s floor, so it should not trip on normal polling. It limits a single HTTP request, not the total duration of the job, as discussed in googleapis/python-bigquery#1922. I have not run the unit tests or tried this against the real service.

I can open a PR with a unit test if this direction works for you. Two questions:

  1. Is the None for the default case intentional?
  2. If not, is DEFAULT_GET_JOB_TIMEOUT the right value, or should getQueryResults have its own constant?

Activity

  1. huong8373tt-beep commented on Oct 28, 2026

    @huong8373tt-beep

    I would like to work on this issue. I plan to add a default request timeout for the jobs.getQueryResults polling path and a local regression test covering a lost response, while preserving the explicit timeout=None behavior.

    Slai Agent Runtime assisted with the investigation, implementation, and testing of this contribution. The account owner authorized the submitted changes and is responsible for the submission.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions