Skip to content

feat: structured DocuSignError for every failure, with an error handling guide - #10

Merged
IronTony merged 5 commits into
mainfrom
feat/structured-errors
Sep 13, 2026
Merged

IronTony merged 5 commits into
mainfrom
feat/structured-errors

Conversation

@IronTony

Copy link
Copy Markdown
Owner

Every failure now rejects with a structured DocuSignError, so an app can show users a message that fits the failure and a developer can tell from Amplitude, New Relic or Sentry what failed and why. Ships in 2.0.0, which is still unpublished.

Why

Errors only said where something broke (login_failed, signing_failed), never why. An app could show nothing better than "something went wrong", and in production an expired token looked the same as a lost connection. None of that was fixable from an app, because the package threw the information away:

  • iOS forwarded a bare NSError.code without its domain, and the SDK raises errors from several domains.
  • Android forwarded only exception.message, although DSException exposes getErrorCode() and getErrorMsg() and DSRestException exposes the HTTP status.
  • The Android recipient-view mint read DocuSign's error body, threw only the status line, and the fetch fallback then discarded even that.
  • Login already worked out whether a token was rejected or the account was not set up for mobile, then flattened the answer into free text.

The contract

class DocuSignError extends Error {
  code: DocuSignErrorCode; // what failed, stable
  reason: 'usage' | 'network' | 'auth' | 'configuration' | 'recipient' | 'unknown';
  envelopeId?: string;
  native?: { domain?; code?; message?; underlying? };
  http?: { status; docusignErrorCode?; docusignMessage? };
  toAttributes(): DocuSignErrorAttributes; // flat, primitive, no message text
}
  • reason is derived in one place, in TypeScript, from facts native code reports: error domain, HTTP status, DocuSign's own error code. Anything unverifiable is unknown. The rules are covered by Jest on both platforms.
  • Expo's bridge rejects with only a code and a message (AsyncFunctionDefinition.swift: reject(error.code, error.description, nil)), so native code rejects only for caller mistakes and resolves runtime failures with a failure payload. src/api.ts turns both into a thrown DocuSignError.
  • addSigningErrorListener receives every DocuSignError exactly once, caller mistakes included, so an app can wire logging once at startup.
  • Messages and details are redacted before they reach app code: JWTs, Bearer credentials, URL query strings and token-like path segments. The redaction is pattern-based, and the guide says so.
  • In development, a caller mistake also prints one console warning naming the fix. Production prints nothing.
  • The package still shows no UI, ships no user-facing strings and depends on no analytics SDK.

Breaking changes (2.0.0)

  • The four public functions reject with DocuSignError. Branch on code and reason, not message text.
  • presentCaptiveSigning* never resolve status: 'error'. iOS used it for SDK errors reported after the UI was on screen, while Android rejected the same failures. 'error' stays in SigningStatus so switch statements compile.
  • New codes for two caller mistakes that hid inside signing_failed: signing_in_progress and invalid_signing_url.
  • addSigningErrorListener receives a DocuSignError instead of { errorCode, errorMessage }.
  • useDocuSignSigning types error as DocuSignError | null.

Full list in CHANGELOG.md.

Also fixed

  • iOS looked up the presenting view controller on the background queue the JS call arrived on. It now runs on main.
  • A missing presenter on iOS settled the promise twice. Now once.

Documentation

docs/ERROR_HANDLING.md covers the error model, translated copy chosen by reason (English and Italian resource files), a retry policy, reporting to Amplitude, New Relic and Sentry, the Amplitude charts and NRQL queries to read the results, and worked examples for every reason.

Its code is real files under examples/error-handling, type-checked in the lint job against the real SDKs. scripts/check-doc-examples.js fails CI if the guide's copies drift from those files. The New Relic query targets MobileJSError, where React Native agent 1.9.0 and later store recordError.

Verification

  • 85 Jest tests. src/api.ts and src/DocuSignError.ts at 100% lines, and the test job now runs with coverage so the thresholds are enforced.
  • Build, lint, examples type-check and the doc drift check pass.
  • Both native platforms compiled against the real DocuSign SDKs: xcodebuild of the ReactNativeDocuSign pod target, and Gradle compileDebugKotlin of the Android module.
  • Two review passes. The first blocked on a redaction bypass (a token path segment followed by a period, comma or parenthesis passed through) plus three smaller gaps. All four are fixed in the last commit, and the second pass confirmed them.

Not verified yet

CI compiles no Swift or Kotlin and runs nothing on a device. Before tagging 2.0.0, a fault-injection run in a consuming app should confirm, on both platforms:

  • A corrupted access token gives login_failed with reason: 'auth'.
  • A wrong recipientClientUserId under the Android signingUrl strategy returns UNKNOWN_ENVELOPE_RECIPIENT. If DocuSign answers with a different code, the recipient mapping gets dropped.
  • A network cut before presenting and after the UI is up gives reason: 'network', with native populated.
  • The format of Android's DSException.getErrorCode() values, recorded for future mappings.

