Cloudflare Workersプロキシ Request/Responseの複製時のエラーパターン
概要
Cloudflare Workersをリバースプロキシとして使用するとき、度々RequestとResponseの複製(clone、reconstruct)に悩まされます。
この記事ではそれらに関連して発生するエラーのパターンについて記載しています。
エラーメッセージなどでたどり着いた方の助けになれば幸いです。
Cloudflare Workersのプロキシについては以下の記事や公式Exampleが参考になります。
基本となるコード
Cloudflare WorkersをRoutesモードでデプロイし、シンプルなリバースプロキシを例に説明します。
export default {
async fetch(request) {
// オリジンにリクエストを送信し、レスポンスをそのまま返す
const response = fetch(request)
return response
}
}
TypeError: Can't modify immutable headers.
①例: Requestヘッダーを変更する
// ❌ NG
export default {
async fetch(request) {
request.headers.set("X-Hoge-Id", "some-id"); // <- TypeError: Can't modify immutable headers.
const response = fetch(request)
return response
}
}
// ❌ requestをclone()しても不可
export default {
async fetch(request) {
const clonedRequest = request.clone()
clonedRequest.headers.set("X-Hoge-Id", "some-id"); // <- TypeError: Can't modify immutable headers.
const response = fetch(clonedRequest)
return response
}
}
// ✅ OK Requestオブジェクトをreconstructする必要がある
export default {
async fetch(request) {
const reconstructRequest = new Request(request)
reconstructRequest.headers.set("X-Hoge-Id", "some-id");
const response = fetch(reconstructRequest)
return response
}
}
例: オリジンのResponseヘッダーを変更する
// ❌ NG
export default {
async fetch(request) {
const response = await fetch(request)
response.headers.delete("X-Hoge-Id"); // <- TypeError: Can't modify immutable headers.
return response
}
}
// ❌ responseをclone()しても不可
export default {
async fetch(request) {
const response = await fetch(request)
const clonedResponse = response.clone();
clonedResponse.headers.delete("X-Hoge-Id"); // <- TypeError: Can't modify immutable headers.
return clonedResponse
}
}
// ✅ OK Responseをreconstructする必要がある
export default {
async fetch(request) {
const response = await fetch(request)
const reconstructResponse = new Response(resopnse.body, response)
reconstructResponse.headers.delete("X-Hoge-Id");
return reconstructResponse
}
}
解説
このエラーはRequest
オブジェクトやオリジンからのResponse
オブジェクトがimmutableであるにもかかわらず、それを変更しようとした場合に発生します。
immutableな状態はclone()
により複製を作っても変わらないためnew Request(request)
やnew Response(resopnse.body, response)
でオブジェクトの再生成(reconstruct)を行う必要があります。(※)
※
Requestのreconstruct new Request(request)
はMDNに記載の方法を、Responseのreconstruct new Response(response.body, response)
はCloudflareのExampleに記載の方法を採用しました
TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.
②例: Responseボディをログに出力し、そのまま返す
// ❌ NG
export default {
async fetch(request) {
const response = await fetch(request)
console.log("レスポンスボディ: ", await response.text())
return response // <- TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.
}
}
// ❌ NG Responseをreconstructしても不可
export default {
async fetch(request) {
const response = await fetch(request)
const reconstructResponse = new Response(response.body, response);
console.log("レスポンスボディ: ", await reconstructResponse.text())
return response // <- TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.
}
}
// ✅ OK responseをclone()してボディを使用する
export default {
async fetch(request) {
const response = await fetch(request)
console.log("レスポンスボディ: ", await response.clone().text())
return response
}
}
解説
このエラーはresponse.body
が使用済みの状態でWorkersの返り値に利用された場合に発生します。
response.body
にはレスポンス本体のコンテンツのReadableStream
が設定されており、text()
やjson()
などを通じて本体データの読み出しを行うと使用済み扱いになります。
body
を2回読み込む方法は、body
の読み出しを行う前にclone()
を行うことです(MDNのclone()
の説明に記載あり)。また、このエラーはnew Response(response.body, response)
の方法でResponseを複製(reconstruct)しても同様に発生します。この方法で複製されたResponseはbody
のReadableStream
を複製しないためです。
また、response.body
が使用済みかどうかはbodyUsed
プロパティでチェックすることも可能です。
TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body.
③例: Responseボディをログに出力し、ヘッダーを改変して返す
// ❌ NG Responseのコンストラクタに渡すことも不可
export default {
async fetch(request) {
const response = await fetch(request)
console.log("レスポンスボディ: ", await response.text())
// ヘッダーを改変するためにnew Response()が必要だが・・・
const reconstructResponse = new Response(response.body, response); // <- TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body.
reconstructResponse.headers.set("X-Hoge-Id", "some-id");
return reconstructResponse
}
}
// ✅ OK responseを読み出すよりも先にclone()してResponseコンストラクタに渡す
export default {
async fetch(request) {
const response = await fetch(request)
const clonedResponse = response.clone();
console.log("レスポンスボディ: ", await response.text())
const reconstructResponse = new Response(clonedResponse.body, clonedResponse);
reconstructResponse.headers.set("X-Hoge-Id", "some-id");
return reconstructResponse
}
}
解説
このエラーはresponse.body
が使用済みの状態でnew Response
にリクエストボディを使用しようとした際に発生します。このエラーも②のエラーと理由は同様で、body
の読み出しを2回出来ないというルールに基づいています。
回避方法も②のエラーと同じで、clone()
でオブジェクトを複製することで回避することが出来ます。
TypeError: This ReadableStream is currently locked to a reader,
④例: Responseボディをログに出力し、キャッシュに保存する
// ❌ NG 読み出し後のclone()不可
export default {
async fetch(request) {
const response = await fetch(request)
console.log('レスポンスボディ', await response.text())
const clonedResponse = response.clone() // <- TypeError: This ReadableStream is currently locked to a reader,
const c = await caches.open('cache')
c.put('response', clonedResponse)
return response
}
}
// ✅ OK responseを読み出すよりも先にclone()して使用する
export default {
async fetch(request) {
const response = await fetch(request)
const clonedResponse = response.clone()
console.log('レスポンスボディ', await response.text())
const c = await caches.open('cache')
c.put('response', clonedResponse)
return response
}
}
解説
このエラーはresponse.body
が使用済みの状態でclone()
を呼び出した時に発生します。こちらも理由は②③のエラーと同様です。
また、MDNのclone()
の説明には以下のように解説があります。
clone() は、レスポンス本体が既に使用されている場合は TypeError を発生させます。 実際、clone() が存在する主な理由は、本体オブジェクトを複数回使用できるようにするためです(一度しか使用できない場合)。
このエラーはresponse.body
が読み出しされるよりも前にclone()
を呼び出すことで回避することが出来ます。
TypeError: Cannot reconstruct a Request with a used body.
⑤例: Requestボディをログ出力した後プロキシする
// ❌ NG
export default {
async fetch(request) {
console.log('リクエストボディ', await request.text())
const response = await fetch(request) // <- TypeError: Cannot reconstruct a Request with a used body.
return response
}
}
// ✅ OK Requestをfetch(request)で使用する前にclone()で複製を作る
export default {
async fetch(request) {
console.log('リクエストボディ', await request.clone().text())
const response = await fetch(request)
return response
}
}
解説
このエラーはrequest.body
が使用済みの状態でnew Request
にリクエストボディを使用しようとした際に発生します。理由は②③④のエラーで見てきたのと同じルールがRequestオブジェクトにも適用されており、body
の読み出しを2回出来ないというルールに基づいています。
例ではfetch()
の呼び出しに使用した際にこのエラーが発生していますが、new Request(request)
でRequestを複製(reconstruct)しようとした場合も同じエラーが発生します。
回避方法は②③④と同じで、request.body
を読み出す前にclone()
で複製を作ることで回避することが出来ます。
A ReadableStream branch was created but never consumed.
⑥Cloudflare Workers特有のWarningが出ることがあります。
Warning全文:
A ReadableStream branch was created but never consumed. Such branches can be created, for instance, by calling the tee() method on a ReadableStream, or by calling the clone() method on a Request or Response object. If a branch is created but never consumed, it can force the runtime to buffer the entire body of the stream in memory, which may cause the Worker to exceed its memory limit and be terminated. To avoid this, ensure that all branches created are consumed.
ReadableStream のブランチが作成されましたが、消費されることはありませんでした。例えば、ReadableStream に対して tee() メソッドを呼び出したり、Request や Response オブジェクトに対して clone() メソッドを呼び出したりすると、こうしたブランチが作成される場合があります。しかし、ブランチが作成されたまま消費されないと、ランタイムがストリームの全体の内容をメモリにバッファリングする必要が生じることがあります。その結果、Worker がメモリ制限を超えて終了する可能性があります。これを防ぐために、作成されたすべてのブランチが確実に消費されるようにしてください。
例: Responseを複製し条件付きで読み出しをする
// ❌ NG responseが200の時にWarningが発生
export default {
async fetch(request) {
const response = await fetch(request)
const clonedResponse = response.clone()
if (clonedResponse.status !== 200) {
console.log('エラーが発生しました', await clonedResponse.text())
}
return response
}
}
// ✅ OK 使用する場合にのみclone()する
export default {
async fetch(request) {
const response = await fetch(request)
if (response.status !== 200) {
console.log('エラーが発生しました', await response.clone().text())
}
return response
}
}
// ✅ OK body.cancel()をコールし、必要がない場合もconsumeされた状態にする
export default {
async fetch(request) {
const response = await fetch(request)
const clonedResponse = response.clone()
if (clonedResponse.status !== 200) {
console.log('エラーが発生しました', await clonedResponse.text())
} else {
await clonedResponse?.body.cancel();
}
return response
}
}
解説
このWarningはRequestやResponseオブジェクトをclone()
で複製したが、body
が使用されないままWorkersの処理が終了した場合に出力されます。
Warning内に記載の通りですが、Request、Responseはclone()
された分メモリを消費するため、使用しないのであれば極力clone()
しないことを意識する必要があります。このWarningはWorkersが不用意なclone()
を防ぐために出力してくれていると解釈しています。
このWarningはclone()
で複製されたオブジェクトのbody
を何かしらの形で使用済みにすることで回避することが出来ます。例ではclone()
されたResponseのbody
を使用しない場合に明示的にbody.cancel()
を呼び出して使用済み扱いにするようにしています。
他にもRequest、ResponseオブジェクトのbodyUsed
プロパティをチェックし、使用されていない場合はcancel()
するなどの方法もよいでしょう。
new Response(response.body, response)
などの方法で複製(reconstruct)した際はbody
が複製されないのでこのWarningは発生せず、考慮の必要はありませんが、2回目のbody
の読み出しは出来ないので注意してください。
まとめ
この記事ではCloudflare WorkersでRequest、Responseの扱いにおいて発生しがちなエラーをエラー別に例と対処方法を記載しました。
考慮が必要なパターンとしては大きく以下の3つになると思います。
- headerがimmutableの場合、headerを変更出来ない (①のケース)
-
new Request(request)
,new Response(response.body, response)
などでreconstructする
-
- bodyを2回使用することは出来ない (②〜⑤のケース)
- 一度目の読み出しを行う前に
clone()
を行う
- 一度目の読み出しを行う前に
-
clone()
したオブジェクトのbodyを使用済みにした方が良い (⑥のケース)- 複製をしないようにするか、
body.cancel()
などで明示的に使用済みにする
- 複製をしないようにするか、
💡おまけ bodyを2回使用できない理由
こうしてみると、リバースプロキシ構築の際、「bodyを2回使用することは出来ない」のはなかなか不便なんですよね。
どうしてこういう制約があるか、という点が気になったので調べました。
fetchのGitHubにそのまんまの質問と答えがあったので掲載しておきます。
仮に、あるアプリが 1MB の HTTP レスポンスを取得し、それを JSON ファイルとして解析して使用するだけで、他の目的には使わないとします(つまり、一度 JSON が生成されたら、そのレスポンスは二度と参照されない場合)。もしも .json() メソッドが呼び出された後も、Response が元のレスポンスボディデータを保持するよう設計されていた場合、たとえその Response オブジェクトがルートから到達不可能になり GC(ガベージコレクション)によって速やかに回収されるとしても、処理中のピークメモリ使用量は 1MB+1MB に達します(シンプルに考えて、生成されたオブジェクトが 1MB であると仮定)。
しかし、パースされたデータが生成されると同時に元データを解放できるように API を設計した場合、メモリフットプリントは(小さいバッファ)+1MB に抑えることができます。
Response ボディを本当に再利用したい場合のみ、clone() を使用してオブジェクトを手動で複製するべきです。
Discussion