fix: improve survey response queue robustness to prevent data loss (#6959)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Johannes
2025-12-14 00:18:11 -08:00
committed by GitHub
parent 6bc7db852c
commit 75cdb25d27
19 changed files with 165 additions and 21 deletions

View File

@@ -28,6 +28,7 @@
"ranking_items": "عناصر الترتيب",
"respondents_will_not_see_this_card": "لن يرى المستجيبون هذه البطاقة",
"retry": "إعادة المحاولة",
"retrying": "إعادة المحاولة...",
"select_a_date": "اختر تاريخًا",
"select_for_ranking": "اختر {item} للترتيب",
"sending_responses": "جارٍ إرسال الردود...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Ranking-Elemente",
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
"retry": "Wiederholen",
"retrying": "Wird wiederholt...",
"select_a_date": "Datum auswählen",
"select_for_ranking": "{item} für Ranking auswählen",
"sending_responses": "Antworten werden gesendet...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Ranking Items",
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"retrying": "Retrying...",
"select_a_date": "Select a date",
"select_for_ranking": "Select {item} for ranking",
"sending_responses": "Sending responses...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Elementos de clasificación",
"respondents_will_not_see_this_card": "Los encuestados no verán esta tarjeta",
"retry": "Reintentar",
"retrying": "Reintentando...",
"select_a_date": "Seleccionar una fecha",
"select_for_ranking": "Seleccionar {item} para clasificación",
"sending_responses": "Enviando respuestas...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Éléments de classement",
"respondents_will_not_see_this_card": "Les répondants ne verront pas cette carte",
"retry": "Réessayer",
"retrying": "Nouvelle tentative...",
"select_a_date": "Sélectionner une date",
"select_for_ranking": "Sélectionner {item} pour le classement",
"sending_responses": "Envoi des réponses...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "रैंकिंग आइटम",
"respondents_will_not_see_this_card": "उत्तरदाता इस कार्ड को नहीं देखेंगे",
"retry": "पुनः प्रयास करें",
"retrying": "पुनः प्रयास कर रहे हैं...",
"select_a_date": "एक तिथि चुनें",
"select_for_ranking": "रैंकिंग के लिए {item} चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Elementi di classifica",
"respondents_will_not_see_this_card": "I rispondenti non vedranno questa scheda",
"retry": "Riprova",
"retrying": "Riprovando...",
"select_a_date": "Seleziona una data",
"select_for_ranking": "Seleziona {item} per la classifica",
"sending_responses": "Invio risposte in corso...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "ランキング項目",
"respondents_will_not_see_this_card": "回答者はこのカードを見ることができません",
"retry": "再試行",
"retrying": "再試行中...",
"select_a_date": "日付を選択",
"select_for_ranking": "ランキング用に{item}を選択",
"sending_responses": "回答を送信中...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Items rangschikken",
"respondents_will_not_see_this_card": "Respondenten zien deze kaart niet",
"retry": "Opnieuw proberen",
"retrying": "Opnieuw proberen...",
"select_a_date": "Selecteer een datum",
"select_for_ranking": "Selecteer {item} voor rangschikking",
"sending_responses": "Reacties verzenden...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Itens de classificação",
"respondents_will_not_see_this_card": "Os respondentes não verão este cartão",
"retry": "Tentar novamente",
"retrying": "Tentando novamente...",
"select_a_date": "Selecione uma data",
"select_for_ranking": "Selecione {item} para classificação",
"sending_responses": "Enviando respostas...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Clasificare articole",
"respondents_will_not_see_this_card": "Respondenții nu vor vedea acest card",
"retry": "Reîncearcă",
"retrying": "Se reîncearcă...",
"select_a_date": "Selectează o dată",
"select_for_ranking": "Selectează {item} pentru clasificare",
"sending_responses": "Trimiterea răspunsurilor...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Ранжирование элементов",
"respondents_will_not_see_this_card": "Респонденты не увидят эту карточку",
"retry": "Повторить",
"retrying": "Повторная попытка...",
"select_a_date": "Выберите дату",
"select_for_ranking": "Выберите {item} для ранжирования",
"sending_responses": "Отправка ответов...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Rangordna objekt",
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
"retry": "Försök igen",
"retrying": "Försöker igen...",
"select_a_date": "Välj ett datum",
"select_for_ranking": "Välj {item} för rangordning",
"sending_responses": "Skickar svar...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "Reyting elementlari",
"respondents_will_not_see_this_card": "Javob beruvchilar ushbu kartani ko'rmaydi",
"retry": "Qayta urinib ko'ring",
"retrying": "Qayta urinilmoqda...",
"select_a_date": "Sanani tanlang",
"select_for_ranking": "Reyting uchun {item} ni tanlang",
"sending_responses": "Javoblar yuborilmoqda...",

View File

@@ -28,6 +28,7 @@
"ranking_items": "排名项目",
"respondents_will_not_see_this_card": "受访者将不会看到此卡片",
"retry": "重试",
"retrying": "重试中...",
"select_a_date": "选择日期",
"select_for_ranking": "选择{item}进行排名",
"sending_responses": "正在发送响应...",

View File

@@ -5,12 +5,18 @@ import { SubmitButton } from "@/components/buttons/submit-button";
import { processResponseData } from "@/lib/response";
interface ResponseErrorComponentProps {
questions: TSurveyElement[];
responseData: TResponseData;
onRetry?: () => void;
readonly questions: TSurveyElement[];
readonly responseData: TResponseData;
readonly onRetry?: () => void;
readonly isRetrying?: boolean;
}
export function ResponseErrorComponent({ questions, responseData, onRetry }: ResponseErrorComponentProps) {
export function ResponseErrorComponent({
questions,
responseData,
onRetry,
isRetrying = false,
}: ResponseErrorComponentProps) {
const { t } = useTranslation();
return (
<div className="fb-flex fb-flex-col fb-bg-white fb-p-4">
@@ -23,14 +29,14 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
{t("common.please_retry_now_or_try_again_later")}
</p>
<div className="fb-mt-4 fb-rounded-lg fb-border fb-border-slate-200 fb-bg-slate-100 fb-px-4 fb-py-5">
<div className="fb-flex fb-max-h-36 fb-flex-1 fb-flex-col fb-space-y-3 fb-overflow-y-scroll">
<div className="fb-flex fb-max-h-48 fb-flex-1 fb-flex-col fb-space-y-2 fb-overflow-y-scroll">
{questions.map((question, index) => {
const response = responseData[question.id];
if (!response) return;
return (
<div className="fb-flex fb-flex-col" key={`response-${index.toString()}`}>
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`${t("common.question")} ${(index + 1).toString()}`}</span>
<span className="fb-mt-1 fb-text-sm fb-font-semibold fb-leading-6 fb-text-slate-900">
<span className="fb-text-sm fb-leading-5 fb-text-slate-900">{`${t("common.question")} ${(index + 1).toString()}`}</span>
<span className="fb-text-sm fb-font-semibold fb-leading-5 fb-text-slate-900">
{processResponseData(response)}
</span>
</div>
@@ -40,11 +46,14 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
</div>
<div className="fb-mt-4 fb-flex fb-flex-1 fb-flex-row fb-items-center fb-justify-end fb-space-x-2">
<SubmitButton
buttonLabel={t("common.retry")}
buttonLabel={isRetrying ? t("common.retrying") : t("common.retry")}
isLastQuestion={false}
onClick={() => {
onRetry?.();
if (!isRetrying) {
onRetry?.();
}
}}
disabled={isRetrying}
/>
</div>
</div>

View File

@@ -110,7 +110,7 @@ export function Survey({
{
appUrl,
environmentId,
retryAttempts: 2,
retryAttempts: 4,
onResponseSendingFailed: (_, errorCode?: TResponseErrorCodesEnum) => {
setShowError(true);
setErrorType(errorCode);
@@ -185,6 +185,7 @@ export function Survey({
const [errorType, setErrorType] = useState<TResponseErrorCodesEnum | undefined>(undefined);
const [showError, setShowError] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(
!getSetIsResponseSendingFinished
);
@@ -710,11 +711,16 @@ export function Survey({
setBlockId(prevBlockId);
};
const retryResponse = () => {
const retryResponse = async () => {
if (responseQueue) {
setShowError(false);
setErrorType(undefined);
void responseQueue.processQueue();
setIsRetrying(true);
const result = await responseQueue.processQueue();
setIsRetrying(false);
if (result.success) {
setShowError(false);
setErrorType(undefined);
}
} else {
onRetry?.();
}
@@ -726,9 +732,10 @@ export function Survey({
case TResponseErrorCodesEnum.ResponseSendingError:
return (
<ResponseErrorComponent
responseData={responseData}
responseData={responseQueue?.getUnsentData() ?? responseData}
questions={questions}
onRetry={retryResponse}
isRetrying={isRetrying}
/>
);
case TResponseErrorCodesEnum.RecaptchaError:

View File

@@ -55,8 +55,10 @@ export class ResponseQueue {
this.processQueue();
}
async processQueue() {
if (this.isRequestInProgress || this.queue.length === 0) return;
async processQueue(): Promise<{ success: boolean }> {
if (this.isRequestInProgress || this.queue.length === 0) {
return { success: false };
}
this.isRequestInProgress = true;
const responseUpdate = this.queue[0];
@@ -65,8 +67,10 @@ export class ResponseQueue {
if (result.success) {
this.handleSuccessfulResponse(responseUpdate, result.quotaFullResponse);
return { success: true };
} else {
this.handleFailedResponse(responseUpdate, result.isRecaptchaError);
return { success: false };
}
}
@@ -88,18 +92,41 @@ export class ResponseQueue {
quotaFullResponse = res.data;
}
if (attempts > 0) {
console.log(`Formbricks: Response sent successfully after ${attempts + 1} attempts`);
}
return { success: true, quotaFullResponse: quotaFullResponse ?? undefined };
}
if (this.isRecaptchaError(res.error)) {
console.error("Formbricks: Recaptcha verification failed", {
error: res.error,
responseId: this.surveyState.responseId,
});
return { success: false, isRecaptchaError: true };
}
console.error(`Formbricks: Failed to send response. Retrying... ${attempts}`);
await delay(1000);
console.error(`Formbricks: Response send failed`, {
attempt: attempts + 1,
maxAttempts: this.config.retryAttempts,
error: res.error,
responseId: this.surveyState.responseId,
queueLength: this.queue.length,
});
// Exponential backoff: 1s, 2s, 4s, 8s
const backoffMs = 1000 * Math.pow(2, attempts);
await delay(backoffMs);
attempts++;
}
console.error(`Formbricks: Failed to send response after ${this.config.retryAttempts} attempts`, {
queueLength: this.queue.length,
responseId: this.surveyState.responseId,
surveyId: this.surveyState.surveyId,
});
return { success: false, isRecaptchaError: false };
}
@@ -133,7 +160,6 @@ export class ResponseQueue {
return;
}
console.error(`Failed to send response after ${this.config.retryAttempts} attempts.`);
this.config.onResponseSendingFailed?.(responseUpdate, TResponseErrorCodesEnum.ResponseSendingError);
}
@@ -198,4 +224,9 @@ export class ResponseQueue {
updateSurveyState(surveyState: SurveyState) {
this.surveyState = surveyState;
}
// get unsent response data from queue
getUnsentData(): TResponseUpdate["data"] {
return this.queue.reduce((acc, item) => ({ ...acc, ...item.data }), {});
}
}

View File

@@ -82,7 +82,7 @@ describe("ResponseQueue", () => {
});
test("add accumulates response, sets survey state, and processes queue", async () => {
vi.spyOn(queue, "processQueue").mockImplementation(() => Promise.resolve());
vi.spyOn(queue, "processQueue").mockImplementation(() => Promise.resolve({ success: true }));
queue.add(responseUpdate);
expect(surveyState.accumulateResponse).toHaveBeenCalledWith(responseUpdate);
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
@@ -192,4 +192,86 @@ describe("ResponseQueue", () => {
queue.updateSurveyState(newState);
expect(queue["surveyState"]).toBe(newState);
});
test("processQueueAsync returns success false if queue empty", async () => {
const result = await queue.processQueue();
expect(result.success).toBe(false);
});
test("processQueueAsync returns success false if request in progress", async () => {
queue["isRequestInProgress"] = true;
const result = await queue.processQueue();
expect(result.success).toBe(false);
});
test("processQueueAsync returns success true on successful send", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(ok(true));
const result = await queue.processQueue();
expect(result.success).toBe(true);
expect(queue.queue.length).toBe(0);
});
test("processQueueAsync returns success false after max attempts", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(
err({
code: "internal_server_error",
message: "An error occurred while sending the response.",
status: 500,
})
);
const result = await queue.processQueue();
expect(result.success).toBe(false);
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
responseUpdate,
TResponseErrorCodesEnum.ResponseSendingError
);
});
test("processQueueAsync returns success false on recaptcha error", async () => {
queue.queue.push(responseUpdate);
vi.spyOn(queue, "sendResponse").mockResolvedValue(
err({
code: "internal_server_error",
message: "An error occurred while sending the response.",
status: 500,
details: {
code: "recaptcha_verification_failed",
},
})
);
const result = await queue.processQueue();
expect(result.success).toBe(false);
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
responseUpdate,
TResponseErrorCodesEnum.RecaptchaError
);
});
test("getUnsentData returns empty object when queue is empty", () => {
const unsentData = queue.getUnsentData();
expect(unsentData).toEqual({});
});
test("getUnsentData returns data from single item in queue", () => {
queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
const unsentData = queue.getUnsentData();
expect(unsentData).toEqual({ q1: "answer1" });
});
test("getUnsentData aggregates data from multiple items in queue", () => {
queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
queue.queue.push({ data: { q2: "answer2" }, hiddenFields: {}, finished: false });
queue.queue.push({ data: { q3: "answer3" }, hiddenFields: {}, finished: true });
const unsentData = queue.getUnsentData();
expect(unsentData).toEqual({ q1: "answer1", q2: "answer2", q3: "answer3" });
});
test("getUnsentData overwrites duplicate keys with latest value", () => {
queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
queue.queue.push({ data: { q1: "updated_answer1", q2: "answer2" }, hiddenFields: {}, finished: false });
const unsentData = queue.getUnsentData();
expect(unsentData).toEqual({ q1: "updated_answer1", q2: "answer2" });
});
});