Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/github_agent_bridge/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,19 @@ def fetch_once(self) -> int:
return 0
uids = sorted(int(x) for x in data[0].split() if int(x) > last_uid)
for uid in uids:
st, msgd = imap.uid("fetch", str(uid), "(RFC822)")
try:
st, msgd = imap.uid("fetch", str(uid), "(RFC822)")
except imaplib.IMAP4.error as exc:
self._record_fetch_failure(uid, f"IMAP4.error: {exc}")
if self._is_lookup_failure(exc):
self.queue.set_state("last_uid", str(uid))
continue
break
if st != "OK" or not msgd or not msgd[0]:
self._record_fetch_failure(uid, f"{st}: {msgd!r}")
if self._is_lookup_failure(msgd):
self.queue.set_state("last_uid", str(uid))
continue
break
msg = email.message_from_bytes(msgd[0][1])
from_addr = decode_header_value(msg.get("From", ""))
Expand All @@ -72,3 +83,10 @@ def fetch_once(self) -> int:
imap.logout()
except Exception:
pass

def _record_fetch_failure(self, uid: int, detail: str) -> None:
self.queue.set_state("last_imap_fetch_error_uid", str(uid))
self.queue.set_state("last_imap_fetch_error", detail[:500])

def _is_lookup_failure(self, value: object) -> bool:
return "lookup failed" in str(value).lower()
113 changes: 113 additions & 0 deletions tests/test_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import imaplib

from github_agent_bridge import reader


class FakeQueue:
def __init__(self):
self.state = {}
self.enqueued = []

def get_state(self, key, default=""):
return self.state.get(key, default)

def set_state(self, key, value):
self.state[key] = value

def enqueue(self, notification, policy):
self.enqueued.append(notification)
return None, "enqueued"


class FetchErrorImap:
logged_out = False

def __init__(self, host, port):
self.host = host
self.port = port

def login(self, username, password):
return "OK", []

def select(self, mailbox):
return "OK", []

def uid(self, command, *args):
if command == "search":
return "OK", [b"42"]
if command == "fetch":
raise imaplib.IMAP4.error(b"Lookup failed 423d73b8af312-310163d197dmb4381437a26")
raise AssertionError(command)

def logout(self):
type(self).logged_out = True


class FetchNoImap(FetchErrorImap):
def uid(self, command, *args):
if command == "search":
return "OK", [b"43"]
if command == "fetch":
return "NO", [b"Lookup failed 423d73b8af312-310163d197dmb4381437a26"]
raise AssertionError(command)


class FetchTemporaryNoImap(FetchErrorImap):
def uid(self, command, *args):
if command == "search":
return "OK", [b"44"]
if command == "fetch":
return "NO", [b"Temporary unavailable"]
raise AssertionError(command)


def test_reader_records_and_skips_imap_fetch_lookup_error(monkeypatch):
queue = FakeQueue()
monkeypatch.setattr(reader.imaplib, "IMAP4_SSL", FetchErrorImap)

count = reader.ImapReader(
reader.ImapConfig("imap.example.com", 993, "bot@example.com", "secret"),
queue,
policy=object(),
).fetch_once()

assert count == 0
assert queue.enqueued == []
assert queue.state["last_uid"] == "42"
assert queue.state["last_imap_fetch_error_uid"] == "42"
assert "Lookup failed" in queue.state["last_imap_fetch_error"]
assert FetchErrorImap.logged_out is True


def test_reader_records_and_skips_failed_imap_fetch_status(monkeypatch):
queue = FakeQueue()
monkeypatch.setattr(reader.imaplib, "IMAP4_SSL", FetchNoImap)

count = reader.ImapReader(
reader.ImapConfig("imap.example.com", 993, "bot@example.com", "secret"),
queue,
policy=object(),
).fetch_once()

assert count == 0
assert queue.enqueued == []
assert queue.state["last_uid"] == "43"
assert queue.state["last_imap_fetch_error_uid"] == "43"
assert queue.state["last_imap_fetch_error"].startswith("NO:")


def test_reader_does_not_advance_last_uid_for_non_lookup_fetch_failure(monkeypatch):
queue = FakeQueue()
monkeypatch.setattr(reader.imaplib, "IMAP4_SSL", FetchTemporaryNoImap)

count = reader.ImapReader(
reader.ImapConfig("imap.example.com", 993, "bot@example.com", "secret"),
queue,
policy=object(),
).fetch_once()

assert count == 0
assert queue.enqueued == []
assert "last_uid" not in queue.state
assert queue.state["last_imap_fetch_error_uid"] == "44"
assert "Temporary unavailable" in queue.state["last_imap_fetch_error"]