Skip to content

Commit c666966

Browse files
authored
fix: podcast failure recovery and retry (1.7.3) (#595)
* fix: surface podcast errors and enable retry for failed episodes Fixes #335, #300 Re-raise exceptions in podcast command so surreal-commands marks jobs as failed instead of completed. Surface error_message in API responses and add a retry endpoint that deletes the failed episode and re-submits the generation job. Frontend shows error details on failed episodes with a retry button. Translations added for all 8 locales. * fix: bump podcast-creator to >= 0.10 Fixes #302 * chore: release 1.7.3 - podcast failure recovery and retry Bump podcast-creator to >= 0.11.2, disable automatic retries for podcast generation to prevent duplicate episodes, and bump version to 1.7.3. Fixes #211, #218, #185, #355, #300, #302 * fix: resolve TypeScript error in handleRetry return type
1 parent 96525b4 commit c666966

File tree

19 files changed

+239
-25
lines changed

19 files changed

+239
-25
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.7.3] - 2026-02-17
11+
12+
### Added
13+
- Retry button for failed podcast episodes in the UI (#211, #218)
14+
- Error details displayed on failed podcast episodes (#185, #355)
15+
- `POST /podcasts/episodes/{id}/retry` API endpoint for re-submitting failed episodes
16+
- `error_message` field in podcast episode API responses
17+
18+
### Fixed
19+
- Podcast generation failures now correctly marked as "failed" instead of "completed" (#300, #335)
20+
- Disabled automatic retries for podcast generation to prevent duplicate episode records (#302)
21+
22+
### Dependencies
23+
- Bump podcast-creator to >= 0.11.2
24+
- Bump esperanto to >= 2.19.4
25+
1026
## [1.7.2] - 2026-02-16
1127

1228
### Added

api/routers/podcasts.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class PodcastEpisodeResponse(BaseModel):
2828
outline: Optional[dict] = None
2929
created: Optional[str] = None
3030
job_status: Optional[str] = None
31+
error_message: Optional[str] = None
3132

3233

3334
def _resolve_audio_path(audio_file: str) -> Path:
@@ -94,11 +95,14 @@ async def list_podcast_episodes():
9495
if not episode.command and not episode.audio_file:
9596
continue
9697

97-
# Get job status if available
98+
# Get job status and error message if available
9899
job_status = None
100+
error_message = None
99101
if episode.command:
100102
try:
101-
job_status = await episode.get_job_status()
103+
detail = await episode.get_job_detail()
104+
job_status = detail["status"]
105+
error_message = detail["error_message"]
102106
except Exception:
103107
job_status = "unknown"
104108
else:
@@ -124,6 +128,7 @@ async def list_podcast_episodes():
124128
outline=episode.outline,
125129
created=str(episode.created) if episode.created else None,
126130
job_status=job_status,
131+
error_message=error_message,
127132
)
128133
)
129134

@@ -142,11 +147,14 @@ async def get_podcast_episode(episode_id: str):
142147
try:
143148
episode = await PodcastService.get_episode(episode_id)
144149

145-
# Get job status if available
150+
# Get job status and error message if available
146151
job_status = None
152+
error_message = None
147153
if episode.command:
148154
try:
149-
job_status = await episode.get_job_status()
155+
detail = await episode.get_job_detail()
156+
job_status = detail["status"]
157+
error_message = detail["error_message"]
150158
except Exception:
151159
job_status = "unknown"
152160
else:
@@ -171,6 +179,7 @@ async def get_podcast_episode(episode_id: str):
171179
outline=episode.outline,
172180
created=str(episode.created) if episode.created else None,
173181
job_status=job_status,
182+
error_message=error_message,
174183
)
175184

176185
except Exception as e:
@@ -203,6 +212,63 @@ async def stream_podcast_episode_audio(episode_id: str):
203212
)
204213

205214

