diff --git a/assemblyai/tts/v1/models.py b/assemblyai/tts/v1/models.py index bdebdbd..245f0de 100644 --- a/assemblyai/tts/v1/models.py +++ b/assemblyai/tts/v1/models.py @@ -247,7 +247,10 @@ class TTSResponseInfo(BaseModel): billed_characters: Optional[int] = None """The characters the request bills. On a stream, the value as the stream - started: a body cut by a server failure bills nothing.""" + started: a body cut by a server failure bills nothing, one cut by + `session_expired` (the API's 600-second limit) bills the text before the + sentence being spoken, and one cut by `audio_backlog` (a client reading + too slowly) bills every character.""" audio_duration_ms: Optional[int] = None """The duration of the audio in milliseconds, on a complete response: diff --git a/tests/unit/test_tts.py b/tests/unit/test_tts.py index 0c985cd..32b7e74 100644 --- a/tests/unit/test_tts.py +++ b/tests/unit/test_tts.py @@ -320,6 +320,33 @@ def test_a_refused_stream_releases_its_connection(): assert body.closed +class _CutJsonBody(httpx.SyncByteStream): + closed = False + + def __iter__(self) -> Iterator[bytes]: + yield b'{"code": ' + raise httpx.ReadError("connection reset") + + def close(self) -> None: + self.closed = True + + +def test_a_refused_stream_releases_its_connection_if_its_body_fails(): + # httpx closes a fully read body itself; a body cut while it is read is + # released only by the stream. The error stays referenced until the end, + # as a caller that logs it would, so its traceback keeps the stream's + # frames alive and garbage collection cannot release the body instead. + body = _CutJsonBody() + with httpx.Client(transport=_transport(body, status=503)) as http: + speech = api.stream_speech( + http, base_url="https://tts.test", body={"text": "Hi."}, timeout=5.0 + ) + with pytest.raises(httpx.ReadError) as exc_info: + speech.read() + assert body.closed + assert "connection reset" in str(exc_info.value) + + def test_a_client_built_from_api_key_is_closed_with_the_instance(): with aai.TextToSpeech(api_key="k") as tts: http = tts.client.http_client diff --git a/tests/unit/test_tts_async.py b/tests/unit/test_tts_async.py index 23b3f1e..876d993 100644 --- a/tests/unit/test_tts_async.py +++ b/tests/unit/test_tts_async.py @@ -187,6 +187,33 @@ async def test_a_refused_stream_releases_its_connection(): assert body.closed +class _CutJsonBody(httpx.AsyncByteStream): + closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b'{"code": ' + raise httpx.ReadError("connection reset") + + async def aclose(self) -> None: + self.closed = True + + +async def test_a_refused_stream_releases_its_connection_if_its_body_fails(): + # httpx closes a fully read body itself; a body cut while it is read is + # released only by the stream. The error stays referenced until the end, + # as a caller that logs it would, so its traceback keeps the stream's + # frames alive and garbage collection cannot release the body instead. + body = _CutJsonBody() + async with httpx.AsyncClient(transport=_transport(body, status=503)) as http: + speech = async_api.stream_speech( + http, base_url="https://tts.test", body={"text": "Hi."}, timeout=5.0 + ) + with pytest.raises(httpx.ReadError) as exc_info: + await speech.aread() + assert body.closed + assert "connection reset" in str(exc_info.value) + + async def test_an_owned_pool_is_closed_on_aclose(): tts = aai.AsyncTextToSpeech(api_key="k") http = tts.client.http_client