Open8

Cloudflare DurableObjects内のエラーを調べる

HiraiKyoHiraiKyo

まず適当にスローする

import { DurableObject } from "cloudflare:workers";
import { Env } from "hono";

export class Counter extends DurableObject {
	constructor(
		private readonly state: DurableObjectState,
		readonly env: Env["Bindings"]
	) {
		super(state, env);
	}
	async throwError() {
		throw new Error("This is intentional error");
	}
}

Cloudflare Worker側でUnhandledだと、これは500エラーで出てくる。

Cloudflare Worker内でエラーをキャッチすると、

{ 
  remote: true
}

このリモートとは何なのか?

HiraiKyoHiraiKyo

コード起因のエラーはremote: trueで伝播する

DurableObjectが伝播させるエラーは2種類あって、

An exception can be thrown within the user code which implements a Durable Object class. The resulting exception will have a .remote property set to True in this case.

記述したコード起因のエラーはremote: trueで判別される。
これに対して、コード起因以外のエラーがおそらくremote: falseで伝播されると思いきや、そんなこともないらしい。

An exception can be generated by Durable Object’s infrastructure. Some sources of infrastructure exceptions include: transient internal errors, sending too many requests to a single Durable Object, and too many requests being queued due to slow or excessive I/O (external API calls or storage operations) within an individual Durable Object. Some infrastructure exceptions may also have the .remote property set to True – for example, when the Durable Object exceeds its memory or CPU limits.

  • 一時的エラー
  • アクセス集中エラー
  • 実行待ちキュー多すぎエラー

例えばこんなエラーがあるが、メモリ・CPUの制限超過でもremote: trueになるらしい。

HiraiKyoHiraiKyo

Troubleshootingを漁る

Troubleshootingを漁って、どんなパラメータがエラーに含まれるのか調べる。

  • overloaded: true: 実行キュー多すぎ、時間かかりすぎ等。負荷分散とかして減らそう
  • retryable: true: 一時的エラー起因。DurableObjectへの参照取得にリミットがあって、そのリミットに到達した時とか。(リミット自体はキャッシュの問題なのでリトライすればいいらしい)
HiraiKyoHiraiKyo

retryableの時のリトライ例

https://developers.cloudflare.com/durable-objects/best-practices/error-handling/#example
ここから引用しただけ。

    // Retry behavior can be adjusted to fit your application.
    let maxAttempts = 3;
    let baseBackoffMs = 100;
    let maxBackoffMs = 20000;

    let attempt = 0;
    while (true) {
      // Try sending the request
      try {
        // Create a Durable Object stub for each attempt, because certain types of
        // errors will break the Durable Object stub.
        const doStub = env.ErrorThrowingObject.get(id);
        const resp = await doStub.fetch("http://your-do/");

        return Response.json(resp);
      } catch (e: any) {
        if (!e.retryable) {
          // Failure was not a transient internal error, so don't retry.
          break;
        }
      }
      let backoffMs = Math.min(maxBackoffMs, baseBackoffMs * Math.random() * Math.pow(2, attempt));
      attempt += 1;
      if (attempt >= maxAttempts) {
        // Reached max attempts, so don't retry.
        break;
      }
      await scheduler.wait(backoffMs);
HiraiKyoHiraiKyo

ユーザ起因のエラーに情報をもっと乗っけたい

ユーザ起因のエラーの場合にも、リトライを許可するかとか重複リクエストによるエラーとか色んな情報を乗っけてCloudflare Workerに返したくなる。

とりあえずJSON.stringifyでエラーメッセージを返すことにする。

	async throwError() {
		throw new Error(JSON.stringify({ message: "test throw", status: 500 }));
	}
HiraiKyoHiraiKyo

エラー型もRPCで伝播させる

とりあえず、こんな感じで書けたら嬉しいを書いてみた。

interface DurableObjectExceptionOptions extends ErrorOptions {
	status?: StatusCode;
	cause?: unknown;
	message?: string;
}
export class DurableObjectException extends Error {
	readonly status?: StatusCode;
	constructor(options?: DurableObjectExceptionOptions) {
		super(options?.message, { cause: options?.cause });
		this.status = options?.status;
	}

	httpException() {
		return new HTTPException(this.status || 500, { message: this.message });
	}
}

export type DurableObjectResponse<T> = Promise<T | DurableObjectException>;

export class Counter extends DurableObject {
	async throwCustomError(): DurableObjectResponse<void> {
		try {
			throw new DurableObjectException({
				message: "This is custom error",
				status: 503,
			});
		} catch (e) {
			return new DurableObjectException({ cause: e });
		}
	}
}

const app = new OpenAPIHono();
const route = createRoute({
    // 省略
});
app.openapi(route, async (c) => {
	try {
		const id = await c.env.COUNTER.idFromName("main");
		const stub = await c.env.COUNTER.get(id);
		const res = await stub.throwCustomError();
		if (res instanceof DurableObjectException) {
			throw res;
		}
		return c.json({ message: "unintetionally ignored throw" }, 200);
	} catch (e: any) {
		if (e instanceof HTTPException) {
			throw e;
		}
		if (e instanceof DurableObjectException) {
			throw e.httpException();
		}
		throw new HTTPException(500);
	}
},
(result, c) => {
	if (!result.success) {
		return c.json({ code: 422, message: result.error.message }, 422);
	}
});
HiraiKyoHiraiKyo

スタックトレースをCloudflare Worker(Hono)側に伝播される方法が分からない

ES2022のErrorオブジェクトはcauseプロパティを持っており、これが再起関数になっていてスタックトレースを取れる(?)という認識。
なので、このErrorオブジェクトをそのままHono側に渡してあげたいが、渡し方がわからない。

そもそも大前提として、こうしたスタックトレースをDurableObjectの外に出してしまう事自体がバッドプラクティスなのか?
DurableObjectも1つのWorkerという扱いなので、Workerを跨ぐようなエラーの伝播の仕方はそもそも良くないという考え方をすべきなのかも。

という事で、DurableObject内でtry..catchを行い握り潰して、neverthrowのResultのような形でエラーハンドリングを行う方針にする。