Skip to content

Commit 81d6599

Browse files
committed
Report unavailable job logs cleanly
1 parent ee64e27 commit 81d6599

7 files changed

Lines changed: 116 additions & 6 deletions

File tree

cwms_batch_events/api/routers/jobs.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,15 @@ def get_job_by_id(
7979
def get_logs_for_job(
8080
job_id: UUID, job_logger: JobLogger = Depends(get_job_logger)
8181
) -> JobLogs:
82-
logs = job_logger.get_logs_for_job(job_id)
82+
try:
83+
logs = job_logger.get_logs_for_job(job_id)
84+
except FileNotFoundError as exc:
85+
raise HTTPException(
86+
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
87+
) from exc
88+
except ValueError as exc:
89+
raise HTTPException(
90+
status_code=status.HTTP_409_CONFLICT,
91+
detail=f"Logs are not available for job '{job_id}': {exc}",
92+
) from exc
8393
return JobLogs(logs=logs)

cwms_batch_events/core/job_logger/cloudwatch.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,19 @@ def get_batch_log_name(self, external_job_id: str) -> str:
3232
f"Multiple jobs found for external_job_id {external_job_id}"
3333
)
3434

35-
return jobs[0]["attempts"][-1]["container"]["logStreamName"]
35+
attempts = jobs[0].get("attempts", [])
36+
if not attempts:
37+
raise ValueError(
38+
f"No Batch job attempts found for external_job_id {external_job_id}"
39+
)
40+
41+
log_stream_name = attempts[-1].get("container", {}).get("logStreamName")
42+
if not log_stream_name:
43+
raise ValueError(
44+
f"No log stream found for external_job_id {external_job_id}"
45+
)
46+
47+
return log_stream_name
3648

3749
def get_logs_for_job(self, job_id: UUID) -> str:
3850
job = self.get_job_details(job_id)

cwms_batch_events/core/job_logger/s3.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import boto3
2+
from botocore.exceptions import ClientError
23
from uuid import UUID
34

45
from cwms_batch_events.core.settings import settings
@@ -12,11 +13,17 @@ def __init__(self):
1213
self.s3 = boto3.client(
1314
"s3",
1415
endpoint_url=S3_ENDPOINT_URL,
16+
region_name=settings.aws_default_region,
1517
)
1618

1719
def get_logs_for_job(self, job_id: UUID) -> str:
1820
key = f"logs/{job_id}.log"
19-
response = self.s3.get_object(Bucket=S3_BUCKET, Key=key)
21+
try:
22+
response = self.s3.get_object(Bucket=S3_BUCKET, Key=key)
23+
except ClientError as exc:
24+
if exc.response.get("Error", {}).get("Code") in {"NoSuchKey", "404"}:
25+
raise FileNotFoundError(f"No logs found for job {job_id}") from exc
26+
raise
2027
body: str = response["Body"].read().decode("utf-8")
2128
return body
2229

tests/cwms_batch_events/api/test_jobs.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,30 @@ def test_get_logs_for_job_returns_logs(client, job_logger):
8888
assert response.status_code == 200
8989
assert response.json() == {"logs": "hello"}
9090
job_logger.get_logs_for_job.assert_called_once()
91+
92+
93+
def test_get_logs_for_job_returns_404_when_logs_missing(client, job_logger):
94+
job_id = str(uuid4())
95+
job_logger.get_logs_for_job.side_effect = FileNotFoundError(
96+
f"No logs found for job {job_id}"
97+
)
98+
99+
response = client.get(f"/jobs/{job_id}/logs")
100+
101+
assert response.status_code == 404
102+
assert response.json() == {"detail": f"No logs found for job {job_id}"}
103+
104+
105+
def test_get_logs_for_job_returns_409_when_logs_not_ready(client, job_logger):
106+
job_id = str(uuid4())
107+
job_logger.get_logs_for_job.side_effect = ValueError("No Batch job attempts found")
108+
109+
response = client.get(f"/jobs/{job_id}/logs")
110+
111+
assert response.status_code == 409
112+
assert response.json() == {
113+
"detail": (
114+
f"Logs are not available for job '{job_id}': "
115+
"No Batch job attempts found"
116+
)
117+
}

tests/cwms_batch_events/core/test_job_loggers.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from uuid import uuid4
44

55
import pytest
6+
from botocore.exceptions import ClientError
67

