Skip to content

Expose HTTP response headers on FMSException - #251

Open
buptliuhs wants to merge 1 commit into
intuit:developfrom
buptliuhs:feature/expose-response-headers-on-fmsexception
Open

Expose HTTP response headers on FMSException#251
buptliuhs wants to merge 1 commit into
intuit:developfrom
buptliuhs:feature/expose-response-headers-on-fmsexception

Conversation

@buptliuhs

Copy link
Copy Markdown

Problem

Some API failures are only distinguishable from a response header, and the SDK discards all of them.

QuickBooks Online returns errorCode=003200 with a byte-identical message for both an expired access token and a revoked one. The only body field that distinguishes them is fault.error[].detail, and that field is not always populated — in Production it comes back null for a revoked token while Sandbox returns "Token revoked". Support case: https://help.developer.intuit.com/s/case/500TR00001m9I8UYAU/v3-api-faulterrordetail-returned-as-null-in-production-but-populated-in-sandbox-for-401-errorcode003200

The WWW-Authenticate response header does carry the distinction reliably in both environments:

www-authenticate: Bearer realm="Intuit", error="invalid_token", error_description="Token expired"
www-authenticate: Bearer realm="Intuit", error="invalid_token", error_description="Token revoked"

This matters because the correct client behaviour is opposite in the two cases: an expired token should be refreshed and retried, whereas a revoked grant is unrecoverable and requires the user to re-authorize. A client that cannot tell them apart will either retry forever or discard a connection whose refresh token is still perfectly good.

Today an SDK caller cannot reach that header:

  • HTTPClientConnectionInterceptor.setResponseElements() copies only Content-Encoding, Content-Type and the status line off the HttpResponse.
  • ResponseElements has no generic header map, so everything else is gone before any exception is constructed.
  • FMSException therefore has nothing to expose.

The only workaround is to bypass DataService and re-issue the request with a raw HTTP client purely to read one header — for a condition the SDK's own exception hierarchy already models as AuthenticationException.

Worth noting HTTPURLConnectionInterceptor already logs the full header map (LOG.debug("Response headers:" + httpUrlConnection.getHeaderFields())) and then drops it on the next line.

Change

try {
    service.findById(customer);
} catch (AuthenticationException e) {
    String challenge = e.getResponseHeader("WWW-Authenticate");
    // Bearer realm="Intuit", error="invalid_token", error_description="Token expired"
}
  • ResponseElements holds all response headers in a case-insensitive map. Case-insensitivity is required rather than cosmetic: header names are case-insensitive per RFC 7230 and HTTP/2 lowercases them, so callers must not have to guess. Where a header repeats, the last value is retained — consistent with the existing use of HttpResponse#getLastHeader for the two headers already captured.
  • The three connection interceptors populate it: HTTPClientConnectionInterceptor, HTTPBatchClientConnectionInterceptor, and HTTPURLConnectionInterceptor (the last via getHeaderFields(), whose multi-value entries and null-keyed status line are normalised).
  • HandleResponseInterceptor attaches the headers to every FMSException it raises. Done once around the existing logic rather than at each of the 13 throw sites, so no path is missed and future ones are covered automatically. Batch requests are included, since IntuitBatchInterceptorProvider inherits the response chain and only swaps the request interceptor.
  • FMSException exposes getResponseHeaders() and getResponseHeader(name). The map accessor returns an empty map rather than null, and both tolerate a null name. All subclasses inherit this.

Compatibility

Additive only — new fields and methods, no signature or behaviour changes, no new dependencies, Java 8 compatible. Existing callers are unaffected.

Known limitation: repeated headers such as Set-Cookie keep only the last value. Map<String, List<String>> would preserve them, but single-value lookups are the use case here and a flat map matches the SDK's existing style. Happy to change this if you'd prefer.

Tests

Adds ResponseHeadersTest (9 tests, TestNG) — no credentials, no network:

  • header normalisation from both the Apache Header[] and HttpURLConnection getHeaderFields() sources
  • case-insensitive lookup (WWW-Authenticate and www-authenticate both resolve)
  • repeated header keeps the last value; null-keyed status line entry is dropped
  • enrichment on the fault path (AuthenticationException) and the status-code fallback path (InvalidTokenException)
  • null safety: empty map rather than null, getResponseHeader(null) returns null

It lands in com.intuit.ipp.interceptors, which testng-devkit.xml already includes by package, so no suite changes were needed.

Verified with mvn -pl ipp-v3-java-devkit test-compile surefire:test -Dtest=ResponseHeadersTest (9/9 passing) plus the adjacent self-contained interceptor and exception suites (35/35, no regressions).

Some API failures are only distinguishable from a response header. QuickBooks
Online returns errorCode 003200 with an identical message for both an expired
and a revoked access token, and fault.error[].detail is not always populated,
so the WWW-Authenticate challenge is what tells the two apart. The correct
client behaviour differs sharply: an expired token should be refreshed and
retried, while a revoked grant requires the user to re-authorize.

The SDK discarded that information. setResponseElements() copied only
Content-Encoding, Content-Type and the status line off the response, and
ResponseElements had no generic header map, so the header was gone before any
exception was constructed. Callers had to bypass DataService and issue raw HTTP
to read it.

Changes:

- ResponseElements holds all response headers in a case-insensitive map.
  Header names are case-insensitive per RFC 7230 and HTTP/2 lowercases them, so
  callers must not have to guess. Where a header repeats, the last value is
  retained, consistent with the existing use of HttpResponse#getLastHeader.
- The three connection interceptors populate it. HTTPURLConnectionInterceptor
  already logged getHeaderFields() and then dropped it.
- HandleResponseInterceptor attaches the headers to every FMSException it
  raises, done once around the existing logic rather than at each of the 13
  throw sites so no path is missed. Batch requests are covered too, since
  IntuitBatchInterceptorProvider inherits the response chain.
- FMSException exposes getResponseHeaders() and getResponseHeader(name).
  getResponseHeaders() returns an empty map rather than null, and both
  accessors tolerate a null name. All exception subclasses inherit this.

Additive only: no signature or behaviour changes, no new dependencies, Java 8
compatible.

Known limitation: repeated headers such as Set-Cookie keep only the last value.

Adds ResponseHeadersTest, covering header normalisation from both the Apache
and HttpURLConnection sources, enrichment on the fault and status-code paths,
and null safety. Requires no credentials or network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant