Repository navigation
bigquery: QueryJob.result() waits forever if a jobs.getQueryResults response is lost (no default request timeout) #18857
Description
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 = NoneClient._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_TIMEOUTWith 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:
- Is the
Nonefor the default case intentional? - If not, is
DEFAULT_GET_JOB_TIMEOUTthe right value, or shouldgetQueryResultshave its own constant?
Activity
huong8373tt-beep commentedon Oct 28, 2026
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.