Skip to content

Commit d7bd101

Browse files
Chandler Caseyclaude
andcommitted
2.0.0: API v2
Targets /api/v2: one error block on every failure (code, number, message, docs, retry_with | details | issues), family-based exception classes (NotFoundError, BatchTooLargeError, UnprocessableInputError), ApiErrorBlock model, batch entries with three outcomes and an error block, Transcript.source and Transcript.error. v1 stays reachable until 2027-09-03; 1.x keeps working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Pvnuti4G9iijR3mRCw9CH
1 parent 64d255d commit d7bd101

13 files changed

Lines changed: 288 additions & 91 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,33 @@
33
All notable changes to this project are documented here. This project adheres to
44
[Semantic Versioning](https://semver.org).
55

6+
## [2.0.0] - 2026-09-03
7+
8+
Targets API v2 (`/api/v2`). v1 is deprecated server-side with a twelve-month
9+
window, so 1.x keeps working until 2027-09-03; upgrade before then.
10+
11+
### Breaking
12+
13+
- Every request goes to `/api/v2/...`.
14+
- `APIError` gains `number` (stable integer code; the thousands digit is the
15+
family, 5xxx = retry), `docs`, `retry_with`, `details`, and a `retryable`
16+
property. New subclasses: `NotFoundError` (404), `BatchTooLargeError` (400,
17+
`details["max"]`), `UnprocessableInputError` (422, the whole 3xxx/4xxx
18+
family: unsupported platform, no captions, private, live, and so on;
19+
`retry_with` is set when a different request would work, e.g.
20+
`{"mode": "audio"}`).
21+
- `BatchResult`: `outcome` is exactly `"ok" | "processing" | "error"`; the
22+
deprecated `cached` and `status` fields are gone; failed entries carry
23+
`error` (an `ApiErrorBlock`, the same shape a request-level error raises);
24+
`source` and `poll_url` added.
25+
- `Transcript.error` (an `ApiErrorBlock`) is set on a failed job polled via
26+
`transcripts.job()`.
27+
28+
### Added
29+
30+
- `Transcript.source`: `"captions"` or `"audio"`, where the words came from.
31+
- `ApiErrorBlock` model.
32+
633
## [1.0.2] - 2026-08-28
734

835
Batch grew audio fallback on the API side; this release types it.

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ print(t.podcast.show, "-", t.podcast.episode)
6666

6767
Podcast transcriptions include best-effort speaker diarization: each segment may carry a `speaker` integer (0, 1, …) identifying who is talking. The ids are hints from voice separation, not named identification, and non-podcast sources never carry them.
6868

69-
Batch works the same way by default (`mode="auto"`): entries with no caption track are transcribed from audio, come back with `outcome == "processing"` and a `job_id`, cost nothing on that call, and are charged on delivery at the audio rate. Re-send the same batch once the jobs have had time to finish and the text comes back normally — or poll each `job_id` with `tf.transcripts.job()`. Pass `mode="captions"` to read existing caption tracks only, in which case a captionless video fails as `no_transcript` (the old behaviour):
69+
Batch works the same way by default (`mode="auto"`): entries with no caption track are transcribed from audio, come back with `outcome == "processing"` and a `job_id`, cost nothing on that call, and are charged on delivery at the audio rate. Re-send the same batch once the jobs have had time to finish and the text comes back normally — or poll each `job_id` with `tf.transcripts.job()`. Pass `mode="captions"` to read existing caption tracks only, in which case a captionless video fails as `outcome == "error"` with `error.code == "no_captions"` (and `error.retry_with` naming the audio mode):
7070

7171
```python
7272
res = tf.transcripts.batch(ids) # captionless entries -> "processing" + job_id
@@ -104,7 +104,7 @@ asyncio.run(main())
104104

105105
## Errors
106106

107-
All errors subclass `TranscriptFetchError`. API errors carry `.status`, `.code`, `.message`, and `.request_id`:
107+
All errors subclass `TranscriptFetchError`. API errors carry `.status`, `.code`, `.number` (the thousands digit is the family; 5xxx means retry), `.message`, `.docs`, `.retry_with` (the request change that would succeed, when there is one), `.details`, `.request_id`, and a `.retryable` property:
108108

109109
```python
110110
from transcriptfetch import (

‎src/transcriptfetch/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
APITimeoutError,
1919
AuthenticationError,
2020
IdempotencyConflictError,
21+
BatchTooLargeError,
2122
InsufficientCreditsError,
23+
NotFoundError,
24+
UnprocessableInputError,
2225
InternalServerError,
2326
InvalidRequestError,
2427
RateLimitError,
@@ -28,6 +31,7 @@
2831
from .models import (
2932
Account,
3033
BatchResponse,
34+
ApiErrorBlock,
3135
BatchResult,
3236
Podcast,
3337
Segment,
@@ -49,6 +53,7 @@
4953
"Transcript",
5054
"Video",
5155
"VideoList",
56+
"ApiErrorBlock",
5257
"BatchResult",
5358
"BatchResponse",
5459
# errors
@@ -58,7 +63,10 @@
5863
"APITimeoutError",
5964
"AuthenticationError",
6065
"InvalidRequestError",
66+
"BatchTooLargeError",
6167
"InsufficientCreditsError",
68+
"NotFoundError",
69+
"UnprocessableInputError",
6270
"IdempotencyConflictError",
6371
"RateLimitError",
6472
"UpstreamUnavailableError",

‎src/transcriptfetch/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.2"
1+
__version__ = "2.0.0"

‎src/transcriptfetch/async_client.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,14 @@ async def _request(
8282

8383
async def me(self) -> Account:
8484
"""Validate the API key and read the account's credit balance. Free."""
85-
env = await self._request("GET", "/api/v1/me")
85+
env = await self._request("GET", "/api/v2/me")
8686
account = Account.model_validate(env.get("data") or {})
8787
account.usage = parse_usage(env)
8888
return account
8989

9090
async def health(self) -> dict[str, Any]:
9191
"""Unauthenticated liveness probe."""
92-
return await self._request("GET", "/api/v1/health", auth=False)
92+
return await self._request("GET", "/api/v2/health", auth=False)
9393

9494
async def close(self) -> None:
9595
if self._owns_http:

‎src/transcriptfetch/client.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ def _request(
7979

8080
def me(self) -> Account:
8181
"""Validate the API key and read the account's credit balance. Free."""
82-
env = self._request("GET", "/api/v1/me")
82+
env = self._request("GET", "/api/v2/me")
8383
account = Account.model_validate(env.get("data") or {})
8484
account.usage = parse_usage(env)
8585
return account
8686

8787
def health(self) -> dict[str, Any]:
8888
"""Unauthenticated liveness probe."""
89-
return self._request("GET", "/api/v1/health", auth=False)
89+
return self._request("GET", "/api/v2/health", auth=False)
9090

9191
def close(self) -> None:
9292
if self._owns_http:

‎src/transcriptfetch/errors.py‎

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
"""Exception hierarchy for the TranscriptFetch SDK.
22
3-
The API returns a canonical error envelope: ``{ ok: false, request_id, error:
4-
{ code, message, issues? } }``. We map ``error.code`` (falling back to the HTTP
5-
status) to a specific exception subclass so callers can branch cleanly.
3+
The API (v2) returns one error block on every failure::
4+
5+
{ "ok": false, "request_id": "...",
6+
"error": { "code", "number", "message", "docs", "retry_with"?, "details"?, "issues"? } }
7+
8+
``code`` is a stable string and ``number`` a stable integer whose thousands
9+
digit is the family (1 request, 2 account, 3 input, 4 content, 5 transient,
10+
9 ours). We map ``error.code`` (falling back to the number's family, then the
11+
HTTP status) to a specific exception subclass so callers can branch cleanly,
12+
and expose the rest of the block as attributes.
613
"""
714

815
from __future__ import annotations
@@ -31,21 +38,49 @@ def __init__(
3138
*,
3239
status: int,
3340
code: Optional[str] = None,
41+
number: Optional[int] = None,
3442
request_id: Optional[str] = None,
43+
docs: Optional[str] = None,
44+
retry_with: Optional[dict[str, Any]] = None,
45+
details: Optional[dict[str, Any]] = None,
3546
issues: Optional[list[Any]] = None,
3647
) -> None:
3748
super().__init__(message)
3849
self.status = status
3950
self.code = code
51+
self.number = number
52+
"""Stable numeric code; the thousands digit is the family."""
4053
self.message = message
4154
self.request_id = request_id
55+
self.docs = docs
56+
"""Per-code documentation URL."""
57+
self.retry_with = retry_with
58+
"""The request change that would succeed, e.g. ``{"mode": "audio"}``, else ``None``."""
59+
self.details = details
60+
"""Structured specifics for the few codes that document them."""
4261
self.issues = issues or []
4362

63+
@property
64+
def retryable(self) -> bool:
65+
"""Whether the SAME request is worth retrying with backoff.
66+
67+
True for the transient (5xxx) and server (9xxx) families, plus rate
68+
limits and credit exhaustion once the condition clears. Never true for
69+
1xxx/3xxx/4xxx, where the request itself has to change (see
70+
``retry_with``).
71+
"""
72+
if self.number is not None:
73+
family = self.number // 1000
74+
return family in (5, 9) or self.code in ("rate_limited", "insufficient_credits")
75+
return self.status == 429 or self.status >= 500
76+
4477
def __str__(self) -> str:
4578
bits = [self.message]
4679
meta = [f"status={self.status}"]
4780
if self.code:
4881
meta.append(f"code={self.code}")
82+
if self.number is not None:
83+
meta.append(f"number={self.number}")
4984
if self.request_id:
5085
meta.append(f"request_id={self.request_id}")
5186
bits.append(f"[{', '.join(meta)}]")
@@ -57,7 +92,15 @@ class AuthenticationError(APIError):
5792

5893

5994
class InvalidRequestError(APIError):
60-
"""400: the request body failed validation (see ``issues``)."""
95+
"""400: the request body failed validation (see ``issues``), or a stale cursor."""
96+
97+
98+
class BatchTooLargeError(InvalidRequestError):
99+
"""400 ``batch_too_large``: over your plan's per-batch cap (see ``details["max"]``)."""
100+
101+
102+
class NotFoundError(APIError):
103+
"""404: no such job for this account."""
61104

62105

63106
class InsufficientCreditsError(APIError):
@@ -76,10 +119,20 @@ def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwarg
76119
self.retry_after = retry_after
77120

78121

122+
class UnprocessableInputError(APIError):
123+
"""422: this input cannot be served, permanently (the 3xxx and 4xxx families).
124+
125+
An unsupported platform, the wrong endpoint for the platform, a podcast
126+
with no public feed, a private or live video, no captions, no speech.
127+
``code`` says which; ``retry_with`` is set when a different request would
128+
work (e.g. ``{"mode": "audio"}`` to transcribe a captionless video).
129+
"""
130+
131+
79132
class UpstreamUnavailableError(APIError):
80133
"""502/503: the upstream platform blocked or was unreachable. Safe to retry.
81134
82-
Upstream platform blocks answer 503 with code ``upstream_error`` — never
135+
Upstream platform blocks answer 503 with code ``upstream_error``, never
83136
429, which is reserved for per-key rate limits (:class:`RateLimitError`).
84137
"""
85138

@@ -91,26 +144,45 @@ class InternalServerError(APIError):
91144
_CODE_TO_EXC: dict[str, type[APIError]] = {
92145
"unauthorized": AuthenticationError,
93146
"invalid_request": InvalidRequestError,
147+
"invalid_cursor": InvalidRequestError,
148+
"not_found": NotFoundError,
94149
"insufficient_credits": InsufficientCreditsError,
150+
"batch_too_large": BatchTooLargeError,
95151
"idempotency_conflict": IdempotencyConflictError,
96152
"rate_limited": RateLimitError,
97153
"upstream_unavailable": UpstreamUnavailableError,
98-
"upstream_error": UpstreamUnavailableError,
99154
"internal_error": InternalServerError,
100155
}
101156

102157
_STATUS_TO_EXC: dict[int, type[APIError]] = {
103158
400: InvalidRequestError,
104159
401: AuthenticationError,
105160
402: InsufficientCreditsError,
161+
404: NotFoundError,
106162
409: IdempotencyConflictError,
163+
422: UnprocessableInputError,
107164
429: RateLimitError,
108165
500: InternalServerError,
109166
502: UpstreamUnavailableError,
110167
503: UpstreamUnavailableError,
111168
}
112169

113170

171+
def _exc_for(code: Optional[str], number: Optional[int], status: int) -> type[APIError]:
172+
"""Choose the class from the number's family when the code is not listed."""
173+
if code and code in _CODE_TO_EXC:
174+
return _CODE_TO_EXC[code]
175+
if number is not None:
176+
family = number // 1000
177+
if family in (3, 4):
178+
return UnprocessableInputError
179+
if family == 5:
180+
return UpstreamUnavailableError
181+
if family == 9:
182+
return InternalServerError
183+
return _STATUS_TO_EXC.get(status, APIError)
184+
185+
114186
def raise_api_error(
115187
status: int,
116188
payload: dict[str, Any],
@@ -120,16 +192,35 @@ def raise_api_error(
120192
"""Map an error response to the appropriate exception and raise it."""
121193
error = payload.get("error") if isinstance(payload, dict) else None
122194
code: Optional[str] = None
195+
number: Optional[int] = None
123196
message = f"Request failed with status {status}"
197+
docs: Optional[str] = None
198+
retry_with: Optional[dict[str, Any]] = None
199+
details: Optional[dict[str, Any]] = None
124200
issues: Optional[list[Any]] = None
125201
if isinstance(error, dict):
126202
code = error.get("code")
203+
raw_number = error.get("number")
204+
number = raw_number if isinstance(raw_number, int) else None
127205
message = error.get("message") or message
206+
docs = error.get("docs") if isinstance(error.get("docs"), str) else None
207+
retry_with = error.get("retry_with") if isinstance(error.get("retry_with"), dict) else None
208+
details = error.get("details") if isinstance(error.get("details"), dict) else None
128209
raw_issues = error.get("issues")
129210
if isinstance(raw_issues, list):
130211
issues = raw_issues
131212

132-
exc_cls = (code and _CODE_TO_EXC.get(code)) or _STATUS_TO_EXC.get(status) or APIError
213+
exc_cls = _exc_for(code, number, status)
214+
kwargs: dict[str, Any] = dict(
215+
status=status,
216+
code=code,
217+
number=number,
218+
request_id=request_id,
219+
docs=docs,
220+
retry_with=retry_with,
221+
details=details,
222+
issues=issues,
223+
)
133224

134225
if exc_cls is RateLimitError:
135226
parsed_retry: Optional[float] = None
@@ -138,13 +229,6 @@ def raise_api_error(
138229
parsed_retry = float(retry_after)
139230
except (TypeError, ValueError):
140231
parsed_retry = None
141-
raise RateLimitError(
142-
message,
143-
status=status,
144-
code=code,
145-
request_id=request_id,
146-
issues=issues,
147-
retry_after=parsed_retry,
148-
)
149-
150-
raise exc_cls(message, status=status, code=code, request_id=request_id, issues=issues)
232+
raise RateLimitError(message, retry_after=parsed_retry, **kwargs)
233+
234+
raise exc_cls(message, **kwargs)

0 commit comments

Comments
 (0)