Skip to content

Remove startup blocking gate from OkHttp/URLSession interceptors and enforce initialize() contract at JS layer #22

@ivolz

Description

@ivolz

Summary

Remove the STARTUP_SYNC_TIME_WINDOW blocking gate from ApproovInterceptor (Android) and the equivalent [NSThread sleepForTimeInterval:] gate from the iOS NSURLSession interceptor, and replace it with a proper initialization contract enforced at the JS layer.


Background — The Gate Has Zero Purpose

The service layer exists solely to protect fetch() calls made from React Native JS code. Given that scope, the current 2,500 ms startup blocking gate in ApproovInterceptor.intercept() (Android) and its iOS equivalent serves no valid purpose. Here is the proof:

There are exactly two ways a JS fetch() can arrive at the interceptor:

Case 1 — Developer did it right (initialize() first):

JS: await ApproovService.initialize()
    → Java/ObjC constructor executes
    → Approov.initialize() called (local, synchronous — no network)
    → isInitialized = true  ← set before JS resumes
JS: fetch("https://api.example.com/...")
    → interceptor fires
    → isInitialized = true  ← gate NEVER triggers, completely inert

Case 2 — Developer did it wrong (fetch() before initialize()):

JS: fetch("https://api.example.com/...")  ← developer error
    → interceptor fires
    → isInitialized = false  ← gate triggers
    → OkHttp/URLSession thread parked for 2,500 ms
    → gate clears, request proceeds WITHOUT a valid token

In Case 1 the gate is inert. In Case 2 the gate silently hides a developer error and then lets the unprotected request through anyway. There is no Case 3.

Current Failure Mode on Hostile Networks

On networks that blackhole Approov attestation DNS (attest.api.approov.io), Approov.initialize() never completes, isInitialized is never set to true, and:

  • Every parallel startup fetch() parks an OkHttp/URLSession thread for 2,500 ms
  • The OkHttp thread pool (default: 5 threads) is exhausted
  • The app freezes on the splash screen with no visible error

This is a confirmed production failure (Samsung Galaxy A34, Android 16, home Wi-Fi with Approov domains blackholed). The fix is to remove the gate entirely.


Required Changes

1. Remove the blocking gate from the Android interceptor

In ApproovInterceptor.java, replace the STARTUP_SYNC_TIME_WINDOW sleep/wait block with an immediate fast-fail:

// REMOVE THIS:
if (!approovService.isInitialized()) {
    approovService.setEarliestNetworkRequestTime();
    // ... Thread.sleep / wait loop ...
}

// REPLACE WITH:
if (!approovService.isInitialized()) {
    throw new IOException("ApproovService.initialize() has not been called. " +
        "Ensure initialize() is awaited before making any fetch() calls.");
}

Remove STARTUP_SYNC_TIME_WINDOW, earliestNetworkRequestTime, and setEarliestNetworkRequestTime() from ApproovService.java.

2. Remove the equivalent gate from the iOS interceptor

Remove the [NSThread sleepForTimeInterval:] equivalent blocking path from the iOS NSURLSession swizzle interceptor. Apply the same fail-fast pattern.

3. Enforce await initialize() at the JS layer

Add enforcement mechanisms so the contract is self-enforcing rather than documentation-only. Implement the following (in priority order):


Option A — Promise Queue (Highest Priority — handles "forgot to await" gracefully)

Store the init promise at module level. The patched fetch() always awaits it internally:

let _initPromise = null;

export function initialize(...args) {
  _initPromise = NativeModules.ApproovService.initialize(...args);
  return _initPromise;
}

const _originalFetch = global.fetch;
global.fetch = async function(...args) {
  if (!_initPromise) {
    throw new Error(
      'ApproovService.initialize() must be called before fetch(). ' +
      'See https://approov.io/docs/react-native/quickstart'
    );
  }
  await _initPromise; // no-op if already resolved; awaits if still in flight
  return _originalFetch(...args);
};

Why: JS is single-threaded. Once _initPromise resolves, await _initPromise is instantaneous forever after. If the developer forgot to await, the fetch() call awaits for them — silently correct on good networks. If initialize() was never called, a clear, immediate error is thrown. Critically: on a network that blackholes Approov DNS, _initPromise hangs inside JS (not inside OkHttp), and can be raced with Promise.race() + a timeout by the developer.


Option B — React Provider Pattern

Provide an <ApproovProvider> component that renders children only after init resolves:

function ApproovProvider({ children, fallback = null }) {
  const [ready, setReady] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    ApproovService.initialize()
      .then(() => setReady(true))
      .catch(setError);
  }, []);

  if (error) return <ErrorScreen error={error} />;
  if (!ready) return fallback; // splash screen while initializing
  return children;
}

Since React will not render children until ready = true, no child component can call fetch() in a useEffect or event handler before initialization completes. This is the same pattern used by Firebase, Amplify, and Segment.


Option C — TypeScript API Shape

Make initialize() return the protected fetch reference, so the function physically does not exist before init:

// Developer must do:
const { fetch } = await ApproovService.initialize();
// fetch() as a symbol simply does not exist before this line

This is a breaking API change but provides the strongest compile-time guarantee.


Option D — ESLint Rule (Build-time enforcement)

Provide a custom ESLint rule that errors if fetch() is called in a scope that does not have await ApproovService.initialize() preceding it. Enforceable in CI.


Acceptance Criteria

  • STARTUP_SYNC_TIME_WINDOW constant removed from ApproovService.java
  • earliestNetworkRequestTime and setEarliestNetworkRequestTime() removed from Android
  • ApproovInterceptor.intercept() throws IOException immediately if isInitialized = false
  • Equivalent iOS NSURLSession gate removed and replaced with fast-fail
  • JS layer implements Option A (Promise queue) as the minimum viable enforcement
  • JS layer ships <ApproovProvider> (Option B) as the recommended React pattern
  • REFERENCE.md and TROUBLESHOOTING.md updated with the new contract
  • Existing tests updated; regression tests added for the fast-fail path
  • No existing integration that correctly calls await initialize() is broken

References

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions