Repository navigation
fix(direct): match Python's provider error classification - #7
Merged
Merged
Conversation
DirectGateway's OpenAI classifier only looked at the error class name and a
string fallback, and the Anthropic paths had no classifier at all — two
copies of an inline string check that could only ever produce
ContentPolicyError or a generic ProviderError.
Port the full detection ladder from the Python SDK's
_classify_openai_error / _classify_anthropic_error:
- class hierarchy (prototype-chain walk, the TS analogue of __mro__)
- HTTP status: 429 -> RateLimitError, 401 -> ProviderError("Authentication
failed: ...")
- error code in (content_filter, content_policy_violation, output_blocked)
-> ContentPolicyError; Anthropic matches on error type instead
- string fallback, unchanged
RateLimitError now carries retry_after_seconds read from the error's
retry-after header (Headers instance or plain record) or a retryAfter
property, defaulting to 60 as Python does. Anthropic errors route through a
new _classifyAnthropicError shared by the blocking and streaming paths.
Also narrows the try blocks to wrap only the provider API call, matching
Python — response parsing no longer gets laundered into a ProviderError.
Co-Authored-By: Claude <noreply@anthropic.com>
Goldokpa
added a commit
that referenced
this pull request
Oct 9, 2026
DirectGateway's OpenAI classifier only looked at the error class name and a
string fallback, and the Anthropic paths had no classifier at all — two
copies of an inline string check that could only ever produce
ContentPolicyError or a generic ProviderError.
Port the full detection ladder from the Python SDK's
_classify_openai_error / _classify_anthropic_error:
- class hierarchy (prototype-chain walk, the TS analogue of __mro__)
- HTTP status: 429 -> RateLimitError, 401 -> ProviderError("Authentication
failed: ...")
- error code in (content_filter, content_policy_violation, output_blocked)
-> ContentPolicyError; Anthropic matches on error type instead
- string fallback, unchanged
RateLimitError now carries retry_after_seconds read from the error's
retry-after header (Headers instance or plain record) or a retryAfter
property, defaulting to 60 as Python does. Anthropic errors route through a
new _classifyAnthropicError shared by the blocking and streaming paths.
Also narrows the try blocks to wrap only the provider API call, matching
Python — response parsing no longer gets laundered into a ProviderError.
Co-authored-by: Kem-Ada <283358351+Kem-Ada@users.noreply.github.com>
Co-authored-by: Gold okpa <Okpagold@gmail.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
DirectGatewayclassified provider SDK errors far more coarsely than the Python SDK does.The OpenAI path checked only the direct class name plus a string fallback, so a
429without arecognisably-named class became a generic
ProviderErrorinstead of aRateLimitError, and anauth failure was indistinguishable from any other provider error. The Anthropic paths had no
classifier at all — just two copies of the same inline string check in
_callAnthropicand_streamAnthropic, which could only ever produceContentPolicyErroror a genericProviderError. An Anthropic rate limit was silently flattened intoProviderError.This ports the full detection ladder from the Python SDK's
_classify_openai_errorand_classify_anthropic_error, in the same priority order: class hierarchy first (most reliable),then HTTP status, then error code/type, then string matching.
Behaviour changes callers will notice:
RateLimitError(withprovider === "anthropic") instead ofProviderError.401from either provider now raisesProviderErrorwith an"Authentication failed: …"/"Anthropic authentication failed: …"prefix, so it is distinguishable from a transport error.RateLimitError.retry_after_secondsis now populated from the provider'sretry-afterhintinstead of always being the
60default._classifyAnthropicErroris shared by the blocking and streaming paths, so a streamingAnthropic error classifies identically to a blocking one. (This drops the previous
"Anthropic streaming error: …"message in favour of Python's"Anthropic API error: …"—Python uses one classifier for both paths.)
One structural fix came along with it: the
tryblocks in_callOpenAiand_callAnthropicwrapped the whole method, including response parsing and
estimateCost. Python wraps only theAPI call. A
TypeErrorfrom an unexpected response shape was therefore being caught andre-thrown as
ProviderError, hiding real client-side bugs behind a provider-blaming message.The blocks are now narrowed to the provider call, matching Python. There is a regression test
for this.
No new dependencies. The OpenAI and Anthropic SDKs stay optional peer dependencies — errors are
recognised structurally (prototype-chain constructor names,
status,code/type), never byimporting the SDK.
Python → TS parity mapping
Reference:
identark/gateways/direct.py(Python) →src/gateways/direct.ts(TS)._classify_openai_error(exc)_classifyOpenAiError(exc)_classify_anthropic_error(exc)_classifyAnthropicError(exc)[c.__name__ for c in type(exc).__mro__]errorClassChain(exc)constructor.nametype(exc).__name__exc.constructor.name"ContentFilter"substring checkgetattr(exc, "status_code", None)errorStatusCode(exc)status(what the JS SDKs use) thenstatus_codegetattr(exc, "retry_after", None)errorRetryAfterSeconds(exc)getattr(exc, "code") or getattr(exc, "error_code")errorCode(exc)getattr(exc, "type") or getattr(exc, "error_type")errorType(exc)RateLimitError(str(exc), provider=…, retry_after_seconds=…)new RateLimitError(exc.message, retryAfter ?? 60, provider)(message, retryAfterSeconds, provider)("content_filter", "content_policy_violation", "output_blocked")CONTENT_POLICY_CODES("content_policy_violation", "content_filter")ANTHROPIC_CONTENT_POLICY_TYPESf"Authentication failed: {exc}"`Authentication failed: ${exc}`f"Anthropic authentication failed: {exc}"`Anthropic authentication failed: ${exc}`f"{self._provider.capitalize()} API error: {exc}"`${provider[0].toUpperCase()}${provider.slice(1)} API error: ${exc}`try: … except: self._classify_*(exc)around the API call onlytry { await … } catch { this._classify*(exc) }around the API call onlyretry-after. This is the one place the TS is not a line-for-line translation, and it is
deliberate. Python reads
getattr(exc, "retry_after", None)because the Python SDK exposes it asan exception attribute. The JS SDKs do not — they put it on the response headers. So
errorRetryAfterSecondschecksretryAfter/retry_afterfirst, then falls back toheaders["retry-after"], handling both aHeadersinstance and a plain record. Non-numericvalues (e.g. the HTTP-date form of
Retry-After) and non-positive values are ignored, so the60default still applies. Python also only reads the hint in the class-name branch, not the429-status branch; the TS reads it in both. Both branches default to60, so this isidentical whenever no hint is present — see Reviewer notes.
Test plan
New file
tests/direct-error-classification.test.ts, 39 tests, positive and negative:RateLimitError; bare429→RateLimitError;default
60;retry-afterfrom a header record;retry-afterfrom aHeadersinstance;non-numeric
retry-afterfalls back to60; configured provider carried onto the error;negative:
503is not a rate limit.ContentFilterFinishReasonError; each of the three errorcodes (parameterised); each of the three string-fallback phrasings (parameterised);
negative:
invalid_request_erroris not a policy block; ordering: a429whose messagecontains
content_filterclassifies as a rate limit, proving priority order."Authentication failed:"; bare401→ same;negative:
403falls through to"Openai API error:".the resolved one (
local); regression: a malformed response yields the realTypeError,not a
ProviderError.429;retry-after; both content-policyerror types (parameterised); all three string-fallback phrasings (parameterised);
negative: unrelated error type is not a policy block; auth by class and by
401;unclassified fallback.
retry_after_seconds; OpenAI stream401; Anthropic stream content policy by error type; Anthropic stream429classifies asRateLimitErrorrather than the generic error it produced before.Python has no unit tests for
_classify_openai_error/_classify_anthropic_error— theclassifiers are exercised only indirectly. These tests are therefore written against the Python
source ladder rather than ported from a Python test file.
Full gate, run from inside
identark-js:Lint and typecheck are clean (no output). Tests: 93 passing / 7 files, up from the
54 / 6 baseline — +39 from this PR. No existing test was modified.
Control-plane: untouched (check_scope OK)
🤖 Generated with Claude Code