215+
@router.post("/podcasts/episodes/{episode_id}/retry")
216+
async def retry_podcast_episode(episode_id: str):
217+
"""Retry a failed podcast episode by deleting it and submitting a new job"""
218+
try:
219+
episode = await PodcastService.get_episode(episode_id)
220+
221+
# Validate episode is in a failed state
222+
detail = await episode.get_job_detail()
223+
if detail["status"] not in ("failed", "error"):
224+
raise HTTPException(
225+
status_code=400,
226+
detail=f"Episode is not in a failed state (current: {detail['status']})",
227+
)
228+
229+
# Extract params for re-submission
230+
ep_profile_name = episode.episode_profile.get("name")
231+
sp_profile_name = episode.speaker_profile.get("name")
232+
episode_name = episode.name
233+
content = episode.content
234+
235+
if not ep_profile_name or not sp_profile_name:
236+
raise HTTPException(
237+
status_code=400,
238+
detail="Cannot retry: episode or speaker profile name missing from stored data",
239+
)
240+
241+
# Delete audio file if any
242+
if episode.audio_file:
243+
audio_path = _resolve_audio_path(episode.audio_file)
244+
if audio_path.exists():
245+
try:
246+
audio_path.unlink()
247+
except Exception as e:
248+
logger.warning(f"Failed to delete audio file {audio_path}: {e}")
249+
250+
# Delete the failed episode
251+
await episode.delete()
252+
253+
# Submit a new job
254+
job_id = await PodcastService.submit_generation_job(
255+
episode_profile_name=ep_profile_name,
256+
speaker_profile_name=sp_profile_name,
257+
episode_name=episode_name,
258+
content=content,
259+
)
260+
261+
return {"job_id": job_id, "message": "Retry submitted successfully"}
262+
263+
except HTTPException:
264+
raise
265+
except Exception as e:
266+
logger.error(f"Error retrying podcast episode: {str(e)}")
267+
raise HTTPException(
268+
status_code=500, detail="Failed to retry episode"
269+
)
270+
271+
206272
@router.delete("/podcasts/episodes/{episode_id}")
207273
async def delete_podcast_episode(episode_id: str):
208274
"""Delete a podcast episode and its associated audio file"""

commands/podcast_commands.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class PodcastGenerationOutput(CommandOutput):
4646
error_message: Optional[str] = None
4747

4848

49-
@command("generate_podcast", app="open_notebook")
49+
@command("generate_podcast", app="open_notebook", retry={"max_attempts": 1})
5050
async def generate_podcast_command(
5151
input_data: PodcastGenerationInput,
5252
) -> PodcastGenerationOutput:
@@ -166,22 +166,19 @@ async def generate_podcast_command(
166166
processing_time=processing_time,
167167
)
168168

169+
except ValueError:
170+
raise
171+
169172
except Exception as e:
170-
processing_time = time.time() - start_time
171173
logger.error(f"Podcast generation failed: {e}")
172174
logger.exception(e)
173175

174-
# Check for specific GPT-5 extended thinking issue
175176
error_msg = str(e)
176177
if "Invalid json output" in error_msg or "Expecting value" in error_msg:
177-
# This often happens with GPT-5 models that use extended thinking (<think> tags)
178-
# and put all output inside thinking blocks
179178
error_msg += (
180179
"\n\nNOTE: This error commonly occurs with GPT-5 models that use extended thinking. "
181180
"The model may be putting all output inside <think> tags, leaving nothing to parse. "
182181
"Try using gpt-4o, gpt-4o-mini, or gpt-4-turbo instead in your episode profile."
183182
)
184183

185-
return PodcastGenerationOutput(
186-
success=False, processing_time=processing_time, error_message=error_msg
187-
)
184+
raise RuntimeError(error_msg) from e

frontend/src/components/podcasts/EpisodeCard.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import { useEffect, useMemo, useState } from 'react'
44
import { formatDistanceToNow } from 'date-fns'
55
import { getDateLocale } from '@/lib/utils/date-locale'
6-
import { InfoIcon, Trash2 } from 'lucide-react'
6+
import { InfoIcon, RefreshCcw, Trash2 } from 'lucide-react'
77

