0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チャットbotお試し用アプリ開発_24日目_API通信クラスの改修

Posted at

目次

・本日の成果・考え
・最後に

本日の成果

以下の記事でリンクテストの計画を建てるまで進めましたが、
テストの項目のうち「インターフェース契約」を検討する中で、API通信のリクエスト・レスポンス(Json)の中身を考慮して通信する設計となっていないため、リンクテストとしての質に大きく関わることが判明しました。

具体的には、
①リクエストの長文や無効文字のバリデーションの実装
②API通信自体はOKだが、Dify(Saasアプリ側)で発生したエラーのキャッチの実装
※レスポンスJson内にエラーの内容が記載されているみたいなので、それをキャッチ予定。

現行のAPI通信クラスの処理は、OkHttpのエラーハンドリングの仕様に沿ったものでないみたいです。
現在のソースでは、onFailureメソッドで、通信時のエラーをキャッチしたら例外を発生させているのですが、
実際には例外をスローしても処理の中で揉み消されるみたいです。

というわけで、アプリとしての質をある程度担保するために、さきにこちらから手をつける必要があると判断しました。

では、先に現行の調査から行いたいと思います。

現行のエラー時のスローの伝番

現行該当箇所
API通信.java
/**
	*DifyAPIとのストリーミング通信処理
	* @param dto DifyRequestDtoオブジェクト:リクエスト
	* @param onChunk レスポンス成功時に実行するメソッド
	* @param conComplete レスポンス受信終了時に実行するメソッド
	*/
	public void streamingMsg(DifyRequestDto dto, Consumer<String> onChunk, Runnable onComplete) {
		//リクエストの中身
		RequestBody body = RequestBody.create(
				//送信データのメディアタイプををJson形式に設定。引数はテンプレ。
				MediaType.parse("application/json"),
				gson.toJson(dto).getBytes(StandardCharsets.UTF_8));

		//リクエストのヘッダ+中身を融合
		Request request = new Request.Builder()
				.url(difyAPI_URL)
				.header("Authorization", "Bearer " + apiKey)
				.post(body)
				.build();

		httpClient.newCall(request).enqueue(new Callback() {
			//通信失敗時の処理
			@Override
			public void onFailure(Call call, IOException e) {
				logger.error("API通信:URLエラー url={}", difyAPI_URL, e);
				throw new IllegalStateException("APIエラー:" + e.getMessage());
			}

			//通信が成功して何かしらのレスポンスを受信した時
			@Override
			public void onResponse(Call call, Response response) throws IOException {
				// ステータスコードチェック(200系以外はNG)
				if (!response.isSuccessful()) {
					logger.error("API通信:通信ステータスエラー code{}",response.code());
					throw new IOException("HTTPエラー;" + response.code());
				}
				//レスポンスのnullチェック
				ResponseBody responseBody = response.body();
				if (responseBody == null) {
					logger.error("API通信:レスポンスnull");
					throw new IllegalStateException("レスポンスの中身がnullです。");
				}

				//チャンク毎にレスポンスの受け取り
				try (BufferedSource source = responseBody.source()) {

					while (!source.exhausted()) {
						//1行ずつUTF-8形式で格納
						String resline = source.readUtf8LineStrict();

						if (resline != null && resline.startsWith("data:")) {

							String data = resline.substring(6);

							DifyResponseDto chunk = gson.fromJson(data, DifyResponseDto.class);
							//チャンクのawnser取り出し
							String answer = chunk.getAnswer();
							//空文字の場合(message_endの一つ手前)、スキップ。 7/26動作確認時点で受信チャンクに混入していたため追加。
							if (answer != null && answer.trim().isEmpty()) {
								continue;
							}
							//受信チャンクのイベントをチェックし、終了イベントなら処理終了。
							String event = chunk.getEvent();
							//nullでないなら、event.trim()、nullなら空文字を返す。
							if ("message_end".equals(event != null ? event.trim() : "")) {
								onComplete.run();
								//System.out.println("チャンクの終了を確認");
								break;
							}
							//チャンクの中身の空白チェック,空白なら例外
							CommonFunction.checkNullBlank(answer);
							//メソッド引数のonChunkにセットされているメソッドの呼び出し。
							onChunk.accept(answer);
						}
					}
				} catch (Exception e2) {
					//ロガーにエラーログ出力
					logger.error("API通信:異常終了",e2);
					//チャンク読み込み中エラー
					throw new IllegalStateException("チャンク読み込みエラー:" + e2.getMessage());
				}
			}
		});

以下でエラーの伝番の流れを確認したいと思います。

  1. 通信処理を途中で中断してonFailureを起動し、呼び出し元にエラーが渡るか確認。
  2. Httpステータスコードを200以外(500)にして、onResponseでスローさせ、呼び出し共にエラーが渡すか確認。
テストソース
動作チェック.java
package app.test;

import static org.junit.Assert.*;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;

import app.windowView.api.DifyApiClient;
import app.windowView.api.DifyRequestDto;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
//追加のimport
import okhttp3.mockwebserver.SocketPolicy;

/**
 * DifyApiClientのエラー挙動確認テストクラス
 * - 通信エラーやHTTP500、JSON内エラーが呼び出し元に伝播しないことを確認

 */
public class DifyApiClientErrorCatch {

	private static MockWebServer mockServer;

	@BeforeClass
	public static void setUpBeforeClass() throws Exception {
		// モックサーバ起動
		mockServer = new MockWebServer();
		mockServer.start();
	}

	@AfterClass
	public static void tearDownAfterClass() throws Exception {
		mockServer.shutdown();
	}

	@Before
	public void setUp() throws Exception {
	}

	@After
	public void tearDown() throws Exception {
	}

	/**
	 * HTTP 500 応答時の動作確認
	 * → 呼び出し元に例外が伝わらないこと、onChunk/onComplete未発火を確認
	 * 期待:合格
	 *  - onChunk は発火しない
	 *  - onComplete は発火しない
	 */
	@Test
	public void testHttp500_NotPropagated() throws IOException, InterruptedException {
		// モックレスポンスを登録
		mockServer.enqueue(new MockResponse()
				.setResponseCode(500)
				.setBody("{\"message\":\"server error\"}"));

		String url = mockServer.url("/chat").toString();
		DifyApiClient api = new DifyApiClient(url, "dummy");

		// コールバック制御
		CountDownLatch latchChunk = new CountDownLatch(1);
		CountDownLatch latchComplete = new CountDownLatch(1);
		AtomicBoolean thrown = new AtomicBoolean(false);

		try {
			api.streamingMsg(new DifyRequestDto("テストパターン1"),
					s -> latchChunk.countDown(),
					() -> latchComplete.countDown());
		} catch (Throwable t) {
			thrown.set(true);
		}

		// 呼び出し元で例外を受け取れていないことを確認
		assertFalse("例外が伝播している", thrown.get());
		// コールバックが発火しないこと
		assertFalse("onChunkが呼ばれた", latchChunk.await(300, TimeUnit.MILLISECONDS));
		assertFalse("onCompleteが呼ばれた", latchComplete.await(300, TimeUnit.MILLISECONDS));
	}

	/**
	 * 途中で切断:通信接続後、すぐに切断 → onFailure 経路を検証
	 * 期待:合格
	 *  - onChunk は発火しない
	 *  - onComplete は発火しない
	 */
	@Test
	public void testOnFailureExecute() throws Exception {
		// 接続直後にソケットを切断(HTTP行/ヘッダ/ボディは一切送られない)
		mockServer.enqueue(new MockResponse()
				.setSocketPolicy(SocketPolicy.DISCONNECT_AT_START));

		String url = mockServer.url("/chat").toString();
		DifyApiClient api = new DifyApiClient(url, "dummy");

		CountDownLatch latchChunk = new CountDownLatch(1);
		CountDownLatch latchComplete = new CountDownLatch(1);
		AtomicBoolean thrown = new AtomicBoolean(false);

		try {
			api.streamingMsg(new DifyRequestDto("テストパターン2"),
					s -> latchChunk.countDown(),
					() -> latchComplete.countDown());
		} catch (Throwable t) {
			thrown.set(true);
		}

		// 呼び出し元で例外を受け取れていないことを確認
		assertFalse("例外が伝播している", thrown.get());
		// 完了は来ない
		assertFalse("onComplete が呼ばれた", latchComplete.await(300, TimeUnit.MILLISECONDS));
		// 先頭チャンクは届く環境が多い(届かなかったらこのアサートは外してよい)
		assertFalse("onChunk が呼ばれた", latchChunk.await(800, TimeUnit.MILLISECONDS));
	}

}

参考

Mockサーバを立ち上げてmockServer.start();で空いているポートに自動で割り振られています。
また、MockサーバなのでIPは自分に戻ってくるループバック(127.0.0.1)固定みたいです。
そこに追加で"/chat"を追加していますが、不要です。

実行結果

スクリーンショット 2025-11-03 15.47.34.png

テスト結果より、
正常終了:API通信呼び出し元では例外をキャッチできていない。
コンソール:エラーが出力されていることから、各メソッド内では例外が発生している。
と読み取れます。

修正ソース

修正済み.java
	/**
	*DifyAPIとのストリーミング通信処理
	* @param dto DifyRequestDtoオブジェクト:リクエスト
	* @param onChunk レスポンス成功時に実行するメソッド(呼び出し元で詳細を実装)
	* @param conComplete レスポンス受信終了時に実行するメソッド(呼び出し元で詳細を実装)
	* @param onError エラーが発生した時に実行するメソッド(呼び出し元で詳細を実装)
	*/
	public void streamingMsg(
			DifyRequestDto dto, 
			Consumer<String> onChunk, 
			Runnable onComplete,
			Consumer<String> onError) {
		//リクエストの中身
		RequestBody body = RequestBody.create(
				//送信データのメディアタイプををJson形式に設定。引数はテンプレ。
				MediaType.parse("application/json"),
				gson.toJson(dto).getBytes(StandardCharsets.UTF_8));

		//リクエストのヘッダ+中身を融合
		Request request = new Request.Builder()
				.url(difyAPI_URL)
				.header("Authorization", "Bearer " + apiKey)
				.post(body)
				.build();

		httpClient.newCall(request).enqueue(new Callback() {
			//通信失敗時の処理
			@Override
			public void onFailure(Call call, IOException e) {
				logger.error("API通信:URLエラー url={}", difyAPI_URL, e);
				//20251102上位にスローできないため廃止
				//throw new IllegalStateException("APIエラー:" + e.getMessage());
				onError.accept("AIとの通信に失敗しました。アプリを再起動してください。");
			}

			//通信が成功して何かしらのレスポンスを受信した時
			@Override
			public void onResponse(Call call, Response response) throws IOException {
				// ステータスコードチェック(200系以外はNG)
				if (!response.isSuccessful()) {
					logger.error("API通信:通信ステータスエラー code{}",response.code());
					//20251102上位にスローできないため廃止
					//throw new IOException("HTTPエラー;" + response.code());
					onError.accept("通信エラーが発生しました。時間をおいて再試行してください。");
					return;
				}
				//レスポンスのnullチェック
				ResponseBody responseBody = response.body();
				if (responseBody == null) {
					logger.error("API通信:レスポンスnull");
					//20251102上位にスローできないため廃止
					//throw new IllegalStateException("レスポンスの中身がnullです。");
					onError.accept("AIからのレスポンスの中身がないためエラーとなりました。時間をおいて再試行してください。");
					return;
				}

				//チャンク毎にレスポンスの受け取り
				try (BufferedSource source = responseBody.source()) {

					while (!source.exhausted()) {
						//1行ずつUTF-8形式で格納
						String resline = source.readUtf8LineStrict();

						if (resline != null && resline.startsWith("data:")) {
							
							//5文字以降をtrim
							String data = resline.substring(6);
							// レスポンスのJsonオブジェクト 
							JsonObject root = gson.fromJson(data, JsonObject.class);

							DifyResponseDto chunk = gson.fromJson(data, DifyResponseDto.class);
							//チャンクのawnser取り出し
							String answer = chunk.getAnswer();
							//空文字の場合(message_endの一つ手前)、スキップ。 7/26動作確認時点で受信チャンクに混入していたため追加。
							if (answer != null && answer.trim().isEmpty()) {
								continue;
							}
							 // Jsonのエラーコードの有無をチェック。発生している場合、その時点でストリーミング処理中断。
							if (root == null) continue;
                            if (root.has("error") || (root.has("code") && root.has("message"))) {
                                logger.error("API通信:アプリ層エラー payload={}", data);
                                onError.accept("処理に失敗しました。時間をおいて再試行してください。");
                                return;
                            }
							
							//受信チャンクのイベントをチェックし、終了イベントなら処理終了。
							String event = chunk.getEvent();
							//nullでないなら、event.trim()、nullなら空文字を返す。
							if ("message_end".equals(event != null ? event.trim() : "")) {
								onComplete.run();
								//System.out.println("チャンクの終了を確認");
								break;
							}
							//チャンクの中身の空白チェック,空白なら例外
							CommonFunction.checkNullBlank(answer);
							//メソッド引数のonChunkにセットされているメソッドの呼び出し。
							onChunk.accept(answer);
						}
					}
				} catch (Exception e2) {
					//ロガーにエラーログ出力
					logger.error("API通信:異常終了",e2);
					//20251102上位にスローできないため廃止
					//チャンク読み込み中エラー
					//throw new IllegalStateException("チャンク読み込みエラー:" + e2.getMessage());
					onError.accept("AIからのレスポンス処理で問題が発生しました。再度実行してください。");
				}
			}
		});
	}

修正したのは、

  • streamingメソッドの戻り値にConsumer<String> onErrorを追加
  • Jsonオブジェクトに一度パースして、オブジェクト自体に問題がないかのチェックを追加
    ※既存ではいきなりDtoに格納しようとしていたため、そこでエラー落ちするリスクがありました。
    if (root == null) continue;以降
  • 今まで例外をスローしていた箇所をonError.accept("文字列");に修正
    ※各実装箇所でreturnで処理を中断していますが、onFailureはOkhttpの仕様で処理が中断されるため不要としています。

修正後の挙動

テストソース(上記のソースを修正)
エラーキャッチ実装後テスト.java
package app.test;

import static org.junit.Assert.*;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;

import app.windowView.api.DifyApiClient;
import app.windowView.api.DifyRequestDto;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
//追加のimport
import okhttp3.mockwebserver.SocketPolicy;

/**
 * DifyApiClientのエラー挙動確認テストクラス
 * - 通信エラーやHTTP500、JSON内エラーが呼び出し元に伝播しないことを確認

 */
public class DifyApiClientErrorCatch {

	private static MockWebServer mockServer;

	@BeforeClass
	public static void setUpBeforeClass() throws Exception {
		// モックサーバ起動
		mockServer = new MockWebServer();
		mockServer.start();
	}

	@AfterClass
	public static void tearDownAfterClass() throws Exception {
		mockServer.shutdown();
	}

	@Before
	public void setUp() throws Exception {
	}

	@After
	public void tearDown() throws Exception {
	}

	/**
	 * HTTP 500 応答時の動作確認
	 * → 呼び出し元に例外が伝わらないこと、onChunk/onComplete未発火を確認
	 * 期待:合格
	 *  - onChunk は発火しない
	 *  - onComplete は発火しない
	 */
	@Test
	public void testHttp500_NotPropagated() throws IOException, InterruptedException {
		// モックレスポンスを登録
		mockServer.enqueue(new MockResponse()
				.setResponseCode(500)
				.setBody("{\"message\":\"server error\"}"));

		String url = mockServer.url("/chat").toString();
		DifyApiClient api = new DifyApiClient(url, "dummy");

		// コールバック制御
		CountDownLatch latchChunk = new CountDownLatch(1);
		CountDownLatch latchComplete = new CountDownLatch(1);
		//修正後ソース追加分
		CountDownLatch latchError = new CountDownLatch(1);
		AtomicBoolean thrown = new AtomicBoolean(false);

		try {
			api.streamingMsg(new DifyRequestDto("テストパターン1"),
					s -> latchChunk.countDown(),
					() -> latchComplete.countDown(),
					e -> latchError.countDown());
		} catch (Throwable t) {
			thrown.set(true);
		}

		// 呼び出し元で例外を受け取れていないことを確認
		assertFalse("例外が伝播している", thrown.get());
		// コールバックが発火しないこと
		assertFalse("onChunkが呼ばれた", latchChunk.await(300, TimeUnit.MILLISECONDS));
		assertFalse("onCompleteが呼ばれた", latchComplete.await(300, TimeUnit.MILLISECONDS));
		//修正ソース追加分
		assertTrue("onErrorが呼ばれている", latchError.await(500, TimeUnit.MILLISECONDS));
	}

	/**
	 * 途中で切断:通信接続後、すぐに切断 → onFailure 経路を検証
	 * 期待:合格
	 *  - onChunk は発火しない
	 *  - onComplete は発火しない
	 */
	@Test
	public void testOnFailureExecute() throws Exception {
		// 接続直後にソケットを切断(HTTP行/ヘッダ/ボディは一切送られない)
		mockServer.enqueue(new MockResponse()
				.setSocketPolicy(SocketPolicy.DISCONNECT_AT_START));

		String url = mockServer.url("/chat").toString();
		DifyApiClient api = new DifyApiClient(url, "dummy");

		CountDownLatch latchChunk = new CountDownLatch(1);
		CountDownLatch latchComplete = new CountDownLatch(1);
		//修正後ソース追加分
		CountDownLatch latchError = new CountDownLatch(1);
		AtomicBoolean thrown = new AtomicBoolean(false);

		try {
			api.streamingMsg(new DifyRequestDto("テストパターン2"),
					s -> latchChunk.countDown(),
					() -> latchComplete.countDown(),
					e -> latchError.countDown());
		} catch (Throwable t) {
			thrown.set(true);
		}

		// 呼び出し元で例外を受け取れていないことを確認
		assertFalse("例外が伝播している", thrown.get());
		// 完了は来ない
		assertFalse("onComplete が呼ばれた", latchComplete.await(300, TimeUnit.MILLISECONDS));
		// 先頭チャンクは届く環境が多い(届かなかったらこのアサートは外してよい)
		assertFalse("onChunk が呼ばれた", latchChunk.await(800, TimeUnit.MILLISECONDS));
		//修正ソース追加分
		assertTrue("onErrorが呼ばれている", latchError.await(500, TimeUnit.MILLISECONDS));
	}

}

テストソースの修正箇所は以下です。

  • アサーションにonErrorが呼び出されたことを確認
    latchError.await(500, TimeUnit.MILLISECONDS));
実行結果

スクリーンショット 2025-11-03 16.24.34.png

実行結果より、同じテストケースでonErrorが呼び出されたことをアサーションで確認できます。

最後に

前回よりおよそ2ヶ月ほど期間が空いてしまいました。

個人的な事情ですが、資格の方を無事取得できましたので、今後はこちらを再開したいと思います。
また、リンクテストを進めるはずが、リンクテストを行うための既存ソースに問題があるということを繰り返している状況となっています。

正直、どんどん課題が出てきてしまいデプロイが遠くかなりしんどい状況です。
個人的なTodoとして、このアプリ開発とAWSハンズオンを並行して投稿していこうと思います。

ここまでお付き合いありがとうございます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?