Following up from #220 as suggested — this is the "Internal error!" variant, captured in production.
Versions: async-http 0.98.1, protocol-http2 0.26.2, protocol-http 0.64.0, async-http-faraday 0.22.2, async 2.43.0, Ruby 4.0.6.
I don't think async-http is at fault here. The Facebook Graph API sends complete 200 response headers and then immediately resets the stream before a single DATA frame — their backend giving up mid-response. This is a first attempt on a healthy, long-lived connection, no GOAWAY nearby, one open stream, 681ms end to end:
write #<HeadersFrame stream_id=5573 flags=5 373b> # END_STREAM|END_HEADERS, bodyless GET
read #<WindowUpdateFrame stream_id=5573 payload=10420224>
read #<HeadersFrame stream_id=5573 flags=4 278b> # :status 200, content-type: application/json, no content-length
read #<ResetStreamFrame stream_id=5573 flags=0 payload=2> # INTERNAL_ERROR
close! stream_id=5573 error_code=2 state=half_closed_local open_streams=1 elapsed_ms=681
The request was GET https://graph.facebook.com/v24.0/me/posts?fields=...&limit=40. It isn't throttling — the response carried x-app-usage: {"call_count":0,"total_cputime":0,"total_time":0}. It isn't endpoint-specific either; we see the same on /me/photos. Frequency is roughly 5-10/hour across our fleet.
The question is whether the existing retry mechanism should cover it. Today Client#call rescues RefusedError and the socket errors, both gated on request.rewind!/request.retry!, but StreamError propagates to the caller. There is already precedent for treating a stream-level error as retryable transport noise — Stream#close! converts REFUSED_STREAM into Protocol::HTTP::RefusedError for exactly that reason. INTERNAL_ERROR looks like the same category: the request is gone, nothing usable reached the application, and request.retry! would keep unsafe methods out of it.
I would suggest keeping it narrow rather than retrying every StreamError. PROTOCOL_ERROR in particular should keep propagating — retrying it would have masked the body-rewind bug from #220 rather than surfacing it.
Two caveats worth noting:
- The reset arrives after
:status 200, so the request was genuinely processed server-side. That is fine for idempotent methods, which is what retry! already enforces, but it is a different situation from REFUSED_STREAM.
- The retry block only wraps
make_response, so this would only help when the reset lands before the caller receives the response object — as it did here, since the reading fiber processed HEADERS and RST_STREAM back to back. A reset arriving well into the body would still surface during body reading, outside the retry. So this would not be a general guarantee.
A note on the trace format: I extended the framer logging from #220 rather than using it verbatim. Alongside the frame reads/writes it logs the decoded request line and the response status/headers — the frame logs are HPACK-opaque, and these connections multiplex thousands of streams, so tying a reset back to its request needed the decoded view — plus stream state, open stream count, elapsed time at the reset, and the Client#call attempt number. Happy to share that initializer if it would be useful to anyone else debugging this.
This reproduces reliably for us, so I am glad to capture more traces or test a fix against production traffic whenever you want.
Following up from #220 as suggested — this is the "Internal error!" variant, captured in production.
Versions: async-http 0.98.1, protocol-http2 0.26.2, protocol-http 0.64.0, async-http-faraday 0.22.2, async 2.43.0, Ruby 4.0.6.
I don't think async-http is at fault here. The Facebook Graph API sends complete
200response headers and then immediately resets the stream before a single DATA frame — their backend giving up mid-response. This is a first attempt on a healthy, long-lived connection, no GOAWAY nearby, one open stream, 681ms end to end:The request was
GET https://graph.facebook.com/v24.0/me/posts?fields=...&limit=40. It isn't throttling — the response carriedx-app-usage: {"call_count":0,"total_cputime":0,"total_time":0}. It isn't endpoint-specific either; we see the same on/me/photos. Frequency is roughly 5-10/hour across our fleet.The question is whether the existing retry mechanism should cover it. Today
Client#callrescuesRefusedErrorand the socket errors, both gated onrequest.rewind!/request.retry!, butStreamErrorpropagates to the caller. There is already precedent for treating a stream-level error as retryable transport noise —Stream#close!convertsREFUSED_STREAMintoProtocol::HTTP::RefusedErrorfor exactly that reason.INTERNAL_ERRORlooks like the same category: the request is gone, nothing usable reached the application, andrequest.retry!would keep unsafe methods out of it.I would suggest keeping it narrow rather than retrying every
StreamError.PROTOCOL_ERRORin particular should keep propagating — retrying it would have masked the body-rewind bug from #220 rather than surfacing it.Two caveats worth noting:
:status 200, so the request was genuinely processed server-side. That is fine for idempotent methods, which is whatretry!already enforces, but it is a different situation fromREFUSED_STREAM.make_response, so this would only help when the reset lands before the caller receives the response object — as it did here, since the reading fiber processed HEADERS and RST_STREAM back to back. A reset arriving well into the body would still surface during body reading, outside the retry. So this would not be a general guarantee.A note on the trace format: I extended the framer logging from #220 rather than using it verbatim. Alongside the frame reads/writes it logs the decoded request line and the response status/headers — the frame logs are HPACK-opaque, and these connections multiplex thousands of streams, so tying a reset back to its request needed the decoded view — plus stream state, open stream count, elapsed time at the reset, and the
Client#callattempt number. Happy to share that initializer if it would be useful to anyone else debugging this.This reproduces reliably for us, so I am glad to capture more traces or test a fix against production traffic whenever you want.