88
import { resolvePodcastAssetUrl } from '@/lib/api/podcasts'
9-
import { EpisodeStatus, PodcastEpisode } from '@/lib/types/podcasts'
9+
import { EpisodeStatus, FAILED_EPISODE_STATUSES, PodcastEpisode } from '@/lib/types/podcasts'
1010
import { cn } from '@/lib/utils'
1111
import {
1212
AlertDialog,
@@ -39,6 +39,8 @@ interface EpisodeCardProps {
3939
episode: PodcastEpisode
4040
onDelete: (episodeId: string) => Promise<void> | void
4141
deleting?: boolean
42+
onRetry?: (episodeId: string) => Promise<void> | void
43+
retrying?: boolean
4244
}
4345

4446
const getSTATUS_META = (t: TranslationKeys): Record<
@@ -136,7 +138,7 @@ function extractTranscriptEntries(transcript: unknown): TranscriptEntry[] {
136138
return []
137139
}
138140

139-
export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
141+
export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }: EpisodeCardProps) {
140142
const { t, language } = useTranslation()
141143
const [audioSrc, setAudioSrc] = useState<string | undefined>()
142144
const [audioError, setAudioError] = useState<string | null>(null)
@@ -217,6 +219,14 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
217219
void onDelete(episode.id)
218220
}
219221

222+
const handleRetry = () => {
223+
if (onRetry) {
224+
void onRetry(episode.id)
225+
}
226+
}
227+
228+
const isFailed = FAILED_EPISODE_STATUSES.includes(episode.job_status as EpisodeStatus)
229+
220230
return (
221231
<Card className="shadow-sm">
222232
<CardContent className="space-y-4 p-4">
@@ -371,6 +381,17 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
371381
</div>
372382
</DialogContent>
373383
</Dialog>
384+
{isFailed && onRetry ? (
385+
<Button
386+
variant="outline"
387+
size="sm"
388+
onClick={handleRetry}
389+
disabled={retrying}
390+
>
391+
<RefreshCcw className={cn('mr-2 h-4 w-4', retrying && 'animate-spin')} />
392+
{retrying ? t.podcasts.retrying : t.podcasts.retry}
393+
</Button>
394+
) : null}
374395
<AlertDialog>
375396
<AlertDialogTrigger asChild>
376397
<Button variant="ghost" size="sm" className="text-destructive">
@@ -401,6 +422,13 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
401422
) : audioError ? (
402423
<p className="text-sm text-destructive">{audioError}</p>
403424
) : null}
425+
426+
{isFailed && episode.error_message ? (
427+
<div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/30">
428+
<p className="text-xs font-medium text-red-800 dark:text-red-300">{t.podcasts.errorDetails}</p>
429+
<p className="mt-1 text-xs whitespace-pre-wrap text-red-700 dark:text-red-400">{episode.error_message}</p>
430+
</div>
431+
) : null}
404432
</CardContent>
405433
</Card>
406434
)

frontend/src/components/podcasts/EpisodesTab.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useCallback, useState } from 'react'
44
import { AlertCircle, Loader2, RefreshCcw } from 'lucide-react'
55

6-
import { useDeletePodcastEpisode, usePodcastEpisodes } from '@/lib/hooks/use-podcasts'
6+
import { useDeletePodcastEpisode, usePodcastEpisodes, useRetryPodcastEpisode } from '@/lib/hooks/use-podcasts'
77
import { EpisodeCard } from '@/components/podcasts/EpisodeCard'
88
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
99
import { Badge } from '@/components/ui/badge'
@@ -62,6 +62,7 @@ export function EpisodesTab() {
6262
isFetching,
6363
} = usePodcastEpisodes()
6464
const deleteEpisode = useDeletePodcastEpisode()
65+
const retryEpisode = useRetryPodcastEpisode()
6566

