From 88454238b235046f58ad762e75575f02838c8542 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Jul 2026 22:51:33 +0530 Subject: [PATCH 1/3] Add RateLimitError for HTTP 429 responses from Google recognizer obtain_transcription() previously wrapped every HTTPError -- including rate limiting (429) -- into a generic RequestError using only e.reason, discarding the actual status code. This made it impossible to catch rate limiting separately from other request failures, or to implement backoff using the Retry-After header. Adds a RateLimitError(RequestError) subclass with a retry_after attribute parsed from the Retry-After header when present. 429 responses now raise RateLimitError specifically; all other HTTPErrors continue to raise RequestError as before, so this is backward compatible for anyone already catching RequestError. Includes a test covering the 429 case and confirming retry_after is parsed correctly. Ran full test suite, flake8, and mypy locally -- all clean except pre-existing, unrelated failures from optional recognizer backends not installed in this environment (cohere, vosk, groq, openai, faster_whisper, soundfile, pocketsphinx, google.cloud.speech). --- speech_recognition/__init__.py | 3 ++- speech_recognition/exceptions.py | 15 +++++++++++++++ speech_recognition/recognizers/google.py | 13 ++++++++++++- tests/recognizers/test_google.py | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/speech_recognition/__init__.py b/speech_recognition/__init__.py index dca4d345..9667bc4b 100644 --- a/speech_recognition/__init__.py +++ b/speech_recognition/__init__.py @@ -28,7 +28,8 @@ from urllib.request import Request, urlopen from .audio import AudioData, get_flac_converter -from .exceptions import ( +from .exceptions import ( # noqa: F401 + RateLimitError, RequestError, TranscriptionFailed, TranscriptionNotReady, diff --git a/speech_recognition/exceptions.py b/speech_recognition/exceptions.py index 3e208a12..0b2790c1 100644 --- a/speech_recognition/exceptions.py +++ b/speech_recognition/exceptions.py @@ -20,3 +20,18 @@ class TranscriptionNotReady(Exception): class TranscriptionFailed(Exception): pass + + +class RateLimitError(RequestError): + """Raised when the speech recognition service returns an HTTP 429 + (Too Many Requests) response, indicating the caller has been rate + limited. + + :attr retry_after: The number of seconds to wait before retrying, + parsed from the response's ``Retry-After`` header if present, + otherwise ``None``. + """ + + def __init__(self, message: str, retry_after: float | None = None): + super().__init__(message) + self.retry_after = retry_after diff --git a/speech_recognition/recognizers/google.py b/speech_recognition/recognizers/google.py index 21216f5c..62052ec8 100644 --- a/speech_recognition/recognizers/google.py +++ b/speech_recognition/recognizers/google.py @@ -9,7 +9,7 @@ from typing_extensions import NotRequired from speech_recognition.audio import AudioData -from speech_recognition.exceptions import RequestError, UnknownValueError +from speech_recognition.exceptions import RateLimitError, RequestError, UnknownValueError class Alternative(TypedDict): @@ -215,6 +215,17 @@ def obtain_transcription(request: Request, timeout: int) -> str: try: response = urlopen(request, timeout=timeout) except HTTPError as e: + if e.code == 429: + retry_after_header = e.headers.get("Retry-After") if e.headers else None + retry_after: float | None + try: + retry_after = float(retry_after_header) if retry_after_header is not None else None + except ValueError: + retry_after = None + raise RateLimitError( + "recognition request failed: rate limited (HTTP 429): {}".format(e.reason), + retry_after=retry_after, + ) raise RequestError("recognition request failed: {}".format(e.reason)) except URLError as e: raise RequestError( diff --git a/tests/recognizers/test_google.py b/tests/recognizers/test_google.py index 4ee0ad1a..d56fe4b7 100644 --- a/tests/recognizers/test_google.py +++ b/tests/recognizers/test_google.py @@ -1,9 +1,11 @@ from unittest import TestCase from unittest.mock import MagicMock, patch +from urllib.error import HTTPError from urllib.request import Request from speech_recognition import Recognizer from speech_recognition.audio import AudioData +from speech_recognition.exceptions import RateLimitError from speech_recognition.recognizers import google MODULE_UNDER_TEST = "speech_recognition.recognizers.google" @@ -114,6 +116,23 @@ def test_obtain(self, urlopen): response.read.assert_called_once_with() response.read.return_value.decode.assert_called_once_with("utf-8") + @patch(f"{MODULE_UNDER_TEST}.urlopen") + def test_obtain_rate_limited(self, urlopen): + request = MagicMock(spec=Request) + error = HTTPError( + url="http://example.com", + code=429, + msg="Too Many Requests", + hdrs={"Retry-After": "30"}, + fp=None, + ) + urlopen.side_effect = error + + with self.assertRaises(RateLimitError) as cm: + google.obtain_transcription(request, 0) + + self.assertEqual(cm.exception.retry_after, 30.0) + @patch(f"{MODULE_UNDER_TEST}.OutputParser") @patch(f"{MODULE_UNDER_TEST}.obtain_transcription") From fbd028f97ab118a926339ded75510e74360871e5 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 30 Jul 2026 16:48:39 +0530 Subject: [PATCH 2/3] Parse Retry-After as either seconds or HTTP-date per RFC 9110 Per @ftnext's review: Retry-After can be a non-negative integer number of seconds OR an HTTP-date, per RFC 9110 10.2.3. The previous implementation only handled the numeric form via float(), silently discarding a date-formatted header instead of parsing it. Note: I could not find official Google documentation for this specific endpoint's Retry-After format, since it's an undocumented legacy API (the same one Chromium uses internally, accessed via a hardcoded key). Rather than assert an unverifiable claim about the exact format Google returns, this makes the parsing correct for both valid forms per the HTTP spec, so the behavior is correct regardless of which one is actually used. --- speech_recognition/recognizers/google.py | 34 ++++++++++++++++++++---- tests/recognizers/test_google.py | 19 +++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/speech_recognition/recognizers/google.py b/speech_recognition/recognizers/google.py index 62052ec8..d3789ec1 100644 --- a/speech_recognition/recognizers/google.py +++ b/speech_recognition/recognizers/google.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from datetime import datetime, timezone from typing import Dict, Literal, Optional, TypedDict from urllib.error import HTTPError, URLError from urllib.parse import urlencode @@ -211,17 +212,40 @@ def find_best_hypothesis(alternatives: list[Alternative]) -> Alternative: return best_hypothesis +def _parse_retry_after(value: str | None) -> float | None: + """ + Parse an HTTP ``Retry-After`` header value into a number of seconds. + + Per RFC 9110 section 10.2.3, the value is either a non-negative + integer number of seconds, or an HTTP-date. Returns ``None`` if + ``value`` is missing or doesn't match either form. + """ + if value is None: + return None + value = value.strip() + try: + return float(value) + except ValueError: + pass + try: + from email.utils import parsedate_to_datetime + retry_date = parsedate_to_datetime(value) + except (TypeError, ValueError, IndexError): + return None + if retry_date.tzinfo is None: + # HTTP-dates are always GMT; treat a naive result as UTC. + retry_date = retry_date.replace(tzinfo=timezone.utc) + delta = (retry_date - datetime.now(timezone.utc)).total_seconds() + return max(delta, 0.0) + + def obtain_transcription(request: Request, timeout: int) -> str: try: response = urlopen(request, timeout=timeout) except HTTPError as e: if e.code == 429: retry_after_header = e.headers.get("Retry-After") if e.headers else None - retry_after: float | None - try: - retry_after = float(retry_after_header) if retry_after_header is not None else None - except ValueError: - retry_after = None + retry_after = _parse_retry_after(retry_after_header) raise RateLimitError( "recognition request failed: rate limited (HTTP 429): {}".format(e.reason), retry_after=retry_after, diff --git a/tests/recognizers/test_google.py b/tests/recognizers/test_google.py index d56fe4b7..634ae014 100644 --- a/tests/recognizers/test_google.py +++ b/tests/recognizers/test_google.py @@ -133,6 +133,25 @@ def test_obtain_rate_limited(self, urlopen): self.assertEqual(cm.exception.retry_after, 30.0) + @patch(f"{MODULE_UNDER_TEST}.urlopen") + def test_obtain_rate_limited_http_date_retry_after(self, urlopen): + request = MagicMock(spec=Request) + error = HTTPError( + url="http://example.com", + code=429, + msg="Too Many Requests", + hdrs={"Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT"}, + fp=None, + ) + urlopen.side_effect = error + + with self.assertRaises(RateLimitError) as cm: + google.obtain_transcription(request, 0) + + # Far-future date -> a large positive number of seconds, not None + self.assertIsNotNone(cm.exception.retry_after) + self.assertGreater(cm.exception.retry_after, 0) + @patch(f"{MODULE_UNDER_TEST}.OutputParser") @patch(f"{MODULE_UNDER_TEST}.obtain_transcription") From 966ddd2a791515978ed8f9f33d1b45eb9ce5ecfc Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 1 Aug 2026 18:19:01 +0530 Subject: [PATCH 3/3] Scope RateLimitError down to just HTTP 429 detection Per @ftnext's follow-up review: drop the retry_after attribute and Retry-After parsing entirely, since we have no documentation or observed evidence that this endpoint actually sends that header at all. Keeping the change scoped to just raising RateLimitError for HTTP 429, which is directly observable and doesn't rely on any unverified assumption about response headers. --- speech_recognition/exceptions.py | 9 +------ speech_recognition/recognizers/google.py | 31 ------------------------ tests/recognizers/test_google.py | 26 ++------------------ 3 files changed, 3 insertions(+), 63 deletions(-) diff --git a/speech_recognition/exceptions.py b/speech_recognition/exceptions.py index 0b2790c1..a50bcfff 100644 --- a/speech_recognition/exceptions.py +++ b/speech_recognition/exceptions.py @@ -26,12 +26,5 @@ class RateLimitError(RequestError): """Raised when the speech recognition service returns an HTTP 429 (Too Many Requests) response, indicating the caller has been rate limited. - - :attr retry_after: The number of seconds to wait before retrying, - parsed from the response's ``Retry-After`` header if present, - otherwise ``None``. """ - - def __init__(self, message: str, retry_after: float | None = None): - super().__init__(message) - self.retry_after = retry_after + pass diff --git a/speech_recognition/recognizers/google.py b/speech_recognition/recognizers/google.py index d3789ec1..32b1125f 100644 --- a/speech_recognition/recognizers/google.py +++ b/speech_recognition/recognizers/google.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -from datetime import datetime, timezone from typing import Dict, Literal, Optional, TypedDict from urllib.error import HTTPError, URLError from urllib.parse import urlencode @@ -212,43 +211,13 @@ def find_best_hypothesis(alternatives: list[Alternative]) -> Alternative: return best_hypothesis -def _parse_retry_after(value: str | None) -> float | None: - """ - Parse an HTTP ``Retry-After`` header value into a number of seconds. - - Per RFC 9110 section 10.2.3, the value is either a non-negative - integer number of seconds, or an HTTP-date. Returns ``None`` if - ``value`` is missing or doesn't match either form. - """ - if value is None: - return None - value = value.strip() - try: - return float(value) - except ValueError: - pass - try: - from email.utils import parsedate_to_datetime - retry_date = parsedate_to_datetime(value) - except (TypeError, ValueError, IndexError): - return None - if retry_date.tzinfo is None: - # HTTP-dates are always GMT; treat a naive result as UTC. - retry_date = retry_date.replace(tzinfo=timezone.utc) - delta = (retry_date - datetime.now(timezone.utc)).total_seconds() - return max(delta, 0.0) - - def obtain_transcription(request: Request, timeout: int) -> str: try: response = urlopen(request, timeout=timeout) except HTTPError as e: if e.code == 429: - retry_after_header = e.headers.get("Retry-After") if e.headers else None - retry_after = _parse_retry_after(retry_after_header) raise RateLimitError( "recognition request failed: rate limited (HTTP 429): {}".format(e.reason), - retry_after=retry_after, ) raise RequestError("recognition request failed: {}".format(e.reason)) except URLError as e: diff --git a/tests/recognizers/test_google.py b/tests/recognizers/test_google.py index 634ae014..9dc47dc7 100644 --- a/tests/recognizers/test_google.py +++ b/tests/recognizers/test_google.py @@ -7,7 +7,6 @@ from speech_recognition.audio import AudioData from speech_recognition.exceptions import RateLimitError from speech_recognition.recognizers import google - MODULE_UNDER_TEST = "speech_recognition.recognizers.google" @@ -123,35 +122,14 @@ def test_obtain_rate_limited(self, urlopen): url="http://example.com", code=429, msg="Too Many Requests", - hdrs={"Retry-After": "30"}, + hdrs={}, fp=None, ) urlopen.side_effect = error - with self.assertRaises(RateLimitError) as cm: + with self.assertRaises(RateLimitError): google.obtain_transcription(request, 0) - self.assertEqual(cm.exception.retry_after, 30.0) - - @patch(f"{MODULE_UNDER_TEST}.urlopen") - def test_obtain_rate_limited_http_date_retry_after(self, urlopen): - request = MagicMock(spec=Request) - error = HTTPError( - url="http://example.com", - code=429, - msg="Too Many Requests", - hdrs={"Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT"}, - fp=None, - ) - urlopen.side_effect = error - - with self.assertRaises(RateLimitError) as cm: - google.obtain_transcription(request, 0) - - # Far-future date -> a large positive number of seconds, not None - self.assertIsNotNone(cm.exception.retry_after) - self.assertGreater(cm.exception.retry_after, 0) - @patch(f"{MODULE_UNDER_TEST}.OutputParser") @patch(f"{MODULE_UNDER_TEST}.obtain_transcription")