Skip to content

Commit dd0bb1e

Browse files
authored
Retry a busy server in a loop, not recursively; time out citation lists (#134)
On a 503 the document was re-posted by calling _send_pdf (or process_txt) again from inside the handler, in the try block whose finally closes the stream: every attempt nested a frame holding an open file. A server that stayed busy ended in exhausted descriptors (reported as a 400 "failed to open file" marker) or a RecursionError (a 500 marker), and --skip_errors then skipped the document for good. Attempts now run in a loop, each one opening, posting and closing its own stream before the wait. process_txt also posted without a timeout, so a hung server blocked a citation-list run indefinitely; it now uses the configured timeout and reports a read timeout as 408 like PDFs do.
1 parent 65998cb commit dd0bb1e

2 files changed

Lines changed: 216 additions & 105 deletions

File tree

‎grobid_client/grobid_client.py‎

Lines changed: 84 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,10 @@ def _warn_on_consolidation_timeout(self, consolidate_citations: bool) -> None:
215215
f"(2-3 minutes is recommended)."
216216
)
217217

218-
def _handle_server_busy_retry(self, file_path: str, retry_func: Any, *args: Any, **kwargs: Any) -> Any:
219-
"""Handle server busy (503) retry logic."""
218+
def _wait_for_busy_server(self, file_path: str) -> None:
219+
"""Pause before re-posting a document the server answered 503 to."""
220220
self.logger.warning(f"Server busy (503), retrying {file_path} after {self.config['sleep_time']} seconds")
221221
time.sleep(self.config["sleep_time"])
222-
return retry_func(*args, **kwargs)
223222

224223
def _handle_request_error(
225224
self, file_path: str, error: Exception, error_type: str = "Request"
@@ -1824,89 +1823,77 @@ def _send_pdf(
18241823
start: int = -1,
18251824
end: int = -1
18261825
) -> Tuple[str, int, Optional[str]]:
1827-
"""Post one document, retrying it from a fresh stream on a 503."""
1828-
pdf_handle: Optional[BinaryIO] = None
1829-
try:
1830-
pdf_handle = open_handle()
1826+
"""Post one document, re-posting it from a fresh stream while the server answers 503.
18311827
1832-
files = {
1833-
"input": (
1834-
name,
1835-
pdf_handle,
1836-
"application/pdf",
1837-
{"Expires": "0"},
1838-
)
1839-
}
1840-
1841-
the_url = self.get_server_url(service)
1842-
1843-
# set the GROBID parameters
1844-
the_data = {}
1845-
if generate_ids:
1846-
the_data["generateIDs"] = "1"
1847-
if consolidate_header:
1848-
the_data["consolidateHeader"] = "1"
1849-
if consolidate_citations:
1850-
the_data["consolidateCitations"] = "1"
1851-
if include_raw_citations:
1852-
the_data["includeRawCitations"] = "1"
1853-
if include_raw_affiliations:
1854-
the_data["includeRawAffiliations"] = "1"
1855-
if tei_coordinates:
1856-
the_data["teiCoordinates"] = self.config["coordinates"]
1857-
if segment_sentences:
1858-
the_data["segmentSentences"] = "1"
1859-
if flavor:
1860-
the_data["flavor"] = flavor
1861-
if start and start > 0:
1862-
the_data["start"] = str(start)
1863-
if end and end > 0:
1864-
the_data["end"] = str(end)
1865-
1866-
res, status = self.post(
1867-
url=the_url, files=files, data=the_data, headers={"Accept": "text/plain"},
1868-
timeout=self.config['timeout']
1869-
)
1828+
A loop rather than a recursive call: every attempt opens, posts and
1829+
closes its own stream before the wait, so a server that stays busy
1830+
costs nothing but time. (Recursing kept one open file per attempt
1831+
and ended in exhausted descriptors or a RecursionError, recorded as
1832+
a bogus error marker.)
1833+
"""
1834+
the_url = self.get_server_url(service)
18701835

1871-
if status == 503:
1872-
return self._handle_server_busy_retry(
1873-
name,
1874-
self._send_pdf,
1875-
service,
1876-
name,
1877-
open_handle,
1878-
generate_ids,
1879-
consolidate_header,
1880-
consolidate_citations,
1881-
include_raw_citations,
1882-
include_raw_affiliations,
1883-
tei_coordinates,
1884-
segment_sentences,
1885-
flavor,
1886-
start,
1887-
end
1836+
# set the GROBID parameters
1837+
the_data = {}
1838+
if generate_ids:
1839+
the_data["generateIDs"] = "1"
1840+
if consolidate_header:
1841+
the_data["consolidateHeader"] = "1"
1842+
if consolidate_citations:
1843+
the_data["consolidateCitations"] = "1"
1844+
if include_raw_citations:
1845+
the_data["includeRawCitations"] = "1"
1846+
if include_raw_affiliations:
1847+
the_data["includeRawAffiliations"] = "1"
1848+
if tei_coordinates:
1849+
the_data["teiCoordinates"] = self.config["coordinates"]
1850+
if segment_sentences:
1851+
the_data["segmentSentences"] = "1"
1852+
if flavor:
1853+
the_data["flavor"] = flavor
1854+
if start and start > 0:
1855+
the_data["start"] = str(start)
1856+
if end and end > 0:
1857+
the_data["end"] = str(end)
1858+
1859+
while True:
1860+
pdf_handle: Optional[BinaryIO] = None
1861+
try:
1862+
pdf_handle = open_handle()
1863+
files = {
1864+
"input": (
1865+
name,
1866+
pdf_handle,
1867+
"application/pdf",
1868+
{"Expires": "0"},
1869+
)
1870+
}
1871+
res, status = self.post(
1872+
url=the_url, files=files, data=the_data, headers={"Accept": "text/plain"},
1873+
timeout=self.config['timeout']
18881874
)
1875+
# requests' exceptions derive from IOError, so they have to be
1876+
# caught before it or every timeout/connection error would be
1877+
# reported as a failure to open the file (status 400).
1878+
except requests.exceptions.ReadTimeout as e:
1879+
self.logger.error(f"Request timeout for {name}: {str(e)}")
1880+
return (name, 408, f"Request timeout: {str(e)}")
1881+
except requests.exceptions.RequestException as e:
1882+
return self._handle_request_error(name, e)
1883+
except IOError as e:
1884+
self.logger.error(f"Failed to open PDF file {name}: {str(e)}")
1885+
return (name, 400, f"Failed to open file: {str(e)}")
1886+
except Exception as e:
1887+
return self._handle_unexpected_error(name, e)
1888+
finally:
1889+
# Always ours to close: the caller's own stream is never kept,
1890+
# only the bytes read out of it.
1891+
if pdf_handle:
1892+
pdf_handle.close()
18891893

1890-
return (name, status, res.text)
1891-
1892-
# requests' exceptions derive from IOError, so they have to be caught
1893-
# before it or every timeout/connection error would be reported as a
1894-
# failure to open the file (status 400).
1895-
except requests.exceptions.ReadTimeout as e:
1896-
self.logger.error(f"Request timeout for {name}: {str(e)}")
1897-
return (name, 408, f"Request timeout: {str(e)}")
1898-
except requests.exceptions.RequestException as e:
1899-
return self._handle_request_error(name, e)
1900-
except IOError as e:
1901-
self.logger.error(f"Failed to open PDF file {name}: {str(e)}")
1902-
return (name, 400, f"Failed to open file: {str(e)}")
1903-
except Exception as e:
1904-
return self._handle_unexpected_error(name, e)
1905-
finally:
1906-
# Always ours to close: the caller's own stream is never kept, only
1907-
# the bytes read out of it.
1908-
if pdf_handle:
1909-
pdf_handle.close()
1894+
if status != 503:
1895+
return (name, status, res.text)
1896+
self._wait_for_busy_server(name)
19101897

19111898
def process_documents(
19121899
self,
@@ -2039,31 +2026,23 @@ def process_txt(
20392026
the_data["includeRawCitations"] = "1"
20402027
the_data["citations"] = references
20412028

2042-
try:
2043-
res, status = self.post(
2044-
url=the_url, data=the_data, headers={"Accept": "application/xml"}
2045-
)
2046-
2047-
if status == 503:
2048-
return self._handle_server_busy_retry(
2049-
txt_file,
2050-
self.process_txt,
2051-
service,
2052-
txt_file,
2053-
generate_ids,
2054-
consolidate_header,
2055-
consolidate_citations,
2056-
include_raw_citations,
2057-
include_raw_affiliations,
2058-
tei_coordinates,
2059-
segment_sentences
2029+
while True:
2030+
try:
2031+
res, status = self.post(
2032+
url=the_url, data=the_data, headers={"Accept": "application/xml"},
2033+
timeout=self.config['timeout']
20602034
)
2061-
except requests.exceptions.RequestException as e:
2062-
return self._handle_request_error(txt_file, e)
2063-
except Exception as e:
2064-
return self._handle_unexpected_error(txt_file, e)
2035+
except requests.exceptions.ReadTimeout as e:
2036+
self.logger.error(f"Request timeout for {txt_file}: {str(e)}")
2037+
return (txt_file, 408, f"Request timeout: {str(e)}")
2038+
except requests.exceptions.RequestException as e:
2039+
return self._handle_request_error(txt_file, e)
2040+
except Exception as e:
2041+
return self._handle_unexpected_error(txt_file, e)
20652042

2066-
return (txt_file, status, res.text)
2043+
if status != 503:
2044+
return (txt_file, status, res.text)
2045+
self._wait_for_busy_server(txt_file)
20672046

20682047

20692048
def main() -> None:

‎tests/test_busy_retry.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""
2+
A 503 is retried in a loop: each attempt closes its stream before the wait,
3+
and a server that stays busy for a long time cannot exhaust descriptors or
4+
the recursion limit.
5+
"""
6+
import io
7+
import os
8+
import tempfile
9+
from unittest.mock import Mock, patch
10+
11+
import requests
12+
13+
from grobid_client.grobid_client import GrobidClient
14+
15+
16+
def _client():
17+
with patch('grobid_client.grobid_client.GrobidClient._test_server_connection'):
18+
with patch('grobid_client.grobid_client.GrobidClient._configure_logging'):
19+
client = GrobidClient(check_server=False)
20+
client.logger = Mock()
21+
client.config['sleep_time'] = 0
22+
return client
23+
24+
25+
def _busy_then_ok(times):
26+
"""A post() that answers 503 `times` times, then 200."""
27+
calls = []
28+
29+
def post(url, files=None, data=None, headers=None, timeout=None):
30+
calls.append(files['input'][1] if files else None)
31+
resp = Mock()
32+
if len(calls) <= times:
33+
resp.text = 'busy'
34+
return (resp, 503)
35+
resp.text = '<TEI>ok</TEI>'
36+
return (resp, 200)
37+
38+
return post, calls
39+
40+
41+
class TestPdfRetry:
42+
43+
def test_each_attempt_uses_a_fresh_closed_stream(self):
44+
client = _client()
45+
post, calls = _busy_then_ok(3)
46+
with patch.object(GrobidClient, 'post', side_effect=post):
47+
with patch('grobid_client.grobid_client.time.sleep') as sleep:
48+
name, status, text = client.process_pdf('processFulltextDocument', b'%PDF-bytes')
49+
assert (status, text) == (200, '<TEI>ok</TEI>')
50+
assert sleep.call_count == 3
51+
assert len(calls) == 4
52+
assert len({id(h) for h in calls}) == 4 # a new stream per attempt
53+
assert all(h.closed for h in calls) # none left open
54+
55+
def test_a_file_on_disk_is_reopened_each_time(self):
56+
client = _client()
57+
post, calls = _busy_then_ok(2)
58+
with tempfile.TemporaryDirectory() as d:
59+
pdf = os.path.join(d, 'p.pdf')
60+
with open(pdf, 'wb') as fh:
61+
fh.write(b'%PDF-disk')
62+
with patch.object(GrobidClient, 'post', side_effect=post):
63+
with patch('grobid_client.grobid_client.time.sleep'):
64+
_, status, _ = client.process_pdf('processFulltextDocument', pdf)
65+
assert status == 200
66+
assert all(h.closed for h in calls)
67+
68+
def test_a_long_busy_spell_does_not_recurse(self):
69+
client = _client()
70+
post, calls = _busy_then_ok(5000)
71+
with patch.object(GrobidClient, 'post', side_effect=post):
72+
with patch('grobid_client.grobid_client.time.sleep'):
73+
_, status, _ = client.process_pdf('processFulltextDocument', b'%PDF')
74+
assert status == 200
75+
assert len(calls) == 5001
76+
77+
def test_timeout_during_a_retry_is_reported_as_408(self):
78+
client = _client()
79+
answers = iter([(Mock(text='busy'), 503), requests.exceptions.ReadTimeout('slow')])
80+
81+
def post(url, files=None, data=None, headers=None, timeout=None):
82+
answer = next(answers)
83+
if isinstance(answer, Exception):
84+
raise answer
85+
return answer
86+
87+
with patch.object(GrobidClient, 'post', side_effect=post):
88+
with patch('grobid_client.grobid_client.time.sleep'):
89+
_, status, text = client.process_pdf('processFulltextDocument', b'%PDF')
90+
assert status == 408 and 'timeout' in text.lower()
91+
92+
93+
class TestTxtRetry:
94+
95+
def _refs(self, d):
96+
path = os.path.join(d, 'refs.txt')
97+
with open(path, 'w', encoding='utf8') as fh:
98+
fh.write('one\ntwo\n')
99+
return path
100+
101+
def test_503_then_ok(self):
102+
client = _client()
103+
post, calls = _busy_then_ok(2)
104+
with tempfile.TemporaryDirectory() as d:
105+
with patch.object(GrobidClient, 'post', side_effect=post):
106+
with patch('grobid_client.grobid_client.time.sleep') as sleep:
107+
name, status, text = client.process_txt(
108+
'processCitationList', self._refs(d), False, False, False, False, False, False, False)
109+
assert (status, text) == (200, '<TEI>ok</TEI>')
110+
assert sleep.call_count == 2 and len(calls) == 3
111+
112+
def test_post_carries_the_configured_timeout(self):
113+
client = _client()
114+
client.config['timeout'] = 42
115+
seen = {}
116+
117+
def post(url, files=None, data=None, headers=None, timeout=None):
118+
seen['timeout'] = timeout
119+
return (Mock(text='<TEI/>'), 200)
120+
121+
with tempfile.TemporaryDirectory() as d:
122+
with patch.object(GrobidClient, 'post', side_effect=post):
123+
client.process_txt('processCitationList', self._refs(d), False, False, False, False, False, False, False)
124+
assert seen['timeout'] == 42
125+
126+
def test_timeout_is_reported_as_408(self):
127+
client = _client()
128+
with tempfile.TemporaryDirectory() as d:
129+
with patch.object(GrobidClient, 'post', side_effect=requests.exceptions.ReadTimeout('slow')):
130+
_, status, text = client.process_txt(
131+
'processCitationList', self._refs(d), False, False, False, False, False, False, False)
132+
assert status == 408 and 'timeout' in text.lower()

0 commit comments

Comments
 (0)