Optimize cosmos gateway header handling#28
Conversation
- Replace String[] parallel arrays with Map<String,String> in StoreResponse for O(1) header lookups instead of O(n) linear scans - Eliminate redundant array-to-Map copies in RxDocumentServiceResponse and StoreClient (was: Map→arrays→Map triple materialization) - Fix HttpHeaders.toLowerCaseMap() to use entrySet() instead of keySet()+get() - Change headersNeedToBeEscaped from List to Set for O(1) contains() checks - Simplify StoreResult.setRequestCharge() to use map-based setHeaderValue() - Update StoreResponseValidator tests to use map-based getHeaderValue() API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
HTTP/2 header names are already lowercase per RFC 7540 §8.1.2. Add keysAlreadyLowerCased flag to HttpHeaders to skip toLowerCase() in set() and getHeader() for HTTP/2 responses (~25 scans saved per response). In RxGatewayStoreModel, use toMap() instead of toLowerCaseMap() when keys are already lowercase to avoid redundant HashMap allocation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
a4450b1 to
0effd86
Compare
There was a problem hiding this comment.
Warning
⚠️ Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
releaseassets.githubusercontent.com
To allow these domains, add them to the network.allowed list in your workflow frontmatter:
network:
allowed:
- defaults
- "releaseassets.githubusercontent.com"See Network Configuration for more information.
Generated by sdkReviewAgent for issue #28
|
|
||
| private HttpHeader getHeader(String headerName) { | ||
| final String headerKey = headerName.toLowerCase(Locale.ROOT); | ||
| final String headerKey = keysAlreadyLowerCased ? headerName : headerName.toLowerCase(Locale.ROOT); |
There was a problem hiding this comment.
🟡 Recommendation · Correctness: getHeader() optimization silently breaks case-insensitive lookup
When keysAlreadyLowerCased=true, the lookup key is used as-is (no toLowerCase). But stored keys are lowercase because HTTP/2 Netty frames provide them in lowercase, while callers of value()/values() may still pass mixed-case names like "Content-Type". Those lookups will silently return null, despite the header being present.
Example:
// keysAlreadyLowerCased=true (H2 path)
headers.set("content-type", "application/json"); // stored as "content-type"
headers.value("Content-Type"); // → null (bug: key="Content-Type" ≠ "content-type")The optimization is sound for set() (callers provide lowercase keys from the H2 frame), but not for lookups — callers have no reason to know they must pass lowercase names. The current call path in RxGatewayStoreModel doesn't trigger this (it only calls toMap()/toLowerCaseMap()), so it's latent rather than a current regression, but it breaks the class's implicit case-insensitive contract.
Fix: Always normalize the lookup key, regardless of the flag:
private HttpHeader getHeader(String headerName) {
return headers.get(headerName.toLowerCase(Locale.ROOT));
}The keysAlreadyLowerCased flag is only appropriate as a write-path optimization in set().
| String requestChargeKey = HttpConstants.HttpHeaders.REQUEST_CHARGE.toLowerCase(Locale.ROOT); | ||
| double currentRequestCharge = this.getRequestCharge(); | ||
| double newRequestCharge = currentRequestCharge + additionalRequestCharge; | ||
| headers.put(requestChargeKey, String.valueOf(newRequestCharge)); |
There was a problem hiding this comment.
🟡 Recommendation · Correctness: withRemappedStatusCode now unconditionally inserts REQUEST_CHARGE
The previous implementation only updated REQUEST_CHARGE if it already existed among responseHeaderNames (it iterated and updated in-place). The new code always inserts the key:
headers.put(requestChargeKey, String.valueOf(newRequestCharge));If a StoreResponse was constructed without a REQUEST_CHARGE header (e.g. a synthetic or fault-injected response), the original withRemappedStatusCode returned a response still lacking the header. Now it returns a response with REQUEST_CHARGE = 0.0 + additionalRequestCharge.
In production Cosmos DB responses this header is always present, so this is unlikely to cause a visible regression. However, it's a silent behavioral change that could affect tests that deliberately construct responses without REQUEST_CHARGE. Consider mirroring the original guard:
if (headers.containsKey(requestChargeKey) || additionalRequestCharge != 0) {
headers.put(requestChargeKey, String.valueOf(newRequestCharge));
}| // For HTTP/2, header keys are already lowercase per RFC 7540 §8.1.2 and were stored | ||
| // as-is (no toLowerCase) in HttpHeaders. Use toMap() directly to avoid redundant work. | ||
| Map<String, String> headerMap = HttpUtils.unescape( | ||
| headers.areKeysLowerCased() ? headers.toMap() : headers.toLowerCaseMap()); |
There was a problem hiding this comment.
🟢 Suggestion · Code Quality: The toMap() vs toLowerCaseMap() conditional provides no performance benefit here
The comment says "avoid redundant work" by calling toMap() for H2. But toLowerCaseMap() already uses the pre-lowercased map key (entry.getKey()) — it does not call toLowerCase() on each key at this step:
// toLowerCaseMap() — no toLowerCase() called on keys here
for (Map.Entry(String, HttpHeader) entry : headers.entrySet()) {
result.put(entry.getKey(), entry.getValue().value()); // key already lowercase
}The actual savings from keysAlreadyLowerCased=true happen earlier, in HttpHeaders.set() during header population in ReactorNettyClient.headers(). The conditional on line 236 is correct for correctness (toMap() preserves original case for H1, which would be wrong), but the performance framing in the comment is misleading.
Consider updating the comment to accurately reflect where the optimization occurs, so future readers don't draw incorrect conclusions about toLowerCaseMap().
This reverts commit 0effd86.
Only update REQUEST_CHARGE header if it already exists in the response, matching the previous array-based implementation that iterated and updated in-place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change HttpTransportSerializer.unwrapToStoreResponse to accept Map<String,String> instead of HttpHeaders. This eliminates: - 25 HttpHeader wrapper object allocations per response - 2 intermediate HashMap allocations per response - 50 redundant toLowerCase() scans for HTTP/2 Add HttpResponse.headersAsLowerCaseMap() with protocol-aware override in ReactorNettyHttpResponse: for HTTP/2, builds Map directly from Netty headers without toLowerCase() (keys already lowercase per RFC 7540). For HTTP/1.1, lowercases each key during map construction. ThinClientStoreModel benefits equally: previously wrapped its RNTBD asMap() result in HttpHeaders just to unwrap it back to Map in super. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Warning
⚠️ Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
releaseassets.githubusercontent.com
To allow these domains, add them to the network.allowed list in your workflow frontmatter:
network:
allowed:
- defaults
- "releaseassets.githubusercontent.com"See Network Configuration for more information.
Generated by sdkReviewAgent for issue #28
| public String[] getResponseHeaderValues() { | ||
| return responseHeaderValues; | ||
| public Map<String, String> getResponseHeaders() { | ||
| return responseHeaders; |
There was a problem hiding this comment.
🟡 Recommendation · Code Quality: getResponseHeaders() exposes the live mutable internal map
responseHeaders is a final HashMap, but final only prevents reassignment — the map's contents remain mutable. Returning the live reference means any caller can silently modify StoreResponse internal state:
storeResponse.getResponseHeaders().put("x-ms-foo", "injected"); // modifies StoreResponse internalsAll current production callers are well-behaved (they either take a defensive copy or read-only), but this is a latent footgun. Hardening with Collections.unmodifiableMap is zero-cost at runtime for read paths and doesn't break anything — setHeaderValue() mutates this.responseHeaders directly, not through the returned reference:
public Map(String, String) getResponseHeaders() {
return Collections.unmodifiableMap(responseHeaders);
}| @@ -310,23 +286,20 @@ public void setFaultInjectionRuleEvaluationResults(List<String> results) { | |||
|
|
|||
| public StoreResponse withRemappedStatusCode(int newStatusCode, double additionalRequestCharge) { | |||
There was a problem hiding this comment.
🟡 Recommendation · Test Coverage: withRemappedStatusCode has no unit test
This method was substantially refactored (parallel arrays → Map, new keysAlreadyLowerCased=true constructor path, containsKey guard for REQUEST_CHARGE), but StoreResponseTest covers none of it. Consider adding tests for:
- Status code is updated — returned response has
newStatusCode, not the original - REQUEST_CHARGE is accumulated — if header is present,
additionalRequestChargeis added - REQUEST_CHARGE absent — if header was never set, no charge header is introduced (guards against regression of the prior unconditional-insert bug)
- Headers are copied, not aliased — mutating the original
StoreResponseheaders after callingwithRemappedStatusCodedoes not affect the returned response
Example skeleton:
`@Test`(groups = { "unit" })
public void withRemappedStatusCode_updatesChargeAndStatus() {
HashMap(String, String) headers = new HashMap<>();
headers.put(HttpConstants.HttpHeaders.REQUEST_CHARGE.toLowerCase(Locale.ROOT), "5.0");
StoreResponse original = new StoreResponse(null, 200, headers, null, 0);
StoreResponse remapped = original.withRemappedStatusCode(201, 2.0);
assertThat(remapped.getStatus()).isEqualTo(201);
assertThat(Double.parseDouble(remapped.getHeaderValue(HttpConstants.HttpHeaders.REQUEST_CHARGE))).isEqualTo(7.0);
// original is unmodified
assertThat(original.getStatus()).isEqualTo(200);
}There was a problem hiding this comment.
Warning
⚠️ Firewall blocked 2 domains
The following domains were blocked by the firewall during workflow execution:
centralus2.in.applicationinsights.azure.comdc.services.visualstudio.com
To allow these domains, add them to the network.allowed list in your workflow frontmatter:
network:
allowed:
- defaults
- "centralus2.in.applicationinsights.azure.com"
- "dc.services.visualstudio.com"See Network Configuration for more information.
Note
🔒 Integrity filtering filtered 1 item
Integrity filtering activated and filtered the following item during workflow execution.
This happens when a tool call accesses a resource that does not meet the required integrity or secrecy level of the workflow.
Generated by sdkReviewAgent for issue #28
| request, | ||
| response.getStatus().code(), | ||
| new HttpHeaders(response.getHeaders().asMap(request.getActivityId())), | ||
| HttpUtils.unescape(response.getHeaders().asMap(request.getActivityId())), |
There was a problem hiding this comment.
🔴 Blocking · Correctness: HttpUtils.unescape() always throws UnsupportedOperationException on the ImmutableMap returned by asMap()
response.getHeaders().asMap(activityId) returns a com.azure.cosmos.implementation.guava25.collect.ImmutableMap. This PR now passes that directly to HttpUtils.unescape():
// ThinClientStoreModel.java line 145
HttpUtils.unescape(response.getHeaders().asMap(request.getActivityId()))HttpUtils.unescape(Map) calls headers.computeIfPresent(...), and the vendored ImmutableMap overrides computeIfPresent to always throw unconditionally:
// guava25/collect/ImmutableMap.java:583-586
`@Deprecated`
`@Override`
public final V computeIfPresent(K key, BiFunction<...> remappingFunction) {
throw new UnsupportedOperationException(); // always, no key check
}So every call through ThinClientStoreModel.unwrapToStoreResponse that successfully decodes an RntbdResponse will throw UnsupportedOperationException before producing a StoreResponse. ThinClient mode is broken for all responses.
Before this PR, the old code wrapped in HttpHeaders first and then called unescape on the mutable map from toLowerCaseMap():
// Old (safe)
new HttpHeaders(response.getHeaders().asMap(request.getActivityId()))
// ... parent called: HttpUtils.unescape(headers.toLowerCaseMap()) ← new mutable HashMapFix: Wrap in a mutable HashMap before calling unescape:
HttpUtils.unescape(new HashMap<>(response.getHeaders().asMap(request.getActivityId()))),This preserves the original semantics and avoids the UnsupportedOperationException.
- Wrap ImmutableMap from RNTBD asMap() in HashMap before passing to HttpUtils.unescape() which calls computeIfPresent() (throws on ImmutableMap) - Return Collections.unmodifiableMap from StoreResponse.getResponseHeaders() to prevent callers from mutating internal state - Add unit tests for withRemappedStatusCode: verifies charge accumulation, status update, header preservation, no-charge-when-absent guard, and unmodifiable map contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add StoreResponse.withLowerCasedHeaders() factory that takes ownership of a map whose keys are already lowercase, skipping the defensive toLowerCasedMap() copy. Used by RxGatewayStoreModel where headers are already lowercased by HttpResponse.headersAsLowerCaseMap(). Per gateway response this eliminates 1 HashMap allocation and ~25 toLowerCase scans that were no-ops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Warning
⚠️ Firewall blocked 2 domains
The following domains were blocked by the firewall during workflow execution:
centralus2.in.applicationinsights.azure.comdc.services.visualstudio.com
To allow these domains, add them to the network.allowed list in your workflow frontmatter:
network:
allowed:
- defaults
- "centralus2.in.applicationinsights.azure.com"
- "dc.services.visualstudio.com"See Network Configuration for more information.
Note
🔒 Integrity filtering filtered 2 items
Integrity filtering activated and filtered the following items during workflow execution.
This happens when a tool call accesses a resource that does not meet the required integrity or secrecy level of the workflow.
- pr:#28 (
pull_request_read: Resource 'pr:#28' has lower integrity than agent requires. Agent would need to drop integrity tags [approved:all unapproved:all] to trust this resource.) - pr:#28 (
pull_request_read: Resource 'pr:#28' has lower integrity than agent requires. Agent would need to drop integrity tags [unapproved:all approved:all] to trust this resource.)
Generated by sdkReviewAgent for issue #28
| request, | ||
| response.getStatus().code(), | ||
| new HttpHeaders(response.getHeaders().asMap(request.getActivityId())), | ||
| HttpUtils.unescape(new HashMap<>(response.getHeaders().asMap(request.getActivityId()))), |
There was a problem hiding this comment.
🔴 Blocking · Correctness: Mixed-case RNTBD header keys bypass lowercase normalization
new HashMap<>(...) copies keys verbatim from the ImmutableMap returned by RntbdResponseHeaders.asMap(). That map is built using WFConstants.BackendHeaders constants, which include mixed-case values:
// WFConstants.BackendHeaders
public static final String GLOBAL_COMMITTED_LSN = "x-ms-global-Committed-lsn"; // uppercase 'C'The resulting HashMap therefore contains "x-ms-global-Committed-lsn" as a key. It is then passed to super.unwrapToStoreResponse() → StoreResponse.withLowerCasedHeaders(…, keysAlreadyLowerCased=true), which stores the key as-is, without normalisation.
Subsequent lookups call getHeaderValue(WFConstants.BackendHeaders.GLOBAL_COMMITTED_LSN), which normalises the argument to "x-ms-global-committed-lsn" (all-lowercase) via attribute.toLowerCase(Locale.ROOT). That key is absent from the map, so the lookup returns null and globalCommittedLSN is always −1 in ThinClient mode — silently breaking quorum-read consistency semantics.
Root cause: before this PR, new HttpHeaders(immutableMap) called set() for every entry, which performed name.toLowerCase(Locale.ROOT). The new new HashMap<>(...) copy preserves the original casing.
Fix — normalise during the copy:
Map(String, String) rawHeaders = response.getHeaders().asMap(request.getActivityId());
Map(String, String) loweredHeaders = new HashMap<>(rawHeaders.size());
rawHeaders.forEach((k, v) -> loweredHeaders.put(k.toLowerCase(Locale.ROOT), v));
StoreResponse storeResponse = super.unwrapToStoreResponse(
endpoint, request, response.getStatus().code(),
HttpUtils.unescape(loweredHeaders), payloadBuf);
No description provided.