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
[core-http] use response status to determine whether body is stream #13192
Conversation
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this 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 |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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); | |||
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 inbodyAsText
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.
There was a problem hiding this comment.
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
/azp run js - storage-blob - tests |
Azure Pipelines successfully started running 1 pipeline(s). |
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`.
…ream-body-when-error
/azp run js - storage-blob - tests |
Azure Pipelines successfully started running 1 pipeline(s). |
as Blob.text() is not supported in IE.
var => const
xhr.addEventListener("load", () => { | ||
resolve(xhr.response); | ||
// Response comes back in Blob when xhr.responseType === "blob" |
There was a problem hiding this comment.
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.
There was a problem hiding this 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) |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 betext
if not specified). The response will come back inresponse
, 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 inapplication/xml
inresponse
ofBlob
type.
- 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
- 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
hasBlob
type and needed to be converted into string.
EDITED: fixing typo
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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 |
- Add @deprecated comment - Remove IE workaround from core v2.
There was a problem hiding this 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
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 astream and
httpRequest.streamResponseBody
is set according to thisassumption. However, Storage issue #12997 shows that the default
response of Download Blob operation does not have a stream body with
the following operation spec:
Treating response body as stream in the default case prevent us from
parsing the body which leads to missing
code
andmessage
from theerror 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. Howeverwe still respect
streamResponseBody
in case it's set by users.Core v2 removes
streamResponseBody
.