Repository: yourssu/ssufid
Pull request #59: 플러그인 크롤링 시도 횟수 설정 및 main 함수 비정상 종료 로직 추가
Author: login: cometj03
Created: 2025-04-06T12:38:25Z | Merged: 2025-04-07T13:55:32Z

## #️⃣연관된 이슈

resolves #49 

## 📝작업 내용

> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)

- crawl의 최대 시도 횟수를 3번으로 설정했습니다.
- main 함수에서 모든 플러그인 실패 시 `panic!`을 수행하도록 했습니다.

## 💬리뷰 요구사항(선택)

> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
>
> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?

- crawl 실패시 에러 메시지를 `eprintln!("{} attempt: {} - {:?}", T::IDENTIFIER, crawl_attempt, e);`으로 했는데 메시지를 조금 더 좋게 할 수 있을까요?
- main 함수에서 직접 `panic!`을 호출하도록 하는 것보다 더 좋은 방법이 있을까요?

Linked issue #49: 플러그인에서 오류 발생시 에러 핸들링
### 📄 설명

현재는 플러그인에서 오류가 나도 에러 로그만 출력하고 main 함수가 정상적으로 종료됩니다. 따라서 ssufid-sites에서 크롤링 오류가 발생해도 다음 단계(배포)를 시도한다는 문제 발생합니다.
예: https://github.com/yourssu/ssufid-sites/actions/runs/14211678572/job/39819908614#step:7:1

해결 방안
- 플러그인의 `crawl` 메서드에서 오류 발생 시 최대 3번까지 재시도 -> 3번 모두 오류 발생 시 실패
- 각 플러그인은 서로에게 영향을 끼치지 않도록
  - 하나의 플러그인이 실패해도 성공한 플러그인은 배포되어야 함

### ✅ 작업할 내용

- [ ] `run` 메서드에서 최대 3번 `crawl` 호출 시도하기 -> 모두 실패 시 `run`에서 오류 반환
- [ ] main 함수에서 모든 플러그인이 실패했다면 비정상 종료
- [x] 기존 플러그인의 output은 유지하고 성공한 플러그인의 output만 업데이트 되도록 ssufid-sites workflow 수정 : yourssu/ssufid-sites#3

### 🙋🏻 참고 자료

_No response_