78
from cwms_batch_events.core.job_logger.cloudwatch import CloudWatchJobLogger
89
from cwms_batch_events.core.job_logger.s3 import S3JobLogger
@@ -24,6 +25,24 @@ def test_s3_job_logger_reads_logs_from_bucket():
2425
assert logs == "hello"
2526

2627

28+
def test_s3_job_logger_reports_missing_logs():
29+
job_id = uuid4()
30+
s3_client = mock.Mock()
31+
s3_client.get_object.side_effect = ClientError(
32+
{"Error": {"Code": "NoSuchKey", "Message": "not found"}},
33+
"GetObject",
34+
)
35+
36+
with mock.patch(
37+
"cwms_batch_events.core.job_logger.s3.boto3.client",
38+
return_value=s3_client,
39+
), mock.patch("cwms_batch_events.core.job_logger.s3.S3_BUCKET", "bucket"):
40+
logger = S3JobLogger()
41+
42+
with pytest.raises(FileNotFoundError, match=f"No logs found for job {job_id}"):
43+
logger.get_logs_for_job(job_id)
44+
45+
2746
def test_s3_job_logger_pushes_logs_to_bucket():
2847
s3_client = mock.Mock()
2948
job_id = uuid4()
@@ -84,6 +103,36 @@ def test_cloudwatch_job_logger_rejects_multiple_batch_jobs():
84103
logger.get_batch_log_name("ext-123")
85104

86105

106+
def test_cloudwatch_job_logger_reports_missing_batch_attempts():
107+
batch_client = mock.Mock()
108+
batch_client.describe_jobs.return_value = {"jobs": [{"attempts": []}]}
109+
110+
with mock.patch(
111+
"cwms_batch_events.core.job_logger.cloudwatch.boto3.client",
112+
side_effect=[batch_client, mock.Mock()],
113+
):
114+
logger = CloudWatchJobLogger(mock.Mock())
115+
116+
with pytest.raises(ValueError, match="No Batch job attempts found"):
117+
logger.get_batch_log_name("ext-123")
118+
119+
120+
def test_cloudwatch_job_logger_reports_missing_log_stream():
121+
batch_client = mock.Mock()
122+
batch_client.describe_jobs.return_value = {
123+
"jobs": [{"attempts": [{"container": {}}]}]
124+
}
125+
126+
with mock.patch(
127+
"cwms_batch_events.core.job_logger.cloudwatch.boto3.client",
128+
side_effect=[batch_client, mock.Mock()],
129+
):
130+
logger = CloudWatchJobLogger(mock.Mock())
131+
132+
with pytest.raises(ValueError, match="No log stream found"):
133+
logger.get_batch_log_name("ext-123")
134+
135+
87136
def test_cloudwatch_job_logger_requires_external_job_id():
88137
job_id = uuid4()
89138
db = mock.Mock()

ui/src/features/jobs-list/JobLogs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ interface JobLogsProps {
77
}
88

99
const JobLogs = ({ jobId, disabled = false }: JobLogsProps) => {
10-
const { data, isLoading, isError } = useJobLogs(jobId, !disabled);
10+
const { data, error, isLoading, isError } = useJobLogs(jobId, !disabled);
1111

1212
let message: string;
1313
if (disabled) message = "Logs unavailable until job is finished";
1414
else if (isLoading) message = "Loading logs...";
15-
else if (isError) message = "Error loading logs!";
15+
else if (isError) message = error?.message ?? "Error loading logs!";
1616
else if (!data) message = "No logs found!";
1717
else message = data.logs;
1818

ui/src/features/jobs-list/useJobLogs.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ const useJobLogs = (jobId: string, enabled: boolean) => {
1818
const fetchJobs = async (jobId: string, token?: string): Promise<JobLogs> => {
1919
const response = await fetchWithAuth(`/api/jobs/${jobId}/logs`, {}, token);
2020
if (!response.ok) {
21-
throw new Error(`Failed to fetch logs for job ${jobId}`);
21+
const payload = await response.json().catch(() => undefined);
22+
const detail =
23+
payload && typeof payload.detail === "string"
24+
? payload.detail
25+
: `Failed to fetch logs for job ${jobId}`;
26+
throw new Error(detail);
2227
}
2328
return response.json();
2429
};

0 commit comments

Comments
 (0)