6667
const handleRefresh = useCallback(() => {
6768
void refetch()
@@ -72,6 +73,11 @@ export function EpisodesTab() {
7273
[deleteEpisode]
7374
)
7475

76+
const handleRetry = useCallback(
77+
async (episodeId: string) => { await retryEpisode.mutateAsync(episodeId) },
78+
[retryEpisode]
79+
)
80+
7581
const emptyState = !isLoading && episodes.length === 0
7682

7783
return (
@@ -158,6 +164,8 @@ export function EpisodesTab() {
158164
episode={episode}
159165
onDelete={handleDelete}
160166
deleting={deleteEpisode.isPending}
167+
onRetry={handleRetry}
168+
retrying={retryEpisode.isPending}
161169
/>
162170
))}
163171
</div>

frontend/src/lib/api/podcasts.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export const podcastsApi = {
3939
await apiClient.delete(`/podcasts/episodes/${episodeId}`)
4040
},
4141

42+
retryEpisode: async (episodeId: string) => {
43+
const response = await apiClient.post<{ job_id: string; message: string }>(
44+
`/podcasts/episodes/${episodeId}/retry`
45+
)
46+
return response.data
47+
},
48+
4249
listEpisodeProfiles: async () => {
4350
const response = await apiClient.get<EpisodeProfile[]>('/episode-profiles')
4451
return response.data

frontend/src/lib/hooks/use-podcasts.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@ export function usePodcastEpisodes(options?: { autoRefresh?: boolean }) {
8080
}
8181
}
8282

83+
export function useRetryPodcastEpisode() {
84+
const queryClient = useQueryClient()
85+
const { toast } = useToast()
86+
const { t } = useTranslation()
87+
88+
return useMutation({
89+
mutationFn: (episodeId: string) => podcastsApi.retryEpisode(episodeId),
90+
onSuccess: async () => {
91+
await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
92+
toast({
93+
title: t.podcasts.retryStarted,
94+
description: t.podcasts.retryStartedDesc,
95+
})
96+
},
97+
onError: (error: unknown) => {
98+
toast({
99+
title: t.podcasts.failedToRetry,
100+
description: getApiErrorKey(error, t.common.error),
101+
variant: 'destructive',
102+
})
103+
},
104+
})
105+
}
106+
83107
export function useDeletePodcastEpisode() {
84108
const queryClient = useQueryClient()
85109
const { toast } = useToast()

frontend/src/lib/locales/en-US/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,12 @@ export const enUS = {
699699
speakerCountMax: "You can configure up to 4 speakers",
700700
delete: "Delete",
701701
failedToDelete: "Failed to delete podcast",
702+
retry: "Retry",
703+
retrying: "Retrying…",
704+
retryStarted: "Retry Started",
705+
retryStartedDesc: "A new podcast generation job has been submitted.",
706+
failedToRetry: "Failed to retry episode",
707+
errorDetails: "Error details",
702708
},
703709
settings: {
704710
contentProcessing: "Content Processing",

frontend/src/lib/locales/fr-FR/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,12 @@ export const frFR = {
699699
speakerCountMax: "Vous pouvez configurer jusqu'à 4 intervenants",
700700
delete: "Supprimer",
701701
failedToDelete: "Échec de la suppression du podcast",
702+
retry: "Réessayer",
703+
retrying: "Nouvelle tentative…",
704+
retryStarted: "Nouvelle tentative lancée",
705+
retryStartedDesc: "Un nouveau travail de génération de podcast a été soumis.",
706+
failedToRetry: "Échec de la nouvelle tentative",
707+
errorDetails: "Détails de l'erreur",
702708
},
703709
settings: {
704710
contentProcessing: "Traitement du contenu",

frontend/src/lib/locales/it-IT/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,12 @@ export const itIT = {
699699
speakerCountMax: "Puoi configurare fino a 4 speaker",
700700
delete: "Elimina",
701701
failedToDelete: "Impossibile eliminare il podcast",
702+
retry: "Riprova",
703+
retrying: "Nuovo tentativo…",
704+
retryStarted: "Nuovo tentativo avviato",
705+
retryStartedDesc: "Un nuovo lavoro di generazione podcast è stato inviato.",
706+
failedToRetry: "Impossibile riprovare",
707+
errorDetails: "Dettagli errore",
702708
},
703709
settings: {
704710
contentProcessing: "Elaborazione contenuti",

0 commit comments

Comments
 (0)