Diff:
diff --git a/src/core/mod.rs b/src/core/mod.rs
index 43bc271..58e2aa4 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -51,6 +51,7 @@ pub struct SsufidCore {
 
 impl SsufidCore {
     pub const POST_COUNT_LIMIT: u32 = 100;
+    pub const RETRY_COUNT: u32 = 3;
 
     pub fn new(cache_dir: &str) -> Self {
         Self {
@@ -59,9 +60,27 @@ impl SsufidCore {
         }
     }
 
+    pub async fn run_with_retry<T: SsufidPlugin>(
+        &self,
+        plugin: &T,
+        posts_limit: u32,
+        retry_count: u32,
+    ) -> Result<SsufidSiteData, Error> {
+        for attempt in 1..=retry_count {
+            let result = self
+                .run(plugin, posts_limit)
+                .await
+                .inspect_err(|e| eprintln!("{:?} [Attempt {}/{}]", e, attempt, retry_count));
+            if result.is_ok() {
+                return result;
+            }
+        }
+        Err(Error::AttemptsExceeded(T::IDENTIFIER))
+    }
+
     pub async fn run<T: SsufidPlugin>(
         &self,
-        plugin: T,
+        plugin: &T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
         let new_entries = plugin.crawl(posts_limit).await?;
diff --git a/src/error.rs b/src/error.rs
index 69d2f95..635e0de 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -10,6 +10,9 @@ pub enum Error {
     #[error("Serialization error: {0}")]
     Serialization(#[from] serde_json::Error),
 
+    #[error("Attempts exceeded error: {0}")]
+    AttemptsExceeded(&'static str),
+
     #[error(transparent)]
     Plugin(Box<PluginError>),
 }
diff --git a/src/main.rs b/src/main.rs
index f4bd0f0..a6ad0f1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16,15 +16,25 @@ async fn main() -> eyre::Result<()> {
     let out_dir = Path::new("./out");
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
+    let tasks_len = tasks.len();
 
-    join_all(tasks).await.into_iter().for_each(|result| {
-        if let Err(err) = result {
-            eprintln!("{:?}", err);
-        }
-    });
+    // Run all tasks and collect errors
+    let errors: Vec<eyre::Report> = join_all(tasks)
+        .await
+        .into_iter()
+        .filter_map(|r| r.err())
+        .collect();
 
     core.save_cache().await?;
-    Ok(())
+
+    if errors.is_empty() {
+        Ok(())
+    } else {
+        for err in &errors {
+            eprintln!("{:?}", err);
+        }
+        Err(eyre::eyre!("{} of {} Run failed", errors.len(), tasks_len))
+    }
 }
 
 async fn save_run<T: SsufidPlugin>(
@@ -32,7 +42,13 @@ async fn save_run<T: SsufidPlugin>(
     base_out_dir: &Path,
     plugin: T,
 ) -> eyre::Result<()> {
-    let site = core.run(plugin, SsufidCore::POST_COUNT_LIMIT).await?;
+    let site = core
+        .run_with_retry(
+            &plugin,
+            SsufidCore::POST_COUNT_LIMIT,
+            SsufidCore::RETRY_COUNT,
+        )
+        .await?;
     let json = serde_json::to_string_pretty(&site)?;
 
     // Use synchronous BufWriter to write pretty xml string.


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/main.rs:34**
* Instead of calling panic! in the main function, consider returning an error as a Result to allow for graceful shutdown and better error handling.
```
panic!("all plugin failed")
```
**src/core/mod.rs:76**
* [nitpick] Consider making the error message more descriptive by including context such as the total number of allowed attempts, e.g., "[Plugin {}] Attempt {}/3 failed: {:?}".
```
eprintln!("{} attempt: {} - {:?}", T::IDENTIFIER, crawl_attempt, e);
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-06T12:38:50Z
user: login: EATSTEAK
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-06T12:49:34Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-06T12:51:31Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T12:54:41Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-06T12:54:48Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-06T12:56:14Z
user: login: 2wndrhs
body: 수고하셨습니다 👏
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T13:24:54Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-06T13:27:35Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-06T13:28:20Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T13:59:32Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T00:04:28Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T05:47:15Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T06:14:04Z
user: login: 2wndrhs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T09:45:27Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T09:54:32Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T10:23:36Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T11:23:53Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T11:27:58Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T11:31:58Z
user: login: 2wndrhs
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-07T12:43:56Z
user: login: EATSTEAK
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:46:25Z
user: login: EATSTEAK
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:47:38Z

Review comments:
diff_hunk: @@ -64,7 +65,27 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
-        let new_entries = plugin.crawl(posts_limit).await?;
+        let mut crawl_attempt = 0;
+        let new_entries = loop {
+            crawl_attempt += 1;
+            match plugin.crawl(posts_limit).await {
+                Ok(posts) => {
+                    break posts;
+                }
+                Err(e) => {
+                    eprintln!(
+                        "[Plugin {}] Attempt: {}/{} - {:?}",
path: src/core/mod.rs
user: login: EATSTEAK
body: p4) `Plugin` prefix는 빼도 좋지 않을까요? `[scatch.ssu.ac.kr] Attempt: 1/3 - {:?}` 요런 식으로?
created_at: 2025-04-06T12:49:23Z
author_association: COLLABORATOR
diff_hunk: @@ -64,7 +65,27 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
-        let new_entries = plugin.crawl(posts_limit).await?;
+        let mut crawl_attempt = 0;
+        let new_entries = loop {
path: src/core/mod.rs
user: login: EATSTEAK
body: p2) retry 로직이 `run` 함수에 있는게 맞을까요? `run` 함수가 retry 한다는 것이 너무 암시적인 것 같습니다. `run_with_retry` 같은 함수를 하나 만들거나, main에 구현하는게 좋을 것 같아요.
created_at: 2025-04-06T12:51:27Z
author_association: COLLABORATOR
diff_hunk: @@ -64,7 +65,27 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
-        let new_entries = plugin.crawl(posts_limit).await?;
+        let mut crawl_attempt = 0;
+        let new_entries = loop {
+            crawl_attempt += 1;
+            match plugin.crawl(posts_limit).await {
+                Ok(posts) => {
+                    break posts;
+                }
+                Err(e) => {
+                    eprintln!(
+                        "[Plugin {}] Attempt: {}/{} - {:?}",
path: src/core/mod.rs
user: login: cometj03
body: ```
[scatch.ssu.ac.kr] Attempt: 1/3 - PluginError { kind: Parse, plugin: "scatch.ssu.ac.kr", message: "msg" }
```
직접 출력하면 이렇게 IDENTIFIER가 겹치게 돼서 조금 고민이네요. kind랑 message만 출력하도록 하는건 어떨까요?
created_at: 2025-04-06T12:54:41Z
author_association: MEMBER
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: EATSTEAK
body: p3) 하나라도 실패하더라도 exit code 1로 나가야 하지 않을까요? `for_each`로 에러를 그냥 소비하지 않고, map 등으로 aggregate 해서 expect나 Err를 리턴하여 main 함수를 종료하는 식으로 끝내면 더 좋을 것 같습니다.
created_at: 2025-04-06T12:54:46Z
author_association: COLLABORATOR
diff_hunk: @@ -64,7 +65,27 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
-        let new_entries = plugin.crawl(posts_limit).await?;
+        let mut crawl_attempt = 0;
+        let new_entries = loop {
+            crawl_attempt += 1;
+            match plugin.crawl(posts_limit).await {
+                Ok(posts) => {
+                    break posts;
+                }
+                Err(e) => {
+                    eprintln!(
+                        "[Plugin {}] Attempt: {}/{} - {:?}",
path: src/core/mod.rs
user: login: EATSTEAK
body: PluginError 정보에 Attempt 수만 추가해서 출력하면 좋을 것 같네요.
created_at: 2025-04-06T12:56:14Z
author_association: COLLABORATOR
diff_hunk: @@ -64,7 +65,27 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
-        let new_entries = plugin.crawl(posts_limit).await?;
+        let mut crawl_attempt = 0;
+        let new_entries = loop {
path: src/core/mod.rs
user: login: 2wndrhs
body: 모든 플러그인에 일관적인 재시도 로직을 적용하기 위해서 SsufidCore에서 재시도 로직을 담당하는건 좋은 것 같아요.

하지만 저도 EATSTEAK 말처럼 재시도 로직이 암시적이라고 느껴져서 별도의 함수로 분리하면 좋을 것 같네요🤔
created_at: 2025-04-06T13:11:04Z
author_association: MEMBER
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: 2wndrhs
body: 일부 플러그인에서 크롤링이 실패하더라도 크롤링이 성공한 플러그인의 결과는 반영되도록 하는게 좋지 않을까요?

명시적으로 `panic!`을 호출하는 것보다는 `Iterator`의 메서드를 활용해서 더 가독성 좋게 개선할 수 있을 것 같아요! (예) `filter()`로 `Ok`를 반환한 Result만 모아서 배열의 길이가 0일 때만 `Err`를 반환)
created_at: 2025-04-06T13:24:34Z
author_association: MEMBER
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: EATSTEAK
body: > @2wndrhs 일부 플러그인에서 크롤링이 실패하더라도 크롤링이 성공한 플러그인의 결과는 반영되도록 하는게 좋지 않을까요?

이 부분은 동의해요. 하지만 전체 크롤링이 끝난 뒤에 하나라도 실패한 플러그인이 있으면 exit code가 1이 되어야 한다는 의미였어요(중간에 강종 X). 제가 헷갈리게 적었나 보네요 ㅎㅎ;;
created_at: 2025-04-06T13:27:35Z
author_association: COLLABORATOR
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: EATSTEAK
body: > filter()로 Ok를 반환한 Result만 모아서 배열의 길이가 0일 때만 Err를 반환)

filter로 `Err`만 모으면 에러 정보와 실패 여부를 한번에 알 수 있어서 좋을 것 같네요.
created_at: 2025-04-06T13:28:20Z
author_association: COLLABORATOR
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: cometj03
body: > 하지만 전체 크롤링이 끝난 뒤에 하나라도 실패한 플러그인이 있으면 exit code가 1이 되어야 한다는 의미였어요(중간에 강종 X)

exit code가 1이면 워크플로에서 다음 스텝을 수행하지 않는 걸로 아는데 맞을까요?
플러그인의 결과가 반영되려면 다음 스텝에 있는 deploy-pages를 수행해야 해서 모두 실패한 게 아니라면 exit code는 0이어야 할 것 같습니다!
created_at: 2025-04-06T13:59:32Z
author_association: MEMBER
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: EATSTEAK
body: > @cometj03 exit code가 1이면 워크플로에서 다음 스텝을 수행하지 않는 걸로 아는데 맞을까요?

워크플로에서 `if: always()` 단 step은 결과와 별개로 수행하는 기능이 있습니다. 워크플
로와는 별개로 생각했을 때 하나라도 실패 시 exit code 가 1이 되는 동작은 정상이라고 생각해요.

그래서 데몬 개발 시에는 워크플로를 생각하지 않고, 워크플로가 데몬의 동작에 맞추어 작성되어야 하지 않을까 싶네요.
created_at: 2025-04-07T00:04:28Z
author_association: COLLABORATOR
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: cometj03
body: Err만 모은 벡터가 비어있지 않은 경우 exit code 1으로 종료하도록 수정했습니다
https://github.com/yourssu/ssufid/pull/59/commits/dbd86ee22fda7d31a37335f7647f4b59c08fb6a3
created_at: 2025-04-07T05:47:15Z
author_association: MEMBER
diff_hunk: @@ -64,7 +65,27 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
     ) -> Result<SsufidSiteData, Error> {
-        let new_entries = plugin.crawl(posts_limit).await?;
+        let mut crawl_attempt = 0;
+        let new_entries = loop {
path: src/core/mod.rs
user: login: cometj03
body: `crawl_with_retry`라는 일반 함수로 분리했습니다

https://github.com/yourssu/ssufid/pull/59/commits/2a86b8eb6e370e6b330b1e63b196bb279ca9f7ec
created_at: 2025-04-07T06:14:03Z
author_association: MEMBER
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: 2wndrhs
body: `Err`를 반환하지 않고 `exit(1)`을 호출하여 종료하는 이유가 있을까요?

`exit()`함수는 소멸자 호출이 이루어지지 않을 수 있다고 해서요.

> Note that because this function never returns, and that it terminates the process, no destructors on the current stack or any other thread’s stack will be run.
> https://doc.rust-lang.org/std/process/fn.exit.html

```rust
return Err(eyre::eyre!("Some tasks failed"));
```
created_at: 2025-04-07T09:45:26Z
author_association: MEMBER
diff_hunk: @@ -64,7 +65,8 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
path: src/core/mod.rs
user: login: EATSTEAK
body: p4) 여전히 사용자 입장에서는 retry가 되는지 알기 어려울 것 같은데, 커스터마이징 기능 겸 해서 run에 retry_count도 추가하는 건 어떨까요?
created_at: 2025-04-07T09:48:38Z
author_association: COLLABORATOR
diff_hunk: @@ -152,6 +154,28 @@ fn inject_update_date(
         .collect()
 }
 
+async fn crawl_with_retry<T: SsufidPlugin>(
+    plugin: T,
+    posts_limit: u32,
+    max_attempt: u32,
+) -> Result<Vec<SsufidPost>, PluginError> {
+    let mut attempt = 0;
path: src/core/mod.rs
user: login: EATSTEAK
body: p3) for를 이용해서 외부에 mutable 변수를 만들지 않고 반복할 수 있을 것 같아요. 컴파일러가 더 최적화도 할 수 있지 않을까요(추측).
created_at: 2025-04-07T09:49:59Z
author_association: COLLABORATOR
diff_hunk: @@ -17,13 +17,23 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
-    join_all(tasks).await.into_iter().for_each(|result| {
-        if let Err(err) = result {
-            eprintln!("{:?}", err);
-        }
-    });
+    let failed_results: Vec<eyre::Result<()>> = join_all(tasks)
+        .await
+        .into_iter()
+        .filter(|result| result.is_err())
+        .collect();
 
     core.save_cache().await?;
+
+    if !failed_results.is_empty() {
+        for result in failed_results {
+            if let Err(err) = result {
+                eprintln!("{:?}", err);
+            }
+        }
+        std::process::exit(1);
path: src/main.rs
user: login: EATSTEAK
body: p2) Err를 리턴하면 1로 종료하는 만큼 Err 를 리턴하면서 summary도 보여주는 건 어떨까요? (1 of 3 Run failed 처럼요)
created_at: 2025-04-07T09:50:43Z
author_association: COLLABORATOR
diff_hunk: @@ -152,6 +154,28 @@ fn inject_update_date(
         .collect()
 }
 
+async fn crawl_with_retry<T: SsufidPlugin>(
+    plugin: T,
+    posts_limit: u32,
+    max_attempt: u32,
+) -> Result<Vec<SsufidPost>, PluginError> {
+    let mut attempt = 0;
+    loop {
+        attempt += 1;
+        match plugin.crawl(posts_limit).await {
+            Ok(r) => {
+                return Ok(r);
+            }
+            Err(e) => {
+                eprintln!("{:?} [Attempt {}/{}]", e, attempt, max_attempt);
+                if attempt >= max_attempt {
+                    return Err(e);
+                }
+            }
path: src/core/mod.rs
user: login: EATSTEAK
body: p5) inspect_err와 if를 활용하면 에러 리턴과 메시지 표시 로직을 분리할 수 있을 것 같아요.
created_at: 2025-04-07T09:54:07Z
author_association: COLLABORATOR
diff_hunk: @@ -17,14 +17,22 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
+    let mut is_successful = false;
     join_all(tasks).await.into_iter().for_each(|result| {
         if let Err(err) = result {
             eprintln!("{:?}", err);
+        } else {
+            is_successful = true;
         }
     });
 
     core.save_cache().await?;
-    Ok(())
+
+    if is_successful {
+        Ok(())
+    } else {
+        panic!("all plugin failed")
path: src/main.rs
user: login: cometj03
body: 아 `eyre::Result`를 반환하는 함수에서 명시적으로 Err 반환하는 방법을 몰라서 exit 한거였어요🥲 
말씀해주신 방법대로 수정하는 게 좋겠네요..!
created_at: 2025-04-07T10:23:36Z
author_association: MEMBER
diff_hunk: @@ -64,7 +65,8 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
path: src/core/mod.rs
user: login: cometj03
body: 사용자라는 게 main 함수 말씀 맞을까요?
그냥 맨 처음에 말씀해주신 것처럼 `run` 자체를 여러 번 호출하는 `run_with_retry` 메서드를 따로 두는 게 좋을 것 같아서 아래 커밋처럼 수정했습니다.

https://github.com/yourssu/ssufid/pull/59/commits/692dc9eae17d01ee391f59a19de8aa5e3710f90e
created_at: 2025-04-07T11:23:53Z
author_association: MEMBER
diff_hunk: @@ -17,13 +17,23 @@ async fn main() -> eyre::Result<()> {
 
     let tasks = vec![save_run(core.clone(), out_dir, SsuCatchPlugin::default())];
 
-    join_all(tasks).await.into_iter().for_each(|result| {
-        if let Err(err) = result {
-            eprintln!("{:?}", err);
-        }
-    });
+    let failed_results: Vec<eyre::Result<()>> = join_all(tasks)
+        .await
+        .into_iter()
+        .filter(|result| result.is_err())
+        .collect();
 
     core.save_cache().await?;
+
+    if !failed_results.is_empty() {
+        for result in failed_results {
+            if let Err(err) = result {
+                eprintln!("{:?}", err);
+            }
+        }
+        std::process::exit(1);
path: src/main.rs
user: login: cometj03
body: main 함수가 반환하는 Err의 메시지로 넣어주었습니다.

https://github.com/yourssu/ssufid/pull/59/commits/6afe1d4f0171af46ff0278b4fe8e6a4d4b31eafa
created_at: 2025-04-07T11:27:58Z
author_association: MEMBER
diff_hunk: @@ -152,6 +154,28 @@ fn inject_update_date(
         .collect()
 }
 
+async fn crawl_with_retry<T: SsufidPlugin>(
+    plugin: T,
+    posts_limit: u32,
+    max_attempt: u32,
+) -> Result<Vec<SsufidPost>, PluginError> {
+    let mut attempt = 0;
+    loop {
+        attempt += 1;
+        match plugin.crawl(posts_limit).await {
+            Ok(r) => {
+                return Ok(r);
+            }
+            Err(e) => {
+                eprintln!("{:?} [Attempt {}/{}]", e, attempt, max_attempt);
+                if attempt >= max_attempt {
+                    return Err(e);
+                }
+            }
path: src/core/mod.rs
user: login: cometj03
body: 반영했습니다. 이정도도 충분할 것 같긴 한데 조금 더 단순화 할 수 있을까요?

https://github.com/yourssu/ssufid/pull/59/commits/41ac0e8e5402cf6f61411f3ebddb4f0e1a3ef053
created_at: 2025-04-07T11:31:58Z
author_association: MEMBER
diff_hunk: @@ -64,7 +65,8 @@ impl SsufidCore {
         plugin: T,
         posts_limit: u32,
path: src/core/mod.rs
user: login: EATSTEAK
body: 넵. 라이브러리 = Ssufid, 사용자 = executable 를 의미했어요.
created_at: 2025-04-07T12:45:49Z
author_association: COLLABORATOR
diff_hunk: @@ -152,6 +154,28 @@ fn inject_update_date(
         .collect()
 }
 
+async fn crawl_with_retry<T: SsufidPlugin>(
+    plugin: T,
+    posts_limit: u32,
+    max_attempt: u32,
+) -> Result<Vec<SsufidPost>, PluginError> {
+    let mut attempt = 0;
+    loop {
+        attempt += 1;
+        match plugin.crawl(posts_limit).await {
+            Ok(r) => {
+                return Ok(r);
+            }
+            Err(e) => {
+                eprintln!("{:?} [Attempt {}/{}]", e, attempt, max_attempt);
+                if attempt >= max_attempt {
+                    return Err(e);
+                }
+            }
path: src/core/mod.rs
user: login: EATSTEAK
body: 이정도면 저도 생각나는게 없네요. 고생했습니다!
created_at: 2025-04-07T12:47:38Z
author_association: COLLABORATOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-06T12:39:42Z
body: ## [Codecov](https://app.codecov.io/gh/yourssu/ssufid/pull/59?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu) Report
Attention: Patch coverage is `0%` with `40 lines` in your changes missing coverage. Please review.
> Project coverage is 73.49%. Comparing base [(`fa476b4`)](https://app.codecov.io/gh/yourssu/ssufid/commit/fa476b45b121db8507d4dcd53befdc13855ceb56?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu) to head [(`f6af2bc`)](https://app.codecov.io/gh/yourssu/ssufid/commit/f6af2bcbcf059429bade5eb4b81a4395a8bac320?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/yourssu/ssufid/pull/59?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu) | Patch % | Lines |
|---|---|---|
| [src/main.rs](https://app.codecov.io/gh/yourssu/ssufid/pull/59?src=pr&el=tree&filepath=src%2Fmain.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu#diff-c3JjL21haW4ucnM=) | 0.00% | [23 Missing :warning: ](https://app.codecov.io/gh/yourssu/ssufid/pull/59?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu) |
| [src/core/mod.rs](https://app.codecov.io/gh/yourssu/ssufid/pull/59?src=pr&el=tree&filepath=src%2Fcore%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu#diff-c3JjL2NvcmUvbW9kLnJz) | 0.00% | [17 Missing :warning: ](https://app.codecov.io/gh/yourssu/ssufid/pull/59?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #59      +/-   ##
==========================================
- Coverage   77.08%   73.49%   -3.60%     
==========================================
  Files           5        5              
  Lines         646      679      +33     
==========================================
+ Hits          498      499       +1     
- Misses        148      180      +32     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/yourssu/ssufid/pull/59?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yourssu).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: EATSTEAK
created_at: 2025-04-06T12:47:27Z
body: @cometj03 PR 제목이 뭔가 잘렸어요..!
author_association: COLLABORATOR