Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core-http] use response status to determine whether body is stream #13192

Merged

Conversation

jeremymeng
Copy link
Contributor

@jeremymeng jeremymeng commented Jan 13, 2021

Our current code has the incorrect assumption that if any body mapper
of an operation spec is of Stream type then response body must be a
stream and httpRequest.streamResponseBody is set according to this
assumption. However, Storage issue #12997 shows that the default
response of Download Blob operation does not have a stream body with
the following operation spec:

  responses: {
    200: {
      bodyMapper: {
        serializedName: "parsedResponse",
        type: {
          name: "Stream"
        }
      },
      headersMapper: Mappers.BlobDownloadHeaders
    },
    ...
    default: {
      bodyMapper: Mappers.StorageError,
      headersMapper: Mappers.BlobDownloadHeaders
    }
  },

Treating response body as stream in the default case prevent us from
parsing the body which leads to missing code and message from the
error object.

This change fixes the issue by actually checking the body type of the
response corresponding to the response status code.

WebResource{Like}.streamResponseBody is deprecated
because a boolean value is not enough to determine whether a response
is streaming or not. A list of streaming response status code
streamResponseStatusCodes is introduced and used instead.

ServiceClient no longer sets the deprecated
streamResponsebody property based on incorrect assumption. However
we still respect streamResponseBody in case it's set by users.

Core v2 removes streamResponseBody.

Our current code has the incorrect assumption that if any body mapper
of an operation spec is of `Stream` type then response body must be a
stream and `httpRequest.streamResponseBody` is set according to this
assumption. However, Storage issue Azure#12997 shows that the default
response of Download Blob operation does not have a stream body with
the following operation spec:

```typescript
  responses: {
    200: {
      bodyMapper: {
        serializedName: "parsedResponse",
        type: {
          name: "Stream"
        }
      },
      headersMapper: Mappers.BlobDownloadHeaders
    },
    ...
    default: {
      bodyMapper: Mappers.StorageError,
      headersMapper: Mappers.BlobDownloadHeaders
    }
  },
```
Treating response body as stream in the default case prevent us from
parsing the body which leads to missing `code` and `message` from the
error object.

This change fixes the issue by actually checking the body type of the
response corresponding to the response status code.
const operationResponse: HttpOperationResponse = {
headers: headers,
request: httpRequest,
status: response.status,
readableStreamBody: httpRequest.streamResponseBody
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably also want to remove all usage of httpRequest.streamResponseBody? It's in public surface but I don't see other usages than checking for response body type and doing it incorrectly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly we should remove it from core-https at least.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at it again. It is used in browser xhrHttpClient to have different code paths to resolve the responses so I will keep it.

Copy link
Member

@xirzec xirzec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change seems good, but can you please port it over to core-https as well? 😄

const operationResponse: HttpOperationResponse = {
headers: headers,
request: httpRequest,
status: response.status,
readableStreamBody: httpRequest.streamResponseBody
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly we should remove it from core-https at least.

@@ -153,14 +154,20 @@ export abstract class FetchHttpClient implements HttpClient {
const response: CommonResponse = await this.fetch(httpRequest.url, requestInit);

const headers = parseHeaders(response.headers);

const isBodyReallyStream = isResponseBodyStream(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we come up with a better variable name here? Perhaps bodyIsStream ?

@@ -153,14 +154,20 @@ export abstract class FetchHttpClient implements HttpClient {
const response: CommonResponse = await this.fetch(httpRequest.url, requestInit);

const headers = parseHeaders(response.headers);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xirzec in core v2 this is in core-https. I don't think it deals with operation specs so we wouldn't be able to check the response type based on status code. Any suggestions?

EDITED: typo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh. So is the only way to detect the response shape based on what we think we know about the response from the operation spec? Seems like there should be a way to detect this without prior knowledge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the current way. Based on the types from the operation spec, we put the body into either readableStreamBody/blobBody, or bodyAsText property. I can think of some heuristics, for example,

  • treat as text if content type header value is one of the several that we would parse, or is plain text. otherwise treat as stream. For text cases we would store response.text() of the original response in bodyAsText so hopefully no matter how the body is transfer back we would have text at the end.

Anyway it feels like core v2 needs a different fix than porting this PR. I will log a separate issue to track it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logged #13235

Change log entry
@Azure Azure deleted a comment from azure-pipelines bot Jan 15, 2021
@jeremymeng
Copy link
Contributor Author

/azp run js - storage-blob - tests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

@jeremymeng
Copy link
Contributor Author

Storage failures look unrelated. Same failures previously in master.

because a boolean value is not enough to determine whether a response
is streaming or not. A list of streaming response status code
`streamResponseStatusCodes` is introduced and used instead.

ServiceClient no longer sets the deprecated
`streamResponsebody` property based on incorrect assumption. However
we still respect `streamResponseBody` in case it's set by users.

Core v2 removes `streamResponseBody`.
@jeremymeng
Copy link
Contributor Author

/azp run js - storage-blob - tests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

as Blob.text() is not supported in IE.
xhr.addEventListener("load", () => {
resolve(xhr.response);
// Response comes back in Blob when xhr.responseType === "blob"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had thought about always using xhr.responseType === "blob" to send request, then converting to text if needed based on the response status codes, but was back-pedaled because I don't want to make big changes to existing code paths.

Copy link
Member

@xirzec xirzec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the design approach with status codes a lot! I think my only concern at this point is it feels like we're overly prioritizing the blob case in reading the result from XHR. Are we not able to detect the simple case at all?

@@ -932,7 +932,9 @@ export class WebResource implements WebResourceLike {
requestId: string;
shouldDeserialize?: boolean | ((response: HttpOperationResponse) => boolean);
spanOptions?: SpanOptions;
// @deprecated (undocumented)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does it say (undocumented)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likely needing a comments following @deprecated. I will add one pointing to the new property.

@@ -100,7 +100,7 @@ module.exports = function(config) {

// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true,
singleRun: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this intended to be changed? I'm assuming it was altered for testing.

What I usually do when I need to debug is

npx karma start --browsers=Chrome --single-run=false

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have --single-run in the unit-test:browser/integration-test:browser npm scripts so it's less to type when we need debug with just npx karma start --browsers=Chrome but I can revert since it's unrelated change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am cool with whatever is best here, I just thought it was a debug thing creeping into the main test path

@@ -130,7 +130,7 @@ class PipelineRequestImpl implements PipelineRequest {
this.keepAlive = options.keepAlive ?? true;
this.proxySettings = options.proxySettings;
this.skipDecompressResponse = options.skipDecompressResponse ?? false;
this.streamResponseBody = options.streamResponseBody ?? false;
this.streamResponseStatusCodes = options.streamResponseStatusCodes ?? new Set<number>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why initialize it to an empty set? Shouldn't we leave it undefined since it's optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow I defined it as non-optional. Making it optional now.


if (isReadableStream(request.body)) {
throw new Error("Node streams are not supported in browser environment.");
}

xhr.send(request.body === undefined ? null : request.body);

if (request.streamResponseBody) {
if (request.streamResponseStatusCodes?.size ?? 0 > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this technically also work?

Suggested change
if (request.streamResponseStatusCodes?.size ?? 0 > 0) {
if (request.streamResponseStatusCodes?.size) {

Since I assume it can't be negative and null/undefined is falsy

headers: parseHeaders(xhr)
});
} else {
// Blob.text() is not supported in IE
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core-https doesn't support IE as a goal, so please remove this fallback.

return new Promise((resolve, reject) => {
xhr.addEventListener("readystatechange", () => {
// Resolve as soon as headers are loaded
if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
const blobBody = new Promise<Blob>((resolve, reject) => {
if (request.streamResponseStatusCodes?.has(xhr.status)) {
// eslint-disable-next-line @typescript-eslint/no-shadow
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can avoid needing to disable lint by renaming resolve and reject here, probably good for readability, even if it's just to call them blobResolve and blobReject

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's another linting rule complaining about the params of Promise constructor callback must be named resolve and reject. The old code chose to use // eslint-disable-next-line @typescript-eslint/no-shadow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other error is: "Promise constructor parameters must be named resolve, reject promise/param-names"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand now

@@ -59,31 +59,63 @@ export class XhrHttpsClient implements HttpsClient {
for (const [name, value] of request.headers) {
xhr.setRequestHeader(name, value);
}
xhr.responseType = request.streamResponseBody ? "blob" : "text";
xhr.responseType = request.streamResponseStatusCodes?.size ?? 0 > 0 ? "blob" : "text";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same thought as below

resolve(xhr.response);
// response comes back in Blob when xhr.responseType === "blob"
// but the response body type is not expected to be stream based on response status code
// so converting from Blob to text
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused about this comment. How do we know the response is a blob and that we can't just read xhr.responseText? I don't see any actual detection logic, we're only asserting that this isn't a known streaming case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that our operation spec lists all streaming operations thus we don't need any more detection logic.

  • Before we send a request with xhr we set xhr.responseType to be either "blob" or "text" (default will be text if not specified). The response will come back in response, typed accordingly. https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType
  • If the operation is streaming (based on our heuristic of any status code having a Stream body mapper) we use "blob" to send request. Otherwise we use "text".
    • The doc also mentions that "If the server returns data that is not compatible with the responseType that was set, the value of response will be null." However, in my testing, it still worked when we send a request using "blob" and Storage service return the error response in application/xml in response of Blob type.
  • So in the cases where we send requests for streaming operations and a response status code doesn't match any of the streaming status codes, the response has Blob type and needed to be converted into string.

EDITED: fixing typo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out I was reading the code wrong. The if block was so big for if (streaming) that I didn't notice the else case (that was excluded from the diff view) where we use text in the non-blob case, so I thought we were treating every response as a blob.

Maybe as a refactoring we could move the blob case out into a helper function? That would also fix the nested-promise constructor naming collision issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea! I extracted the "blob" case into a function.

@jeremymeng
Copy link
Contributor Author

it feels like we're overly prioritizing the blob case in reading the result from XHR. Are we not able to detect the simple case at all?

I think the problem is xhr sets a response type before sending the request, and shapes the response accordingly. If we don't set response type it will defaults to text which is probably not working for streaming responses.

- Add @deprecated comment
- Remove IE workaround from core v2.
Copy link
Member

@xirzec xirzec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks good. Turns out I misread part of the code because I ran out of caffeine in my blood or something. I left a small comment reply on how we could make that code slightly more obvious, but it's not required.

- Extract Blob response case into a function
- small cleanup
@jeremymeng jeremymeng merged commit e140079 into Azure:master Jan 28, 2021
@jeremymeng jeremymeng deleted the corehttp-parse-stream-body-when-error branch January 28, 2021 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants