apix 3.0.0
apix: ^3.0.0 copied to clipboard
Production-ready Flutter/Dart API client with auth refresh queue, exponential retry, smart caching and error tracking (Sentry-ready). Powered by Dio.
3.0.0 #
This release is about the cache. Two of its promises were not kept: cacheFirst
did not refresh in the background despite saying so, and the TTL was only as
strong as whichever CacheStorage you plugged in. Both are now true. Error
tracking also stops flattening every failure into one DioException.
Migration at a glance #
| If you… | Then… |
|---|---|
use CacheStrategy.cacheFirst |
responses may now be older than defaultTtl. Check response.isStale, or move to networkFirst where freshness matters |
call CacheRequestExtension.isFromCache(response) |
replace with response.isFromCache |
implement CacheStorage yourself |
delete the expiry filter from get — keep it in has |
inspect the argument of ErrorTrackingConfig.onError |
it is the typed ApiException now, not a DioException. e is DioException stops matching silently — use (e as ApiException).originalError |
catch on ApiException around a cacheOnly call |
you will now actually catch CacheException; before, it slipped through |
catch on HttpException for 4xx/5xx |
still works — ClientException / ServerException are subtypes |
use SentrySetup with filterNetworkNoise |
your 5xx start reaching Sentry. Expect more events, not fewer — they were being dropped |
use nothing but networkFirst (the default) |
nothing to do |
⚠️ Breaking behavior #
CacheStrategy.cacheFirstnow serves stale data — it does what it always documented: serve the cache immediately, refresh behind (stale-while-revalidate). An expired entry is returned, flaggedisStale, while one background request renews it.- Network volume is unchanged: a fresh entry costs nothing, a stale one costs exactly one request — asserted by a test, not assumed. Only the waiting disappears.
- The risk to check: a response may now be older than
defaultTtl. Where that is unacceptable — an amount, a balance, an authorisation — usenetworkFirst, or surfaceresponse.isStale. - Other strategies are unaffected.
Breaking #
-
ErrorTrackingConfig.onErrorreceives the typedApiException, not the rawDioException. Trackers group by runtime type, so a 500, a 404 and a timeout used to land in a single issue titledDioException. Nothing fails to compile;e is DioExceptionjust stops matching — reach the original through(e as ApiException).originalError. -
CacheRequestExtension.isFromCache(response)removed — useresponse.isFromCache. The old form was a static on an extension ofRequestOptionstaking aResponse, so autocomplete never surfaced it. -
CacheStorage.getmust no longer filter on expiry — only affects custom implementations. It returns entries expired or not;nullmeans absent. The interceptor owns the TTL, so a backend can no longer weaken it by forgetting to filter.haskeeps filtering.
Added #
-
response.isFromCache/response.isStale—isStaleis true wherever apix knowingly returns expired data:cacheFirstrevalidating, and the offline fallback ofnetworkFirst/httpCacheAware. On an amount, a balance or a status, surface it. A304is not stale. The underlying keys are exported asfromCacheKey/fromCacheStaleKey. -
FileCacheStorage— a cache that survives restarts, with no new dependency: one JSON file per entry, in a directory you supply.- Bounded by default (
maxEntries: 200) — a process cache dies with the app, a disk cache does not. Expired entries are evicted first.maxEntries: nullopts out. - Reads never throw (a corrupt file is a miss, and is discarded); writes are atomic; only its own files are touched.
- ⚠️ Entries are stored in clear text — never point it at credentials, tokens, personal data or amounts.
- Bounded by default (
Fixed #
-
ClientExceptionandServerExceptionwere never thrown —ErrorMapperspecialised only 401/403/404 and mapped everything else to a bareHttpException, leaving both clauses dead at every call site despite the documented hierarchy. Unspecialised 4xx now map toClientException, 5xx toServerException; other statuses stayHttpException. Not breaking — both areHttpExceptionsubtypes. -
ApiX errors were discarded by ApiX's own Sentry filter —
SentryException.typeis a bare class name, so the noise filter could not tell apix'sHttpExceptionfromdart:io's and dropped every 5xx. Classification is now by type hierarchy. Name matching is unchanged for everything else. -
CacheExceptionwas not anApiException— acacheOnlymiss escaped the typed-error contract entirely, becauseRequestInterceptorHandler.rejectskips the following error interceptors. -
cacheOnlyserved expired entries — it now rejects them, distinguishing a miss from an expiry. -
Cache eviction ignored expiry —
InMemoryCacheStoragecould drop a fresh entry while keeping a stale one.
2.3.0 #
Changed #
- ⚠️ BREAKING BEHAVIOR — Retry is now HTTP-method-aware;
POSTandPATCHare no longer retried by default- Automatic retry previously replayed any request whose status code matched
retryStatusCodes, ignoring the HTTP method. A5xxreturned after the server had already committed (e.g. a gateway502/504timeout following a payment) would replay a non-idempotent request and produce a duplicate (double charge / double top-up). RetryInterceptornow retries only requests whose method is in the newRetryConfig.retryableMethods, which defaults to the idempotent methods per RFC 7231 §4.2.2:{GET, HEAD, OPTIONS, TRACE, PUT, DELETE}. Method matching is case-insensitive.- Migration — if you relied on
POST/PATCHbeing retried, either widen the set globally withRetryConfig(retryableMethods: {...'POST'}), or opt in per request (recommended) withRequestOptions.forceRetry()— see below. - Unchanged: no retry on a no-response network error (
statusCode == null),Retry-Afterhandling, and the per-requestdisableRetry()opt-out (still takes precedence).
- Automatic retry previously replayed any request whose status code matched
Added #
-
RetryConfig.retryableMethods—Set<String>of upper-case HTTP methods eligible for retry (default = idempotent methods)- Fully configurable: remove a method a backend mishandles, or add one you know is safe.
-
RequestOptions.forceRetry()— per-request opt-in to retry a non-idempotent method- Overrides the method guard only — for a request that is provably safe to replay, typically a
POST/PATCHprotected by anIdempotency-Key. - Never overrides the no-response network guard, the status-code guard, or
maxAttempts;disableRetry()still wins if both are set. - Symmetric counterpart of
disableRetry(); backed by the exportedforceRetryKeyextra.
- Overrides the method guard only — for a request that is provably safe to replay, typically a
-
Dio
Options,CancelTokenandResponsere-exported from theapixbarrel — no more directpackage:dioimport for common calls (Options(extra: {noRetryKey: true}),cancelToken:, typing a returnedResponse<T>, ...)
Fixed #
- dio 5.10.0 compatibility across the declared
>=5.4.0 <7.0.0range — dio 5.10.0 introduced theDioExceptionType.transformTimeoutenum value (breaking the exhaustive exception-mapping switches) and an optional parameter onErrorInterceptorHandler.reject. Exception mapping now routestransformTimeout— and any futureDioExceptionType— through its default branch (ErrorMapperInterceptormaps it to a genericApiException;AuthInterceptortreats it as a non-network failure), so apix builds on both the floor and the latest of its declared dio range.
2.2.0 #
Added #
SentrySetupOptions.configureOptions— Escape hatch forSentryFlutterOptionsnot exposed by apix- Callback
void Function(SentryFlutterOptions)invoked last duringSentryFlutter.init, after every apix default - Lets consumers enable Sentry options introduced in newer SDK versions without waiting for an apix release (e.g.
enableTombstoneinsentry_flutter9.14+,enableAppHangTrackingV2, replay tuning) - Can override anything, including
beforeSend/beforeSendTransaction— for composition that preserves apix's network-noise filter, prefercustomBeforeSend/customBeforeSendTransaction - Exceptions thrown in the callback are swallowed in release builds and rethrown in debug, to avoid breaking app startup on a typo
- Callback
2.1.0 #
Fixed #
-
*AndDecode/*AndParse— Parsing failures now surface as typedApiException(critical)- New
ParsingException(extendsApiException) thrown whenfromJsonor a customparsercallback throws (e.g. truncated JSON, type mismatch) - Closes a gap in the 2.0.0 contract:
on ApiException catchnow catches every client-side parse failure originalErrorandstackTraceare preserved- User-thrown
ApiExceptionfrom insidefromJson/parseris rethrown unchanged (no double-wrap)
- New
-
AuthInterceptor— Network blip no longer logs the user out (major)- When the refresh request fails with a connection / timeout error, the original request is rejected with
NetworkException(typed:ConnectionException,TimeoutException) onAuthFailureis not invoked on network failures- Real auth failures (401/403 from the refresh endpoint) still produce
AuthExceptionand callonAuthFailure(regression preserved)
- When the refresh request fails with a connection / timeout error, the original request is rejected with
-
AuthInterceptor— Token provider failures now typed (moderate)- New
TokenProviderException(operation: read | write | clear)(extendsApiException) - Wraps errors thrown by
getAccessToken,getRefreshToken, and the user-suppliedonTokenRefreshedcallback - Surfaces directly to callers:
on TokenProviderException catchdistinguishes credential storage issues from network/HTTP errors
- New
-
AuthExceptionnow preserves the underlying causeAuthException(message, originalError: ..., stackTrace: ...)— typed cause flows through to the caller viaoriginalError
Added #
-
RetryConfig.respectRetryAfter— Honor the server'sRetry-Afterheader on retryable responses (defaulttrue)- Parses both delta-seconds (
"60") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT") formats per RFC 7231 §7.1.3 - Capped at
RetryConfig.maxDelayMs; falls back to exponential backoff if the header is absent or malformed - Public
RetryInterceptor.parseRetryAfter(value, {now})exposed for advanced use and testing
- Parses both delta-seconds (
-
ApiClientConfig.strictContentType— Detect captive portals / wrong Content-Type (defaultfalse)- When
true,*AndDecodemethods verify the response'sContent-Typestarts withapplication/json - Throws
UnexpectedContentTypeException(extendsApiException) on mismatch — exposesexpectedContentTypeandactualContentTypefields *AndParsemethods are unaffected (they accept any payload type by design)
- When
-
ApiClientConfig.responseValidator— Hook for legacy APIs that signal business errors via HTTP 200ResponseValidatortypedef:ApiException? Function(Response)- Returning a non-null exception fails the request with that typed exception; returning
nulllets the response pass through - Only fires on 2xx responses (4xx/5xx still go through
ErrorMapperInterceptor) - Preserves the exact subclass returned (e.g. a custom
BusinessException)
Changed #
*AndDecodemethods now use<dynamic>internally with explicit_requireDatavalidation (instead of relying on Dio's eager generic cast)- Eliminates confusing
TypeErroron non-JSON responses; replaced by clearApiExceptionmessages _requireDatanow also throwsApiExceptionwhen the body is non-null but not aMap<String, dynamic>
- Eliminates confusing
2.0.0 #
Breaking #
ApiClientmethods now throwApiExceptioninstead ofDioException- All HTTP methods (
get,post,put,delete,patch) and typed variants unwrapDioExceptionautomatically - Code using
on DioException catchonApiClientmethods must migrate toon ApiException catch(or subtypes) client.dio(raw Dio access) still throwsDioException— onlyApiClientmethods are affectedgetResult()handles bothApiExceptionandDioException(fallback for raw Dio usage)
- All HTTP methods (
Fixed #
-
AuthInterceptor— Refresh request isolation (critical)- Refresh requests no longer inject the expired access token in the Authorization header
- Refresh requests that return 401 no longer cause a deadlock (recursive refresh loop)
- Auth-retried requests that fail again with 401 no longer trigger infinite refresh-retry loops
-
ApiClientmethods now throw typedApiExceptiondirectly (critical)- All HTTP methods (
get,post,put,delete,patch) unwrapDioExceptionautomatically on ClientException catch,on UnauthorizedException catch, etc. now work as expected- No need to catch
DioExceptionand extract.errormanually getResult()also works correctly with bothApiExceptionandDioException(fallback for raw Dio usage)
- All HTTP methods (
-
CacheInterceptor— Cache key generation (critical)- Fixed double-encoding of query parameters in cache keys
invalidateUrl()now resolves relative URLs against the client's base URL
-
CacheInterceptor— Deduplicated requests (major)- Deduplicated requests now use the main Dio instance (with auth, logging, etc.) instead of a bare Dio that lost all interceptors
-
ApiClient— Null safety on response body (major)*AndDecodemethods now throwApiExceptioninstead ofTypeErroron null response body (e.g. 204 No Content)_extractDatanow throwsApiExceptioninstead ofTypeErroron non-Map responses
-
ErrorMapperInterceptor— Nested error message extraction (major)- Now extracts messages from nested error objects:
{ "error": { "message": "..." } } - Supports common API formats:
error.message,error.detail,error.description captureStatusCodesfilter now applies consistently inonError(previously only filtered inonResponse)
- Now extracts messages from nested error objects:
-
MetricsInterceptor— Request ID collisions (moderate)- Uses monotonic counter instead of
_inFlight.lengthfor unique IDs - Adds orphan cleanup for entries older than 5 minutes
- Uses monotonic counter instead of
-
SecureStorageService— Corruption blast radius (moderate)read()andcontainsKey()now delete only the corrupted key instead of callingdeleteAll()
-
Interceptor resilience (moderate)
AuthInterceptor,RetryInterceptor,CacheInterceptornow wrap asynconRequest/onError/onResponsein try/catch to prevent silent request hangs on unexpected exceptions
-
Sentry noise filter (minor)
- No longer accidentally filters out ApiX's own
TimeoutException,HttpException,ClientException
- No longer accidentally filters out ApiX's own
Added #
-
AuthConfig.onAuthFailure— Centralized callback when token refresh fails- Called exactly once per refresh attempt (even with concurrent requests queued)
- Receives the error object for diagnostic:
onAuthFailure: (tokenProvider, error) async { ... } - Use to clear tokens, redirect to login, or log the failure reason
-
RetryConfig.maxDelayMs— Maximum delay cap for exponential backoff- Defaults to 30000ms (30 seconds)
- Prevents overflow on high attempt counts
-
InMemoryCacheStorage.maxEntries— Optional size limit with FIFO eviction -
CacheEntry.tryFromJson()— Null-safe factory for corrupted storage data
Changed #
AuthExceptionnow extendsUnauthorizedException(wasApiException)- Catchable with
on UnauthorizedException catchalongside normal 401 errors
- Catchable with
OnAuthFailureCallbacksignature:(TokenProvider, Object? error)— includes the failure reason- Empty tokens (
"") are now ignored inonRequestand_performSimplifiedRefresh
1.4.0 #
Added #
-
ApiClientConfig.dataKey- Configurable key for envelope unwrapping (default:'data')- Used by all
*Datamethods to extract payload fromresponse.data[dataKey] - Customizable per client:
ApiClientConfig(baseUrl: '...', dataKey: 'result')
- Used by all
-
Data methods (envelope unwrapping) - Extract and format
response.data[dataKey]for envelope APIs- GET single:
getAndDecodeData,getAndDecodeDataOrNull,getAndParseData,getAndParseDataOrNull - GET list:
getListAndDecodeData,getListAndDecodeDataOrNull,getListAndDecodeDataOrEmpty,getListAndParseData,getListAndParseDataOrNull,getListAndParseDataOrEmpty - POST single:
postAndDecodeData,postAndDecodeDataOrNull,postAndParseData,postAndParseDataOrNull - POST list:
postListAndDecodeData,postListAndDecodeDataOrNull,postListAndDecodeDataOrEmpty,postListAndParseData,postListAndParseDataOrNull,postListAndParseDataOrEmpty
- GET single:
Changed #
ApiClienttyped response methods redesigned - 3 clear levels of response handling:- Standard:
get,post,put,delete,patch→ rawResponse<T> - Parse/Decode:
{verb}AndParse,{verb}AndDecode→ formatresponse.data(non-nullable, all verbs) - Data:
{verb}And{Parse|Decode}Data→ unwrap envelope then format (GET & POST only, with OrNull/List variants)
- Standard:
Removed #
getAndParseOrNull,getAndDecodeOrNull- Replaced bygetAndParseDataOrNull,getAndDecodeDataOrNullpostAndParseOrNull,postAndDecodeOrNull- Replaced bypostAndParseDataOrNull,postAndDecodeDataOrNullgetListAndDecode,getListAndParse- Replaced bygetListAndDecodeData,getListAndParseDatagetListAndDecodeOrNull,getListAndDecodeOrEmpty- Replaced bygetListAndDecodeDataOrNull,getListAndDecodeDataOrEmptygetListAndParseOrNull,getListAndParseOrEmpty- Replaced bygetListAndParseDataOrNull,getListAndParseDataOrEmpty
1.3.0 #
Added #
-
SecureStorageService.withBiometrics()- Factory constructor for biometric-protected storage- iOS: Face ID / Touch ID via
userPresenceaccess control flag - Android: Biometric-backed encryption via
AndroidOptions.biometric()(API 28+) - Customizable prompt titles for Android
- iOS: Face ID / Touch ID via
-
SentrySetup.addBreadcrumbFromMap()- Helper method forErrorTrackingConfig.onBreadcrumb- Simplifies error tracking configuration to a single line
- Example:
errorTrackingConfig: ErrorTrackingConfig(onError: SentrySetup.captureException, onBreadcrumb: SentrySetup.addBreadcrumbFromMap)
-
Resultfunctional methods - Enhanced Result type with Either-like operationsgetOrElse(defaultValue)- Returns value or default on failureflatMap(transform)/flatMapAsync- Chains Result-returning operationsmapError(transform)- Transforms the error typerecover(fallback)- Recovers from failure with fallback value
-
ApiClientflexible parsing methods - Support for any response type, not just JSONgetAndParse(path, parser)- Parse any response type (int, String, DateTime, etc.)putAndParse,patchAndParse- PUT/PATCH variants- Note: OrNull and List variants were redesigned in 1.4.0 as Data methods with envelope unwrapping
Fixed #
SecureStorageService- Auto-clear storage on bad padding exception- Handles corrupted encrypted data (e.g., after app reinstall or key rotation)
- Affected methods:
read(),readAll(),containsKey() - Returns safe defaults (
null,{},false) instead of throwing
1.2.0 #
Added #
ErrorMapperInterceptor- Automatically transformsDioExceptioninto typedApiExceptionsubclasses- Timeout errors →
TimeoutException - Connection errors →
ConnectionException - HTTP 401 →
UnauthorizedException - HTTP 403 →
ForbiddenException - HTTP 404 →
NotFoundException - Other HTTP errors →
HttpException - Message extracted from response body (
message,error,detail,error_description) - Added automatically to all clients created via
ApiClientFactory
- Timeout errors →
Changed #
- Dependencies updated for latest versions compatibility:
dio:>=5.4.0 <7.0.0(was>=5.0.0)sentry_flutter:>=9.0.0 <10.0.0(was>=8.0.0)flutter_secure_storage:>=10.0.0 <11.0.0(was>=9.0.0)
SecureStorageService- Uses new secure defaults (RSA OAEP + AES-GCM) on AndroidSentrySetup- Updated for sentry_flutter 9.x API compatibility
1.1.0 #
Added #
authConfigparameter inApiClientFactory.create- Configure authentication directlyretryConfigparameter inApiClientFactory.create- Configure retry logic directlycacheConfigparameter inApiClientFactory.create- Configure caching directlyloggerConfigparameter inApiClientFactory.create- Configure logging directlyerrorTrackingConfigparameter inApiClientFactory.create- Configure error tracking directly (Sentry, Crashlytics, etc.)metricsConfigparameter inApiClientFactory.create- Configure request metrics directly
Changed #
- Renamed
captureException→onError,addBreadcrumb→onBreadcrumb - Updated README documentation to match actual API signatures
1.0.0 #
🎉 First Stable Release #
ApiX is now production-ready with a complete feature set for Flutter/Dart API clients.
Features #
- Core API Client - Dio-powered client with configurable timeouts, headers, and interceptors
- Authentication - TokenProvider interface with refresh token queue and automatic retry
- Secure Token Storage - Built-in SecureTokenProvider with flutter_secure_storage
- Retry Logic - Exponential backoff with configurable status codes and max attempts
- Smart Caching - CacheFirst, NetworkFirst, and HTTP-aware strategies with TTL
- Observability - Logger, Metrics, and Sentry interceptors for debugging and monitoring
- Result Pattern - Functional error handling with Success/Failure types
- Exception Hierarchy - NetworkException, HttpException, and typed client/server errors
Highlights #
- 401 tests passing
- Full API documentation
- Example app included
- CI/CD with GitHub Actions
0.3.0 #
Added #
-
SecureStorageService: Wrapper for
flutter_secure_storagewith simplified APIwrite(key, value),read(key),delete(key),deleteAll()containsKey(key),readAll()- Default secure options for Android and iOS
- Injectable
FlutterSecureStoragefor custom configuration
-
SecureTokenProvider: Ready-to-use
TokenProviderimplementation- Zero-boilerplate token management
- Configurable storage keys (
accessTokenKey,refreshTokenKey) - Exposed
storagegetter for secondary usage (Firebase tokens, API keys) - Works with
SecureStorageServicevia composition
-
Simplified Token Refresh: New
refreshEndpointapproach inAuthConfigrefreshEndpoint: Relative URL for automatic refresh callsrefreshHeaders: Optional custom headers for refresh requestonTokenRefreshed: Callback with rawResponsefor parsingrefreshTokenBodyKey: Configurable body key (default: 'refresh_token')hasSimplifiedRefresh: Getter to check if simplified flow is configured
Changed #
AuthInterceptornow supports both simplified and legacy refresh flows- Simplified flow takes priority when
refreshEndpointis configured
Backward Compatibility #
- Existing
onRefreshcallback still works as before - All new fields are optional with sensible defaults
0.0.1 #
- Initial release with core features:
- ApiClient with configurable timeouts and interceptors
- TokenProvider interface for authentication
- AuthInterceptor with refresh token queue
- RetryInterceptor with exponential backoff
- CacheInterceptor with multiple strategies
- LoggerInterceptor for debugging
- ErrorTrackingInterceptor for error reporting
- Result pattern for functional error handling