Notes

  • @amplitude/analytics-react-native, newrelic-react-native-agent, @sentry/react-native, i18next and react-i18next are devDependencies, only for type-checking the examples. They are not in the tarball, and the only runtime dependency is still adm-zip. They add 3 moderate npm audit advisories to the dev tree (27 before, 30 after).
  • Merge with rebase. The five commits are separable (TS, iOS, Android, docs, review fixes).
  • chore(release): 2.0.0聽#9 needs a rebase after this lands, so its CHANGELOG rename picks up these entries.

Failures only said where something broke (login_failed, signing_failed),
never why, so an app could show nothing better than a generic message and a
developer could not tell an expired token from a lost connection in
production.

initialize, loginWithAccessToken, presentCaptiveSigning and
presentCaptiveSigningWithUrl now reject with a DocuSignError carrying code,
reason, native, http and toAttributes(). reason is derived in one place from
facts native code reports (error domain, HTTP status, DocuSign's error code),
so it never guesses and is covered by Jest on both platforms.

The Expo bridge rejects with only a code and a message, so native code now
resolves runtime failures with a failure payload and this layer throws. Native
rejections remain for caller mistakes and are wrapped into the same class.

Messages and details are redacted before they reach app code: JWTs, Bearer
credentials, URL query strings and token-like path segments. In development a
caller mistake also prints one warning naming the fix.

addSigningErrorListener now receives every DocuSignError exactly once,
caller mistakes included, instead of wrapping the native onSigningError event.
The SDK raises errors from several domains, and the module forwarded only
the bare NSError code, which is ambiguous without its domain. Runtime failures
now travel as DocuSignFailure with domain, code, message and
NSUnderlyingErrorKey, and settle through one module helper that resolves them
with the payload and emits onSigningError once.

Login failures keep the userinfo check's HTTP status and DocuSign's error body
instead of flattening them into a string, which is what separates an expired
token from a valid token the SDK still refuses.

SDK errors delivered through the cancel notification now fail like every
other failure rather than resolving status "error", matching Android.

Two caller mistakes get their own codes (signing_in_progress,
invalid_signing_url), and an unreachable initialize failure reports
initialize_failed instead of not_initialized.

The presenter lookup moves onto the main thread, where UIKit requires it, and
a missing presenter settles the promise once instead of completing the slot
and throwing.
The module forwarded only exception.message, although DSException exposes
getErrorCode() and getErrorMsg() and DSRestException exposes the HTTP status.
Runtime failures now travel as DocuSignFailure with the exception class, the
SDK error code, the cause and any HTTP details, and settle through one module
helper that resolves them with the payload and emits onSigningError once.

The recipient-view mint read DocuSign's error body, threw only the status
line, and the fetch fallback then discarded even that. The body is parsed into
DocuSignHttpException and attached to the failure if the fallback also fails,
so a recipient that does not match the envelope is named instead of lost.

Login failures keep the userinfo check's status and error body. Two caller
mistakes get their own codes (signing_in_progress, invalid_signing_url), and a
missing foreground Activity rejects presentation_failed.
docs/ERROR_HANDLING.md covers the error model, translated copy chosen by
reason, a retry policy, reporting to Amplitude, New Relic and Sentry, the
queries to read those errors in production, and worked examples.

Its code lives in examples/error-handling and is type-checked in the lint job
against the real SDKs, which are devDependencies only and stay out of the
tarball. scripts/check-doc-examples.js fails CI when the guide's copies drift
from those files.

The test job now runs with coverage so the thresholds are enforced. README
and CHANGELOG describe the new contract.
The URL pattern also captured the punctuation that ends a sentence, so a
token path segment followed by a period, comma or closing parenthesis failed
the anchored token test and reached the message untouched. Trailing
punctuation is now peeled off before redacting and put back after.

The guide stated that the access token and signing URL never appear in any
field. The package never writes them itself, but redacting SDK text is
pattern-based, so the guide now says exactly what is removed and points at
toAttributes(), which carries no message text, as the safest payload.

Android initialize failures rejected with a message alone although they are
runtime failures of the SDK. They now resolve with the exception's details
like login and signing failures do.

Android reported the immediate exception cause as the underlying error. Java
wraps transport failures, so the SocketTimeoutException that explains a
timeout can sit several levels down and the failure was classified unknown.
The underlying error is now the bounded, cycle-safe root cause. iOS keeps the
immediate NSUnderlyingErrorKey, whose deeper CFNetwork error would lose the
network classification.
@IronTony IronTony self-assigned this Sep 13, 2026
@IronTony IronTony added the enhancement New feature or request label Sep 13, 2026
@IronTony
IronTony merged commit a3d6c95 into main Sep 13, 2026
6 checks passed
@IronTony IronTony mentioned this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant